From 83c486703f9b642da02e9917b6fc17a17c3926d9 Mon Sep 17 00:00:00 2001 From: YuraLitvinov Date: Thu, 15 Oct 2026 03:17:32 +0200 Subject: [PATCH 1/2] Replace the C++/Qt application with a Rust + GTK4 port Squash of the rust-gtk4-port branch (16 parity works). A toolkit-free qldm-core library (device model, sysfs/udev enumeration, naming, properties, storage via UDisks2, network/ethtool, display/EDID, camera helper, system information, Secure Boot/MOK, search, events) and a qldm-gtk front end with the main window, tabbed Properties and System Information windows. Adds gettext translations, a Makefile install recipe, Rust CI and packaging notes; removes CMake, the C++ sources/tests and the Qt translation files. See doc/parity-checklist.md for coverage and known gaps. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 93 - .github/workflows/rust.yml | 49 + .gitignore | 2 + CMakeLists.txt | 218 -- Cargo.lock | 1876 ++++++++++++++ Cargo.toml | 3 + Makefile | 41 + README.md | 30 +- crates/qldm-core/Cargo.toml | 29 + crates/qldm-core/build.rs | 23 + .../qldm-core/src/bin/qldm-camera-helper.rs | 5 + crates/qldm-core/src/camera.rs | 627 +++++ crates/qldm-core/src/cpuinfo.rs | 83 + crates/qldm-core/src/dirfd.rs | 148 ++ crates/qldm-core/src/display.rs | 205 ++ crates/qldm-core/src/display_wayland.rs | 401 +++ crates/qldm-core/src/display_x11.rs | 241 ++ crates/qldm-core/src/efivars.rs | 224 ++ crates/qldm-core/src/enumerate.rs | 279 +++ crates/qldm-core/src/ethernet.rs | 710 ++++++ crates/qldm-core/src/events.rs | 342 +++ crates/qldm-core/src/events_view.rs | 228 ++ crates/qldm-core/src/functionnames.rs | 129 + crates/qldm-core/src/i18n.rs | 193 ++ crates/qldm-core/src/label.rs | 224 ++ crates/qldm-core/src/lib.rs | 45 + crates/qldm-core/src/model.rs | 393 +++ crates/qldm-core/src/modulenames.rs | 394 +++ crates/qldm-core/src/monitor.rs | 351 +++ crates/qldm-core/src/monitor_edid.rs | 505 ++++ crates/qldm-core/src/monitorstate.rs | 152 ++ crates/qldm-core/src/naming.rs | 249 ++ crates/qldm-core/src/network.rs | 250 ++ crates/qldm-core/src/presentation.rs | 261 ++ crates/qldm-core/src/properties.rs | 1149 +++++++++ crates/qldm-core/src/propertytext.rs | 721 ++++++ crates/qldm-core/src/report.rs | 1323 ++++++++++ crates/qldm-core/src/resourceformat.rs | 156 ++ crates/qldm-core/src/resources.rs | 500 ++++ crates/qldm-core/src/search.rs | 458 ++++ crates/qldm-core/src/secureboot.rs | 381 +++ crates/qldm-core/src/storage.rs | 1180 +++++++++ crates/qldm-core/src/storage_presentation.rs | 696 +++++ crates/qldm-core/src/sysfs.rs | 151 ++ crates/qldm-core/src/system.rs | 1078 ++++++++ crates/qldm-core/src/system_report.rs | 582 +++++ crates/qldm-core/src/tree.rs | 1007 ++++++++ crates/qldm-core/src/v4l2_sys.rs | 95 + crates/qldm-core/tests/camera_helper.rs | 85 + crates/qldm-core/tests/translation.rs | 42 + crates/qldm-gtk/Cargo.toml | 15 + crates/qldm-gtk/src/lib.rs | 7 + crates/qldm-gtk/src/main.rs | 19 + crates/qldm-gtk/src/properties/mod.rs | 764 ++++++ crates/qldm-gtk/src/properties/widgets.rs | 202 ++ crates/qldm-gtk/src/row.rs | 89 + crates/qldm-gtk/src/settings.rs | 81 + crates/qldm-gtk/src/system_dialog.rs | 194 ++ crates/qldm-gtk/src/window.rs | 1368 ++++++++++ crates/qldm-gtk/tests/ui_smoke.rs | 212 ++ doc/architecture.md | 7 +- doc/implementation.md | 93 +- doc/parity-checklist.md | 51 + packaging/build-rust.md | 82 + po/qlinuxdevicemanager.pot | 1906 ++++++++++++++ po/update-pot.sh | 10 + project.json | 7 +- resources/embedded/projectmetadata.h.in | 4 - .../translations/qlinuxdevicemanager_en.ts | 2232 ----------------- resources/external/translations/update.sh | 1 - rustfmt.toml | 2 + src/devices/cameraproperties.cpp | 309 --- src/devices/cameraproperties.h | 14 - src/devices/cpumetadata.cpp | 66 - src/devices/cpumetadata.h | 5 - src/devices/device.cpp | 199 -- src/devices/device.h | 66 - src/devices/deviceevents.cpp | 98 - src/devices/deviceevents.h | 60 - src/devices/devicelabel.cpp | 120 - src/devices/devicelabel.h | 7 - src/devices/devicepresentation.cpp | 111 - src/devices/devicepresentation.h | 5 - src/devices/deviceproperties.cpp | 633 ----- src/devices/deviceproperties.h | 71 - src/devices/deviceresources.cpp | 247 -- src/devices/deviceresources.h | 33 - src/devices/devicesearch.cpp | 174 -- src/devices/devicesearch.h | 43 - src/devices/directoryscan.h | 31 - src/devices/displaybackend.h | 25 - src/devices/displayproperties.cpp | 37 - src/devices/displayproperties.h | 13 - src/devices/displaywayland.cpp | 279 --- src/devices/displayx11.cpp | 196 -- src/devices/efivariables.cpp | 116 - src/devices/efivariables.h | 7 - src/devices/enumerator.cpp | 349 --- src/devices/enumerator.h | 22 - src/devices/ethernetproperties.cpp | 306 --- src/devices/ethernetproperties.h | 23 - src/devices/functionnames.cpp | 101 - src/devices/functionnames.h | 8 - src/devices/modulenames.cpp | 101 - src/devices/modulenames.h | 21 - src/devices/monitorproperties.cpp | 207 -- src/devices/monitorproperties.h | 14 - src/devices/monitorstate.cpp | 65 - src/devices/monitorstate.h | 15 - src/devices/networkproperties.cpp | 125 - src/devices/networkproperties.h | 26 - src/devices/propertytext.cpp | 331 --- src/devices/propertytext.h | 21 - src/devices/resourceformat.cpp | 93 - src/devices/resourceformat.h | 15 - src/devices/secureboot.cpp | 143 -- src/devices/secureboot.h | 23 - src/devices/storagepresentation.cpp | 350 --- src/devices/storagepresentation.h | 26 - src/devices/storageproperties.cpp | 651 ----- src/devices/storageproperties.h | 42 - src/devices/systemproperties.cpp | 634 ----- src/devices/systemproperties.h | 54 - src/main.cpp | 59 - src/ui/devicefilter.cpp | 80 - src/ui/devicefilter.h | 19 - src/ui/devicemodel.cpp | 500 ---- src/ui/devicemodel.h | 60 - src/ui/mainwindow.cpp | 560 ----- src/ui/mainwindow.h | 88 - src/ui/propertiesdialog.cpp | 1843 -------------- src/ui/propertiesdialog.h | 100 - src/ui/systemdialog.cpp | 464 ---- src/ui/systemdialog.h | 33 - tests/device-events.cpp | 102 - tests/device-resources.cpp | 57 - tests/device-rules.cpp | 388 --- tests/device-search.cpp | 477 ---- tests/directory-scan.cpp | 58 - tests/monitor-state.cpp | 50 - tests/system-properties.cpp | 177 -- 141 files changed, 23317 insertions(+), 14482 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/rust.yml delete mode 100644 CMakeLists.txt create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 crates/qldm-core/Cargo.toml create mode 100644 crates/qldm-core/build.rs create mode 100644 crates/qldm-core/src/bin/qldm-camera-helper.rs create mode 100644 crates/qldm-core/src/camera.rs create mode 100644 crates/qldm-core/src/cpuinfo.rs create mode 100644 crates/qldm-core/src/dirfd.rs create mode 100644 crates/qldm-core/src/display.rs create mode 100644 crates/qldm-core/src/display_wayland.rs create mode 100644 crates/qldm-core/src/display_x11.rs create mode 100644 crates/qldm-core/src/efivars.rs create mode 100644 crates/qldm-core/src/enumerate.rs create mode 100644 crates/qldm-core/src/ethernet.rs create mode 100644 crates/qldm-core/src/events.rs create mode 100644 crates/qldm-core/src/events_view.rs create mode 100644 crates/qldm-core/src/functionnames.rs create mode 100644 crates/qldm-core/src/i18n.rs create mode 100644 crates/qldm-core/src/label.rs create mode 100644 crates/qldm-core/src/lib.rs create mode 100644 crates/qldm-core/src/model.rs create mode 100644 crates/qldm-core/src/modulenames.rs create mode 100644 crates/qldm-core/src/monitor.rs create mode 100644 crates/qldm-core/src/monitor_edid.rs create mode 100644 crates/qldm-core/src/monitorstate.rs create mode 100644 crates/qldm-core/src/naming.rs create mode 100644 crates/qldm-core/src/network.rs create mode 100644 crates/qldm-core/src/presentation.rs create mode 100644 crates/qldm-core/src/properties.rs create mode 100644 crates/qldm-core/src/propertytext.rs create mode 100644 crates/qldm-core/src/report.rs create mode 100644 crates/qldm-core/src/resourceformat.rs create mode 100644 crates/qldm-core/src/resources.rs create mode 100644 crates/qldm-core/src/search.rs create mode 100644 crates/qldm-core/src/secureboot.rs create mode 100644 crates/qldm-core/src/storage.rs create mode 100644 crates/qldm-core/src/storage_presentation.rs create mode 100644 crates/qldm-core/src/sysfs.rs create mode 100644 crates/qldm-core/src/system.rs create mode 100644 crates/qldm-core/src/system_report.rs create mode 100644 crates/qldm-core/src/tree.rs create mode 100644 crates/qldm-core/src/v4l2_sys.rs create mode 100644 crates/qldm-core/tests/camera_helper.rs create mode 100644 crates/qldm-core/tests/translation.rs create mode 100644 crates/qldm-gtk/Cargo.toml create mode 100644 crates/qldm-gtk/src/lib.rs create mode 100644 crates/qldm-gtk/src/main.rs create mode 100644 crates/qldm-gtk/src/properties/mod.rs create mode 100644 crates/qldm-gtk/src/properties/widgets.rs create mode 100644 crates/qldm-gtk/src/row.rs create mode 100644 crates/qldm-gtk/src/settings.rs create mode 100644 crates/qldm-gtk/src/system_dialog.rs create mode 100644 crates/qldm-gtk/src/window.rs create mode 100644 crates/qldm-gtk/tests/ui_smoke.rs create mode 100644 doc/parity-checklist.md create mode 100644 packaging/build-rust.md create mode 100644 po/qlinuxdevicemanager.pot create mode 100755 po/update-pot.sh delete mode 100644 resources/embedded/projectmetadata.h.in delete mode 100644 resources/external/translations/qlinuxdevicemanager_en.ts delete mode 100755 resources/external/translations/update.sh create mode 100644 rustfmt.toml delete mode 100644 src/devices/cameraproperties.cpp delete mode 100644 src/devices/cameraproperties.h delete mode 100644 src/devices/cpumetadata.cpp delete mode 100644 src/devices/cpumetadata.h delete mode 100644 src/devices/device.cpp delete mode 100644 src/devices/device.h delete mode 100644 src/devices/deviceevents.cpp delete mode 100644 src/devices/deviceevents.h delete mode 100644 src/devices/devicelabel.cpp delete mode 100644 src/devices/devicelabel.h delete mode 100644 src/devices/devicepresentation.cpp delete mode 100644 src/devices/devicepresentation.h delete mode 100644 src/devices/deviceproperties.cpp delete mode 100644 src/devices/deviceproperties.h delete mode 100644 src/devices/deviceresources.cpp delete mode 100644 src/devices/deviceresources.h delete mode 100644 src/devices/devicesearch.cpp delete mode 100644 src/devices/devicesearch.h delete mode 100644 src/devices/directoryscan.h delete mode 100644 src/devices/displaybackend.h delete mode 100644 src/devices/displayproperties.cpp delete mode 100644 src/devices/displayproperties.h delete mode 100644 src/devices/displaywayland.cpp delete mode 100644 src/devices/displayx11.cpp delete mode 100644 src/devices/efivariables.cpp delete mode 100644 src/devices/efivariables.h delete mode 100644 src/devices/enumerator.cpp delete mode 100644 src/devices/enumerator.h delete mode 100644 src/devices/ethernetproperties.cpp delete mode 100644 src/devices/ethernetproperties.h delete mode 100644 src/devices/functionnames.cpp delete mode 100644 src/devices/functionnames.h delete mode 100644 src/devices/modulenames.cpp delete mode 100644 src/devices/modulenames.h delete mode 100644 src/devices/monitorproperties.cpp delete mode 100644 src/devices/monitorproperties.h delete mode 100644 src/devices/monitorstate.cpp delete mode 100644 src/devices/monitorstate.h delete mode 100644 src/devices/networkproperties.cpp delete mode 100644 src/devices/networkproperties.h delete mode 100644 src/devices/propertytext.cpp delete mode 100644 src/devices/propertytext.h delete mode 100644 src/devices/resourceformat.cpp delete mode 100644 src/devices/resourceformat.h delete mode 100644 src/devices/secureboot.cpp delete mode 100644 src/devices/secureboot.h delete mode 100644 src/devices/storagepresentation.cpp delete mode 100644 src/devices/storagepresentation.h delete mode 100644 src/devices/storageproperties.cpp delete mode 100644 src/devices/storageproperties.h delete mode 100644 src/devices/systemproperties.cpp delete mode 100644 src/devices/systemproperties.h delete mode 100644 src/main.cpp delete mode 100644 src/ui/devicefilter.cpp delete mode 100644 src/ui/devicefilter.h delete mode 100644 src/ui/devicemodel.cpp delete mode 100644 src/ui/devicemodel.h delete mode 100644 src/ui/mainwindow.cpp delete mode 100644 src/ui/mainwindow.h delete mode 100644 src/ui/propertiesdialog.cpp delete mode 100644 src/ui/propertiesdialog.h delete mode 100644 src/ui/systemdialog.cpp delete mode 100644 src/ui/systemdialog.h delete mode 100644 tests/device-events.cpp delete mode 100644 tests/device-resources.cpp delete mode 100644 tests/device-rules.cpp delete mode 100644 tests/device-search.cpp delete mode 100644 tests/directory-scan.cpp delete mode 100644 tests/monitor-state.cpp delete mode 100644 tests/system-properties.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index b386fba..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Linux AppImage - -on: [push, pull_request, workflow_dispatch] - -permissions: - contents: read - -jobs: - appimage: - name: Linux AppImage (Qt 6.8, x86_64) - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential cmake ninja-build pkg-config python3 wget file \ - libudev-dev libkmod-dev libssl-dev libgl-dev libegl-dev \ - libxkbcommon-dev libxcb-cursor0 libxcb-randr0-dev libwayland-dev libdisplay-info-dev libv4l-dev - pkg-config --atleast-version=30 libkmod - pkg-config --atleast-version=0.1.1 libdisplay-info - - # Ubuntu 24.04's packaged Qt is older than our required Qt 6.8. - - name: Install Qt - uses: jurplel/install-qt-action@v4 - with: - version: '6.8.3' - host: linux - target: desktop - arch: linux_gcc_64 - cache: true - - - name: Build and stage AppDir - run: | - set -euo pipefail - cmake -S . -B _build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_INSTALL_PREFIX=/usr \ - -DCMAKE_INSTALL_LIBDIR=lib \ - -DBUILD_TESTING=OFF -DKMOD=ON -DUDISKS2=ON -DDISPLAY_INFO=ON - cmake --build _build --parallel "$(nproc)" - DESTDIR="$PWD/_build/AppDir" cmake --install _build - install -Dm644 LICENSE _build/AppDir/usr/share/licenses/QLinuxDeviceManager/LICENSE - python3 - <<'PY' >> "$GITHUB_ENV" - import json - from pathlib import Path - metadata = json.loads(Path("project.json").read_text()) - print("PROJECT_NAME=" + metadata["identity"]["name"]) - print("PROJECT_VERSION=" + metadata["release"]["version"]) - PY - - - name: Package AppImage - run: | - set -euo pipefail - tools="$RUNNER_TEMP/linuxdeploy" - mkdir -p "$tools" _build/artifacts - wget -q -O "$tools/linuxdeploy-x86_64.AppImage" \ - https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage - wget -q -O "$tools/linuxdeploy-plugin-qt-x86_64.AppImage" \ - https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage - chmod +x "$tools"/*.AppImage - export PATH="$tools:$PATH" - export APPIMAGE_EXTRACT_AND_RUN=1 - export QMAKE="$QT_ROOT_DIR/bin/qmake" - export LD_LIBRARY_PATH="$QT_ROOT_DIR/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - export EXTRA_PLATFORM_PLUGINS="libqwayland-egl.so;libqwayland-generic.so" - export EXTRA_QT_MODULES="waylandclient;waylandcompositor" - export LDAI_OUTPUT="$PWD/_build/artifacts/$PROJECT_NAME-$PROJECT_VERSION-Linux-x86_64.AppImage" - export MKSQUASHFS_ARGUMENTS="-comp zstd -Xcompression-level 22 -b 1M" - # Qt's OpenSSL backend loads these at runtime; ELF scanning alone can miss them. - "$tools/linuxdeploy-x86_64.AppImage" \ - --appdir _build/AppDir \ - --desktop-file _build/AppDir/usr/share/applications/io.github.birdie-github.QLinuxDeviceManager.desktop \ - --icon-file _build/AppDir/usr/share/icons/hicolor/512x512/apps/io.github.birdie-github.QLinuxDeviceManager.png \ - --library /usr/lib/x86_64-linux-gnu/libssl.so.3 \ - --library /usr/lib/x86_64-linux-gnu/libcrypto.so.3 \ - --library /usr/lib/x86_64-linux-gnu/libxcb.so.1 \ - --library /usr/lib/x86_64-linux-gnu/libxcb-randr.so.0 \ - --library /usr/lib/x86_64-linux-gnu/libwayland-client.so.0 \ - --plugin qt \ - --output appimage - test -f "$LDAI_OUTPUT" - test -f _build/AppDir/usr/plugins/tls/libqopensslbackend.so - - - name: Upload Linux AppImage - uses: actions/upload-artifact@v4 - with: - name: ${{ env.PROJECT_NAME }}-Linux-AppImage-x86_64-${{ github.sha }} - path: _build/artifacts/*.AppImage - if-no-files-found: error - retention-days: 14 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..f8616f6 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,49 @@ +name: Rust + +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: read + +jobs: + rust: + name: fmt, clippy, test (Ubuntu 24.04) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libgtk-4-dev libudev-dev libkmod-dev libdisplay-info-dev \ + libv4l-dev libwayland-dev libxcb-randr0-dev clang libclang-dev gettext desktop-file-utils \ + xvfb at-spi2-core + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Format + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: Unit and integration tests + run: cargo test --workspace --locked --exclude qldm-gtk + + # The UI smoke test needs a display; xvfb provides an X11 one. + - name: UI smoke test + run: xvfb-run -a cargo test -p qldm-gtk --locked --test ui_smoke + + - name: Desktop entry and message catalogue + run: | + desktop-file-validate packaging/io.github.birdie-github.QLinuxDeviceManager.desktop + po/update-pot.sh + git diff --exit-code -I POT-Creation-Date po/qlinuxdevicemanager.pot + + - name: Install into a staging directory + run: make install PREFIX=/usr DESTDIR="$PWD/stage" diff --git a/.gitignore b/.gitignore index f73177a..fde01ce 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ BUILD/ *.patch *.diff .directory +executable_graph.k.json +/target diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index f3ad239..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,218 +0,0 @@ -cmake_minimum_required(VERSION 3.19) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/project.json" PROJECT_METADATA) -string(JSON APP_NAME GET "${PROJECT_METADATA}" identity name) -string(JSON APP_VERSION GET "${PROJECT_METADATA}" release version) -string(JSON APP_REPOSITORY GET "${PROJECT_METADATA}" project repository) -project(${APP_NAME} VERSION ${APP_VERSION} LANGUAGES CXX) -set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS project.json) -configure_file(resources/embedded/projectmetadata.h.in generated/projectmetadata.h @ONLY) -if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") - message(FATAL_ERROR "QLinuxDeviceManager supports Linux only") -endif() -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) -set(CMAKE_AUTOMOC ON) -find_package(Qt6 6.8 REQUIRED COMPONENTS Widgets Network) -# Optional read-only UDisks2 cache access. Native storage viewing still works without it. -option(UDISKS2 "Use optional QtDBus for cached UDisks2 storage metadata" ON) -if(UDISKS2) - find_package(Qt6 6.8 QUIET COMPONENTS DBus) -endif() -find_package(PkgConfig REQUIRED) -pkg_check_modules(UDEV REQUIRED IMPORTED_TARGET libudev) -pkg_check_modules(V4L2 REQUIRED IMPORTED_TARGET libv4l2) -# Headers only: session libraries are opened on demand with QLibrary (dlopen). -# Do not link PkgConfig targets for these backends into the executable. -pkg_check_modules(DISPLAY_X11 REQUIRED xcb-randr) -pkg_check_modules(DISPLAY_WAYLAND REQUIRED wayland-client>=1.20) -option(DISPLAY_INFO "Use optional libdisplay-info for monitor EDID decoding" ON) -if(DISPLAY_INFO) - pkg_check_modules(DISPLAY_INFO QUIET IMPORTED_TARGET libdisplay-info>=0.1.1) -endif() -option(KMOD "Use optional libkmod for function descriptions and installed module properties" ON) -if(KMOD) - pkg_check_modules(KMOD QUIET IMPORTED_TARGET libkmod>=30) -endif() -add_executable(qlinuxdevicemanager - src/main.cpp src/devices/device.h src/devices/device.cpp - src/devices/enumerator.h src/devices/enumerator.cpp - src/devices/monitorstate.h src/devices/monitorstate.cpp - src/devices/deviceevents.h src/devices/deviceevents.cpp - src/devices/deviceproperties.h src/devices/deviceproperties.cpp - src/devices/networkproperties.h src/devices/networkproperties.cpp - src/devices/ethernetproperties.h src/devices/ethernetproperties.cpp - src/devices/displayproperties.h src/devices/displayproperties.cpp - src/devices/monitorproperties.h src/devices/monitorproperties.cpp - src/devices/cameraproperties.h src/devices/cameraproperties.cpp - src/devices/displaybackend.h src/devices/displayx11.cpp src/devices/displaywayland.cpp - src/devices/storageproperties.h src/devices/storageproperties.cpp - src/devices/directoryscan.h - src/devices/storagepresentation.h src/devices/storagepresentation.cpp - src/devices/deviceresources.h src/devices/deviceresources.cpp - src/devices/resourceformat.h src/devices/resourceformat.cpp - src/devices/cpumetadata.h src/devices/cpumetadata.cpp - src/devices/functionnames.h src/devices/functionnames.cpp - src/devices/devicelabel.h src/devices/devicelabel.cpp - src/devices/efivariables.h src/devices/efivariables.cpp - src/devices/modulenames.h src/devices/modulenames.cpp - src/devices/devicepresentation.h src/devices/devicepresentation.cpp - src/devices/devicesearch.h src/devices/devicesearch.cpp - src/devices/propertytext.h src/devices/propertytext.cpp - src/ui/devicefilter.h src/ui/devicefilter.cpp - src/ui/devicemodel.h src/ui/devicemodel.cpp - src/ui/mainwindow.h src/ui/mainwindow.cpp - src/ui/propertiesdialog.h src/ui/propertiesdialog.cpp - src/devices/secureboot.h src/devices/secureboot.cpp - src/devices/systemproperties.h src/devices/systemproperties.cpp - src/ui/systemdialog.h src/ui/systemdialog.cpp) -# Embed existing PNG assets so a build-tree executable has its own icon without -# an installed icon theme or Qt SVG image plugin. Keep every supplied raster size. -set(APP_ICON_SIZES 16 32 48 64 128 256 512) -set(APP_ICON_FILES) -foreach(icon_size IN LISTS APP_ICON_SIZES) - list(APPEND APP_ICON_FILES - resources/external/icons/${icon_size}x${icon_size}/QLinuxDeviceManager.png) -endforeach() -qt_add_resources(qlinuxdevicemanager app_icons - PREFIX "/icons" - BASE "resources/external/icons" - FILES ${APP_ICON_FILES}) - -target_include_directories(qlinuxdevicemanager PRIVATE - src/devices src/ui "${CMAKE_CURRENT_BINARY_DIR}/generated" - ${DISPLAY_X11_INCLUDE_DIRS} ${DISPLAY_WAYLAND_INCLUDE_DIRS}) -target_link_libraries(qlinuxdevicemanager PRIVATE Qt6::Widgets Qt6::Network PkgConfig::UDEV PkgConfig::V4L2) -if(DISPLAY_INFO AND TARGET PkgConfig::DISPLAY_INFO) - target_link_libraries(qlinuxdevicemanager PRIVATE PkgConfig::DISPLAY_INFO) - target_compile_definitions(qlinuxdevicemanager PRIVATE QLDM_HAVE_DISPLAY_INFO) - message(STATUS "Monitor EDID decoding: enabled (libdisplay-info)") -else() - message(STATUS "Monitor EDID decoding: disabled; kernel mode names remain available") -endif() -if(KMOD AND TARGET PkgConfig::KMOD) - target_link_libraries(qlinuxdevicemanager PRIVATE PkgConfig::KMOD) - target_compile_definitions(qlinuxdevicemanager PRIVATE QLDM_HAVE_KMOD) - message(STATUS "Module description naming: enabled (libkmod)") -else() - message(STATUS "Module description naming: disabled (libkmod >= 30 unavailable or disabled)") -endif() -if(UDISKS2 AND TARGET Qt6::DBus) - target_link_libraries(qlinuxdevicemanager PRIVATE Qt6::DBus) - target_compile_definitions(qlinuxdevicemanager PRIVATE QLDM_HAVE_DBUS) - message(STATUS "Cached UDisks2 storage metadata: enabled (QtDBus)") -else() - message(STATUS "Cached UDisks2 storage metadata: disabled; native fallback available") -endif() -target_compile_options(qlinuxdevicemanager PRIVATE -Wall -Wextra -Wpedantic) -include(GNUInstallDirs) -install(TARGETS qlinuxdevicemanager RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) -install(FILES packaging/io.github.birdie-github.QLinuxDeviceManager.desktop - DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) -# Match the desktop entry's Icon key and Qt desktop file identity. -set(APP_DESKTOP_ID io.github.birdie-github.QLinuxDeviceManager) -foreach(icon_size IN LISTS APP_ICON_SIZES) - install(FILES resources/external/icons/${icon_size}x${icon_size}/QLinuxDeviceManager.png - DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${icon_size}x${icon_size}/apps - RENAME ${APP_DESKTOP_ID}.png) -endforeach() -install(FILES resources/external/icons/QLinuxDeviceManager.svg - DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps - RENAME ${APP_DESKTOP_ID}.svg) -install(FILES project.json DESTINATION ${CMAKE_INSTALL_DATADIR}/QLinuxDeviceManager) -# Optional tools: basic viewing never requires Linguist. -find_package(Qt6 6.8 QUIET COMPONENTS LinguistTools) -if(TARGET Qt6::lupdate) - add_custom_target(update-translations - COMMAND Qt6::lupdate ${CMAKE_CURRENT_SOURCE_DIR}/src - -ts ${CMAKE_CURRENT_SOURCE_DIR}/resources/external/translations/qlinuxdevicemanager_en.ts) -endif() -if(TARGET Qt6::lrelease) - qt_add_translation(QM_FILES resources/external/translations/qlinuxdevicemanager_en.ts) - add_custom_target(translations ALL DEPENDS ${QM_FILES}) - install(FILES ${QM_FILES} DESTINATION ${CMAKE_INSTALL_DATADIR}/QLinuxDeviceManager/translations) -endif() -include(CTest) -if(BUILD_TESTING) - add_executable(directory-scan-test tests/directory-scan.cpp src/devices/directoryscan.h) - target_include_directories(directory-scan-test PRIVATE src/devices) - target_link_libraries(directory-scan-test PRIVATE Qt6::Core) - add_test(NAME directory-scan COMMAND directory-scan-test) - add_executable(monitor-state-test tests/monitor-state.cpp - src/devices/monitorstate.cpp src/devices/monitorstate.h) - target_include_directories(monitor-state-test PRIVATE src/devices) - target_link_libraries(monitor-state-test PRIVATE Qt6::Core) - add_test(NAME monitor-state COMMAND monitor-state-test) - add_executable(device-events-test tests/device-events.cpp - src/devices/deviceevents.cpp src/devices/deviceevents.h) - target_include_directories(device-events-test PRIVATE src/devices) - target_link_libraries(device-events-test PRIVATE Qt6::Core) - add_test(NAME device-events COMMAND device-events-test) - add_executable(device-rules-test tests/device-rules.cpp - src/devices/device.cpp src/devices/device.h - src/devices/cpumetadata.cpp src/devices/cpumetadata.h - src/devices/functionnames.cpp src/devices/functionnames.h - src/devices/devicelabel.cpp src/devices/devicelabel.h - src/devices/efivariables.cpp src/devices/efivariables.h - src/devices/devicepresentation.cpp src/devices/devicepresentation.h) - target_include_directories(device-rules-test PRIVATE src/devices) - target_link_libraries(device-rules-test PRIVATE Qt6::Widgets) - add_test(NAME device-rules COMMAND device-rules-test) - add_executable(device-resources-test tests/device-resources.cpp - src/devices/deviceresources.cpp src/devices/deviceresources.h) - target_include_directories(device-resources-test PRIVATE src/devices) - target_link_libraries(device-resources-test PRIVATE Qt6::Core) - add_test(NAME device-resources COMMAND device-resources-test) - add_executable(device-search-test tests/device-search.cpp - src/devices/devicesearch.cpp src/devices/devicesearch.h - src/devices/deviceproperties.cpp src/devices/deviceproperties.h - src/devices/networkproperties.cpp src/devices/networkproperties.h - src/devices/ethernetproperties.cpp src/devices/ethernetproperties.h - src/devices/displayproperties.cpp src/devices/displayproperties.h - src/devices/monitorproperties.cpp src/devices/monitorproperties.h - src/devices/cameraproperties.cpp src/devices/cameraproperties.h - src/devices/displaybackend.h src/devices/displayx11.cpp src/devices/displaywayland.cpp - src/devices/storageproperties.cpp src/devices/storageproperties.h - src/devices/storagepresentation.cpp src/devices/storagepresentation.h - src/devices/deviceresources.cpp src/devices/deviceresources.h - src/devices/device.cpp src/devices/device.h - src/devices/devicelabel.cpp src/devices/devicelabel.h - src/devices/propertytext.cpp src/devices/propertytext.h - src/devices/resourceformat.cpp src/devices/resourceformat.h - src/devices/efivariables.cpp src/devices/efivariables.h - src/devices/functionnames.cpp src/devices/functionnames.h - src/ui/devicemodel.cpp src/ui/devicemodel.h - src/ui/devicefilter.cpp src/ui/devicefilter.h) - target_include_directories(device-search-test PRIVATE src/devices src/ui - ${DISPLAY_X11_INCLUDE_DIRS} ${DISPLAY_WAYLAND_INCLUDE_DIRS}) - target_link_libraries(device-search-test PRIVATE Qt6::Widgets PkgConfig::UDEV PkgConfig::V4L2) - if(DISPLAY_INFO AND TARGET PkgConfig::DISPLAY_INFO) - target_link_libraries(device-search-test PRIVATE PkgConfig::DISPLAY_INFO) - target_compile_definitions(device-search-test PRIVATE QLDM_HAVE_DISPLAY_INFO) - endif() - if(KMOD AND TARGET PkgConfig::KMOD) - target_link_libraries(device-search-test PRIVATE PkgConfig::KMOD) - target_compile_definitions(device-search-test PRIVATE QLDM_HAVE_KMOD) - endif() - if(UDISKS2 AND TARGET Qt6::DBus) - target_link_libraries(device-search-test PRIVATE Qt6::DBus) - target_compile_definitions(device-search-test PRIVATE QLDM_HAVE_DBUS) - endif() - add_test(NAME device-search COMMAND device-search-test) - add_executable(system-properties-test tests/system-properties.cpp - src/devices/systemproperties.cpp src/devices/systemproperties.h - src/devices/secureboot.cpp src/devices/secureboot.h - src/devices/cpumetadata.cpp src/devices/cpumetadata.h) - target_include_directories(system-properties-test PRIVATE src/devices) - target_link_libraries(system-properties-test PRIVATE Qt6::Core Qt6::Network PkgConfig::UDEV) - add_test(NAME system-properties COMMAND system-properties-test) -endif() - -# Apply Qt's 6.8 API checks only to project executables, including fixture targets. -foreach(qldm_target IN ITEMS qlinuxdevicemanager monitor-state-test device-events-test - device-rules-test device-resources-test device-search-test system-properties-test - directory-scan-test) - if(TARGET ${qldm_target}) - target_compile_definitions(${qldm_target} PRIVATE QT_ENABLE_STRICT_MODE_UP_TO=0x060800) - endif() -endforeach() diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5ed6a32 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1876 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[package]] +name = "glib" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qldm-core" +version = "0.1.0" +dependencies = [ + "libc", + "libloading", + "serde_json", + "tempfile", + "udev", + "wayland-client", + "wayland-protocols", + "x11rb", + "x509-parser", + "zbus", +] + +[[package]] +name = "qldm-gtk" +version = "0.1.0" +dependencies = [ + "async-channel", + "gtk4", + "libc", + "qldm-core", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[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.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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eaf463b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["crates/qldm-core", "crates/qldm-gtk"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6bc022f --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# Install recipe for the Rust build: make && sudo make install PREFIX=/usr +# Packagers: make install DESTDIR=$PWD/pkg PREFIX=/usr +PREFIX ?= /usr +DESTDIR ?= +BINDIR ?= $(PREFIX)/bin +DATADIR ?= $(PREFIX)/share +LOCALEDIR ?= $(DATADIR)/locale +APP_ID := io.github.birdie-github.QLinuxDeviceManager +EXE := qlinuxdevicemanager +ICON_SIZES := 16 32 48 64 128 256 512 +LINGUAS := $(basename $(notdir $(wildcard po/*.po))) + +all: + QLDM_LOCALE_DIR=$(LOCALEDIR) cargo build --release --workspace --locked + +check: + cargo test --workspace --locked + +# The camera helper must sit next to the GUI binary: the GUI looks for it there. +install: all + install -Dm755 target/release/qldm-gtk $(DESTDIR)$(BINDIR)/$(EXE) + install -Dm755 target/release/qldm-camera-helper $(DESTDIR)$(BINDIR)/qldm-camera-helper + install -Dm644 packaging/$(APP_ID).desktop $(DESTDIR)$(DATADIR)/applications/$(APP_ID).desktop + for s in $(ICON_SIZES); do \ + install -Dm644 resources/external/icons/$${s}x$${s}/QLinuxDeviceManager.png \ + $(DESTDIR)$(DATADIR)/icons/hicolor/$${s}x$${s}/apps/$(APP_ID).png; done + install -Dm644 resources/external/icons/QLinuxDeviceManager.svg \ + $(DESTDIR)$(DATADIR)/icons/hicolor/scalable/apps/$(APP_ID).svg + install -Dm644 project.json $(DESTDIR)$(DATADIR)/QLinuxDeviceManager/project.json + install -Dm644 LICENSE $(DESTDIR)$(DATADIR)/licenses/QLinuxDeviceManager/LICENSE + for l in $(LINGUAS); do \ + install -d $(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES && \ + msgfmt po/$$l.po -o $(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/qlinuxdevicemanager.mo; done + +validate: + desktop-file-validate packaging/$(APP_ID).desktop + +pot: + po/update-pot.sh + +.PHONY: all check install validate pot diff --git a/README.md b/README.md index 29e104b..6849570 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ does not upload it. ![QLinuxDeviceManager showing the Linux hardware device tree](screenshots/main-window.webp) [More screenshots](SCREENSHOTS.md) · -[Download an AppImage](https://github.com/birdie-github/QLinuxDeviceManager/releases) · +[Releases](https://github.com/birdie-github/QLinuxDeviceManager/releases) · [Build from source](doc/implementation.md#build-and-installation) ## What you can see @@ -64,33 +64,23 @@ field in specialized hardware tabs. - **Live monitoring** - no need to restart the app to discover newly attached devices. - **Can show hidden or virtual devices**. -## Download and run on Linux +## Build and run on Linux -An **x86_64 AppImage** is available at [the releases](https://github.com/birdie-github/QLinuxDeviceManager/releases). - -In the folder containing the extracted AppImage, run: - -```sh -chmod +x QLinuxDeviceManager-*.AppImage -./QLinuxDeviceManager-*.AppImage -``` - -The AppImage includes Qt and support for X11 and Wayland. It is built on Ubuntu -24.04; compatibility with older distributions is not guaranteed. Workflow downloads -expire after 14 days. - -If it fails to start because FUSE is unavailable, try: +The application is a Rust program using GTK4. Build it from a source checkout: ```sh -./QLinuxDeviceManager-*.AppImage --appimage-extract-and-run +cargo build --release +./target/release/qldm-gtk ``` -To compile and install the application yourself, see the -[build instructions](doc/implementation.md#build-and-installation). +or install it (with the camera helper, desktop entry, icons and translations) via +`sudo make install PREFIX=/usr/local`. See the +[build instructions](doc/implementation.md#build-and-installation) for dependencies and +[packaging notes](packaging/build-rust.md) for Flatpak and AppImage. ## Getting started -Launch the AppImage, or open **QLinuxDeviceManager** from your application menu +Launch `qlinuxdevicemanager`, or open **QLinuxDeviceManager** from your application menu if installed. The installed command is `qlinuxdevicemanager`. 1. Expand a category in the device tree and select a device. diff --git a/crates/qldm-core/Cargo.toml b/crates/qldm-core/Cargo.toml new file mode 100644 index 0000000..a19991d --- /dev/null +++ b/crates/qldm-core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "qldm-core" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" + +[dependencies] +udev = { version = "0.9", features = ["hwdb"] } +libc = "0.2" +libloading = { version = "0.8", optional = true } +zbus = { version = "5", optional = true } +serde_json = "1" +x509-parser = "0.18" +x11rb = { version = "0.13", features = ["randr", "render"] } +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client", "unstable"] } + +[features] +default = ["kmod", "udisks2", "display-info"] +# Module descriptions via libkmod, opened at run time; absence only disables them. +kmod = ["dep:libloading"] +# Cached UDisks2 storage metadata over the system bus (read-only; never starts the daemon). +udisks2 = ["dep:zbus"] +# Monitor EDID name/serial decoding via libdisplay-info, opened at run time. +display-info = ["dep:libloading"] + +[dev-dependencies] +tempfile = "3" +libc = "0.2" diff --git a/crates/qldm-core/build.rs b/crates/qldm-core/build.rs new file mode 100644 index 0000000..39058dd --- /dev/null +++ b/crates/qldm-core/build.rs @@ -0,0 +1,23 @@ +//! Reads name, version and repository from `project.json` (the single source of truth, as the +//! CMake build did) and exports them as compile-time environment variables. +use std::path::Path; + +fn field<'a>(json: &'a str, key: &str) -> &'a str { + let needle = format!("\"{key}\""); + let at = json.find(&needle).unwrap_or_else(|| panic!("project.json has no {key}")); + let rest = &json[at + needle.len()..]; + let start = rest.find('"').expect("string value") + 1; + let end = start + rest[start..].find('"').expect("closing quote"); + &rest[start..end] +} + +fn main() { + let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let path = Path::new(&manifest).join("../../project.json"); + println!("cargo:rerun-if-changed={}", path.display()); + let json = std::fs::read_to_string(&path).expect("project.json"); + println!("cargo:rerun-if-env-changed=QLDM_LOCALE_DIR"); + println!("cargo:rustc-env=QLDM_NAME={}", field(&json, "name")); + println!("cargo:rustc-env=QLDM_VERSION={}", field(&json, "version")); + println!("cargo:rustc-env=QLDM_REPOSITORY={}", field(&json, "repository")); +} diff --git a/crates/qldm-core/src/bin/qldm-camera-helper.rs b/crates/qldm-core/src/bin/qldm-camera-helper.rs new file mode 100644 index 0000000..92f4709 --- /dev/null +++ b/crates/qldm-core/src/bin/qldm-camera-helper.rs @@ -0,0 +1,5 @@ +//! Short-lived helper process for V4L2 queries; see `qldm_core::camera`. +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + std::process::exit(qldm_core::camera::run_camera_query_helper(&args)); +} diff --git a/crates/qldm-core/src/camera.rs b/crates/qldm-core/src/camera.rs new file mode 100644 index 0000000..aee88bd --- /dev/null +++ b/crates/qldm-core/src/camera.rs @@ -0,0 +1,627 @@ +//! Camera capability reading, ported from `src/devices/cameraproperties.cpp`. +//! +//! V4L2 capability, format, frame-size and frame-interval enumeration only: the node is opened +//! read-only and non-blocking, no format is ever set and no stream is started. The queries run +//! in a short-lived helper process (`qldm-camera-helper`) so a driver that hangs in an ioctl can +//! be killed after the 2 s budget instead of wedging the application. The helper dies with its +//! parent, and a helper that cannot be reaped blocks further helpers rather than piling up. +use crate::dirfd::{io_errno, Dir}; +use crate::model::{Attribute, ReadState}; +use crate::v4l2_sys::*; +use serde_json::{json, Map, Value}; +use std::io::Read; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; +use std::os::unix::process::ExitStatusExt; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const REPORT_LIMIT: usize = 1024 * 1024; +const MODE_LIMIT: usize = 2048; +const ENUMERATION_LIMIT: u32 = 128; +const QUERY_LIMIT: u32 = 4096; +const BUDGET: Duration = Duration::from_millis(2000); + +const BUF_TYPE_VIDEO_CAPTURE: u32 = 1; +const BUF_TYPE_VIDEO_CAPTURE_MPLANE: u32 = 9; +const FRMSIZE_DISCRETE: u32 = 1; +const FRMSIZE_CONTINUOUS: u32 = 2; +const FRMSIZE_STEPWISE: u32 = 3; +const FRMIVAL_DISCRETE: u32 = 1; +const FRMIVAL_CONTINUOUS: u32 = 2; +const FRMIVAL_STEPWISE: u32 = 3; + +/// `_IOC(dir, 'V', nr, size)` for the generic ioctl encoding used on Linux. +const fn ioc(direction: u64, nr: u64, size: usize) -> libc::c_ulong { + ((direction << 30) | ((size as u64) << 16) | ((b'V' as u64) << 8) | nr) as libc::c_ulong +} +const IOC_READ: u64 = 2; +const IOC_RW: u64 = 3; +const VIDIOC_QUERYCAP: libc::c_ulong = ioc(IOC_READ, 0, std::mem::size_of::()); +const VIDIOC_ENUM_FMT: libc::c_ulong = ioc(IOC_RW, 2, std::mem::size_of::()); +const VIDIOC_ENUM_FRAMESIZES: libc::c_ulong = ioc(IOC_RW, 74, std::mem::size_of::()); +const VIDIOC_ENUM_FRAMEINTERVALS: libc::c_ulong = ioc(IOC_RW, 75, std::mem::size_of::()); + +#[derive(Clone, Debug, Default)] +pub struct CameraProperties { + pub applicable: bool, + pub state: Attribute, + pub node: String, + /// Owned, bounded helper report: each mode retains its queue/format/size/interval relation. + pub report: Value, +} + +fn failure(error: i32) -> Attribute { + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENODEV | libc::ENOENT | libc::ENXIO => ReadState::Removed, + libc::ENOTTY | libc::EOPNOTSUPP | libc::EINVAL => ReadState::Unsupported, + _ => ReadState::Error, + }; + Attribute { state, value: String::new(), error } +} + +fn failed(error: i32) -> Value { + json!({ "error": error }) +} + +/// A fixed-size C string field that must be NUL-terminated valid UTF-8. +fn text(field: &[u8]) -> Option { + let length = field.iter().position(|b| *b == 0)?; + String::from_utf8(field[..length].to_vec()).ok() +} + +fn fraction(value: &v4l2_fract) -> Value { + json!([value.numerator, value.denominator]) +} + +fn valid_fraction(value: &v4l2_fract) -> bool { + value.numerator != 0 && value.denominator != 0 +} + +/// `a <= b` as fractions, compared exactly. +fn ordered(a: &v4l2_fract, b: &v4l2_fract) -> bool { + a.numerator as u64 * b.denominator as u64 <= b.numerator as u64 * a.denominator as u64 +} + +struct Query { + fd: i32, + started: Instant, + calls: u32, + error: i32, +} + +impl Query { + fn new(fd: i32) -> Self { + Self { fd, started: Instant::now(), calls: 0, error: 0 } + } + + /// Plain ioctl passthrough (no libv4l converters, emulated formats or capture setup). + fn get(&mut self, request: libc::c_ulong, value: &mut T) -> bool { + self.calls += 1; + if self.calls > QUERY_LIMIT { + self.error = libc::EOVERFLOW; + return false; + } + loop { + if self.started.elapsed() >= BUDGET { + self.error = libc::ETIMEDOUT; + return false; + } + // SAFETY: `value` is a valid, exclusively borrowed struct of the size the request + // number encodes; the kernel reads and writes only within it. + if unsafe { libc::ioctl(self.fd, request as _, value as *mut T) } == 0 { + self.error = 0; + return true; + } + self.error = io_errno(&std::io::Error::last_os_error()); + if self.error != libc::EINTR { + return false; + } + } + } +} + +fn zeroed() -> T { + // SAFETY: only used for the plain-old-data V4L2 structs, for which all-zero is valid. + unsafe { std::mem::zeroed() } +} + +fn intervals(query: &mut Query, format: u32, width: u32, height: u32) -> Value { + let mut discrete = Vec::new(); + for index in 0..=ENUMERATION_LIMIT { + let mut value: v4l2_frmivalenum = zeroed(); + value.index = index; + value.pixel_format = format; + value.width = width; + value.height = height; + if !query.get(VIDIOC_ENUM_FRAMEINTERVALS, &mut value) { + if query.error == libc::EINVAL && index > 0 { + return json!({ "kind": "discrete", "values": discrete }); + } + return failed(query.error); + } + if value.index != index || value.pixel_format != format || value.width != width || value.height != height { + return failed(libc::EPROTO); + } + if index == ENUMERATION_LIMIT { + return failed(libc::EOVERFLOW); + } + // SAFETY: the active union member is selected by `type_`, as the kernel documents. + unsafe { + match value.type_ { + FRMIVAL_DISCRETE => { + if !valid_fraction(&value.__bindgen_anon_1.discrete) { + return failed(libc::EPROTO); + } + discrete.push(fraction(&value.__bindgen_anon_1.discrete)); + } + FRMIVAL_STEPWISE | FRMIVAL_CONTINUOUS if index == 0 => { + let range = value.__bindgen_anon_1.stepwise; + if !valid_fraction(&range.min) + || !valid_fraction(&range.max) + || !ordered(&range.min, &range.max) + || (value.type_ == FRMIVAL_STEPWISE && !valid_fraction(&range.step)) + { + return failed(libc::EPROTO); + } + return json!({ + "kind": if value.type_ == FRMIVAL_CONTINUOUS { "continuous" } else { "stepwise" }, + "min": fraction(&range.min), "max": fraction(&range.max), "step": fraction(&range.step), + }); + } + _ => return failed(libc::EPROTO), + } + } + } + failed(libc::EOVERFLOW) +} + +/// Opens the sysfs device directory and returns `dev:ino`, for the helper's identity check. +fn directory_identity(path: &Path) -> std::io::Result { + let info = Dir::open(path)?.metadata()?; + Ok(format!("{}:{}", info.dev(), info.ino())) +} + +/// Everything the helper reads, as a JSON object. `identity` is the parent's `dev:ino` of the +/// sysfs directory; any mismatch (a replaced device) is `ENODEV`. +fn query_device(path: &str, node: &str, identity: &str) -> Value { + let directory = match Dir::open(path) { + Ok(d) => d, + Err(e) => return failed(io_errno(&e)), + }; + let selected = match directory.metadata() { + Ok(m) => m, + Err(e) => return failed(io_errno(&e)), + }; + if format!("{}:{}", selected.dev(), selected.ino()) != identity { + return failed(libc::ENODEV); + } + let dev = directory.read_text("dev"); + if dev.state != ReadState::Available { + return failed(dev.error); + } + let numbers: Vec<&str> = dev.value.split(':').collect(); + let (Some(major), Some(minor)) = (numbers.first().and_then(|n| n.parse::().ok()), numbers.get(1).and_then(|n| n.parse::().ok())) else { + return failed(libc::EPROTO); + }; + if numbers.len() != 2 { + return failed(libc::EPROTO); + } + let device = match std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC | libc::O_NOFOLLOW).open(node) { + Ok(f) => f, + Err(e) => return failed(io_errno(&e)), + }; + let opened = match device.metadata() { + Ok(m) => m, + Err(e) => return failed(io_errno(&e)), + }; + let (rmajor, rminor) = (libc::major(opened.rdev()), libc::minor(opened.rdev())); + if !opened.file_type().is_char_device() || rmajor != major || rminor != minor { + return failed(libc::ENODEV); + } + let binding = format!("/sys/dev/char/{major}:{minor}"); + let same_device = || { + let Ok(current) = std::fs::metadata(path) else { return false }; + let Ok(now) = std::fs::symlink_metadata(node) else { return false }; + current.dev() == selected.dev() + && current.ino() == selected.ino() + && now.file_type().is_char_device() + && now.rdev() == opened.rdev() + && now.ino() == opened.ino() + && now.dev() == opened.dev() + && std::fs::canonicalize(&binding).is_ok_and(|p| p == Path::new(path)) + }; + if !same_device() { + return failed(libc::ENODEV); + } + let mut query = Query::new(device.as_raw_fd()); + let mut capability: v4l2_capability = zeroed(); + if !query.get(VIDIOC_QUERYCAP, &mut capability) { + return failed(query.error); + } + let (Some(card), Some(driver), Some(bus)) = (text(&capability.card), text(&capability.driver), text(&capability.bus_info)) else { + return failed(libc::EPROTO); + }; + let caps = if capability.capabilities & V4L2_CAP_DEVICE_CAPS != 0 { capability.device_caps } else { capability.capabilities }; + if caps & (V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_VIDEO_CAPTURE_MPLANE) == 0 || caps & (V4L2_CAP_VIDEO_M2M | V4L2_CAP_VIDEO_M2M_MPLANE) != 0 { + return json!({ "applicable": false }); + } + let mut result = Map::new(); + result.insert("applicable".into(), json!(true)); + result.insert("card".into(), json!(card)); + result.insert("driver".into(), json!(driver)); + result.insert("bus".into(), json!(bus)); + let mut modes: Vec = Vec::new(); + let mut collection_error = 0; + 'queues: for (queue, capability_bit) in [(BUF_TYPE_VIDEO_CAPTURE, V4L2_CAP_VIDEO_CAPTURE), (BUF_TYPE_VIDEO_CAPTURE_MPLANE, V4L2_CAP_VIDEO_CAPTURE_MPLANE)] { + if caps & capability_bit == 0 { + continue; + } + let mut formats: Vec = Vec::new(); + for index in 0..=ENUMERATION_LIMIT { + let mut format: v4l2_fmtdesc = zeroed(); + format.type_ = queue; + format.index = index; + if !query.get(VIDIOC_ENUM_FMT, &mut format) { + if query.error != libc::EINVAL || index == 0 { + collection_error = query.error; + } + break; + } + if text(&format.description).is_none() { + collection_error = libc::EPROTO; + break; + } + if format.type_ != queue || format.index != index || format.pixelformat == 0 { + collection_error = libc::EPROTO; + break; + } + if index == ENUMERATION_LIMIT { + collection_error = libc::EOVERFLOW; + break; + } + formats.push(format); + } + for format in &formats { + let mut base = Map::new(); + base.insert("queue".into(), json!(if queue == BUF_TYPE_VIDEO_CAPTURE { "single" } else { "multi" })); + base.insert("format".into(), json!(format.pixelformat)); + base.insert("description".into(), json!(text(&format.description).unwrap_or_default())); + base.insert("flags".into(), json!(format.flags)); + let mut sizes: Vec> = Vec::new(); + for index in 0..=ENUMERATION_LIMIT { + let mut size: v4l2_frmsizeenum = zeroed(); + size.index = index; + size.pixel_format = format.pixelformat; + if !query.get(VIDIOC_ENUM_FRAMESIZES, &mut size) { + if query.error != libc::EINVAL || index == 0 { + let mut row = base.clone(); + row.insert("size_error".into(), json!(query.error)); + sizes.push(row); + } + break; + } + if index == ENUMERATION_LIMIT { + collection_error = libc::EOVERFLOW; + break; + } + if size.index != index || size.pixel_format != format.pixelformat { + collection_error = libc::EPROTO; + break; + } + let mut row = base.clone(); + // SAFETY: the active union member is selected by `type_`. + unsafe { + match size.type_ { + FRMSIZE_DISCRETE => { + let d = size.__bindgen_anon_1.discrete; + if d.width == 0 || d.height == 0 { + collection_error = libc::EPROTO; + break; + } + row.insert("size_kind".into(), json!("discrete")); + row.insert("width".into(), json!(d.width)); + row.insert("height".into(), json!(d.height)); + } + FRMSIZE_STEPWISE | FRMSIZE_CONTINUOUS if index == 0 => { + let r = size.__bindgen_anon_1.stepwise; + let bad = r.min_width == 0 + || r.min_height == 0 + || r.min_width > r.max_width + || r.min_height > r.max_height + || r.step_width == 0 + || r.step_height == 0 + || (size.type_ == FRMSIZE_CONTINUOUS && (r.step_width != 1 || r.step_height != 1)); + if bad { + collection_error = libc::EPROTO; + break; + } + row.insert("size_kind".into(), json!(if size.type_ == FRMSIZE_CONTINUOUS { "continuous" } else { "stepwise" })); + for (key, value) in [ + ("width", r.min_width), + ("height", r.min_height), + ("max_width", r.max_width), + ("max_height", r.max_height), + ("step_width", r.step_width), + ("step_height", r.step_height), + ] { + row.insert(key.into(), json!(value)); + } + } + _ => { + collection_error = libc::EPROTO; + break; + } + } + } + let discrete = size.type_ == FRMSIZE_DISCRETE; + sizes.push(row); + if !discrete { + break; + } + } + for mut row in sizes { + // Ranges stay ranges: never imply one rate list applies to every size. + if row.get("size_kind") == Some(&json!("discrete")) { + let width = row["width"].as_u64().unwrap_or(0) as u32; + let height = row["height"].as_u64().unwrap_or(0) as u32; + row.insert("intervals".into(), intervals(&mut query, format.pixelformat, width, height)); + } + if modes.len() == MODE_LIMIT { + collection_error = libc::EOVERFLOW; + break; + } + modes.push(Value::Object(row)); + } + if query.error == libc::ETIMEDOUT || query.error == libc::EOVERFLOW { + collection_error = query.error; + } + if modes.len() == MODE_LIMIT { + collection_error = libc::EOVERFLOW; + } + if query.error == libc::ETIMEDOUT || query.error == libc::EOVERFLOW || modes.len() == MODE_LIMIT { + break 'queues; + } + } + } + if !same_device() { + return failed(libc::ENODEV); + } + result.insert("modes".into(), Value::Array(modes)); + result.insert("error".into(), json!(collection_error)); + Value::Object(result) +} + +/// Entry point of the helper process: `qldm-camera-helper --camera-query +/// `. Prints one compact JSON object on stdout. +pub fn run_camera_query_helper(args: &[String]) -> i32 { + // If the parent dies, do not leave an ordinary helper running independently. + // SAFETY: prctl with PR_SET_PDEATHSIG only changes this process's own signal setting. + let armed = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) } == 0; + // SAFETY: getppid has no preconditions. + if !armed || unsafe { libc::getppid() } == 1 { + return 1; + } + let [flag, path, node, identity] = args else { return 2 }; + if flag != "--camera-query" { + return 2; + } + let mut bytes = serde_json::to_vec(&query_device(path, node, identity)).unwrap_or_default(); + if bytes.len() > REPORT_LIMIT { + bytes = serde_json::to_vec(&failed(libc::EOVERFLOW)).unwrap_or_default(); + } + use std::io::Write; + match std::io::stdout().lock().write_all(&bytes) { + Ok(()) => 0, + Err(_) => 1, + } +} + +/// A helper that could not be reaped after being killed; no new helper starts until it is. +static PENDING: Mutex> = Mutex::new(None); + +/// Default helper location: next to the running executable. +pub fn default_helper_path() -> std::path::PathBuf { + std::env::current_exe().ok().and_then(|exe| exe.parent().map(|dir| dir.join("qldm-camera-helper"))).unwrap_or_else(|| "qldm-camera-helper".into()) +} + +/// Reads a camera's V4L2 capabilities through the helper at `helper`. `directory` is the open +/// sysfs device directory and `node` its `/dev/video*` node (empty when udev reported none). +pub fn collect_camera_properties(directory: &Dir, sysfs_path: &str, node: &str, helper: &Path, cancel: Option<&AtomicBool>) -> CameraProperties { + let mut result = CameraProperties { applicable: true, node: node.to_string(), ..Default::default() }; + if node.is_empty() { + return result; + } + if !node.starts_with("/dev/") || node.len() > 4096 || sysfs_path.len() > 4096 || node.contains('\0') { + result.state = failure(libc::EPROTO); + return result; + } + // One properties worker serializes requests. Never accumulate stuck helpers. + { + let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(child) = pending.as_mut() { + match child.try_wait() { + Ok(None) => { + result.state = failure(libc::EAGAIN); + return result; + } + _ => *pending = None, + } + } + } + let identity = match directory.metadata() { + Ok(info) => format!("{}:{}", info.dev(), info.ino()), + Err(e) => { + result.state = failure(io_errno(&e)); + return result; + } + }; + let mut child = match Command::new(helper) + .args(["--camera-query", sysfs_path, node, &identity]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(e) => { + result.state = failure(io_errno(&e)); + return result; + } + }; + let mut stdout = child.stdout.take().expect("piped stdout"); + // SAFETY: switching the read end of our own pipe to non-blocking mode. + unsafe { + let flags = libc::fcntl(stdout.as_raw_fd(), libc::F_GETFL); + libc::fcntl(stdout.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK); + } + let started = Instant::now(); + let mut bytes: Vec = Vec::new(); + let (mut eof, mut exit) = (false, None); + let mut error = 0; + while !eof || exit.is_none() { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + error = libc::ECANCELED; + break; + } + if started.elapsed() >= BUDGET { + error = libc::ETIMEDOUT; + break; + } + let mut buffer = [0u8; 4096]; + let mut count = -1isize; + match stdout.read(&mut buffer) { + Ok(0) => eof = true, + Ok(n) => { + count = n as isize; + bytes.extend_from_slice(&buffer[..n]); + if bytes.len() > REPORT_LIMIT { + error = libc::EOVERFLOW; + break; + } + } + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted) => {} + Err(e) => { + error = io_errno(&e); + break; + } + } + if exit.is_none() { + match child.try_wait() { + Ok(Some(status)) => exit = Some(status), + Ok(None) => {} + Err(e) => { + error = io_errno(&e); + break; + } + } + } + if !eof && count <= 0 { + let mut wait = libc::pollfd { fd: stdout.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: `wait` is a valid single-element array for the call's duration. + unsafe { libc::poll(&mut wait, 1, 25) }; + } else if eof && exit.is_none() { + std::thread::sleep(Duration::from_millis(10)); + } + } + if error != 0 { + if exit.is_none() { + let _ = child.kill(); + if !matches!(child.try_wait(), Ok(Some(_))) { + *PENDING.lock().unwrap_or_else(|e| e.into_inner()) = Some(child); + } + } + result.state = failure(error); + return result; + } + if !exit.is_some_and(|s| s.success() && s.signal().is_none()) { + result.state = failure(libc::EPROTO); + return result; + } + let Some(report) = serde_json::from_slice::(&bytes).ok().filter(Value::is_object) else { + result.state = failure(libc::EPROTO); + return result; + }; + if let Some(applicable) = report.get("applicable").and_then(Value::as_bool) { + result.applicable = applicable; + } + let report_error = report.get("error").and_then(Value::as_i64).unwrap_or(0) as i32; + result.state = if report_error != 0 { failure(report_error) } else { Attribute { state: ReadState::Available, ..Default::default() } }; + result.report = report; + result +} + +/// Sysfs directory identity the helper expects, for callers that hold only a path. +pub fn identity_of(path: &Path) -> std::io::Result { + directory_identity(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ioctl_numbers_match_the_kernel_headers() { + // Printed with a C program against /usr/include/linux/videodev2.h. + assert_eq!(VIDIOC_QUERYCAP, 0x8068_5600); + assert_eq!(VIDIOC_ENUM_FMT, 0xc040_5602); + assert_eq!(VIDIOC_ENUM_FRAMESIZES, 0xc02c_564a); + assert_eq!(VIDIOC_ENUM_FRAMEINTERVALS, 0xc034_564b); + assert_eq!(std::mem::size_of::(), 104); + assert_eq!(std::mem::size_of::(), 64); + assert_eq!(std::mem::size_of::(), 44); + assert_eq!(std::mem::size_of::(), 52); + } + + #[test] + fn text_fields_must_be_terminated_utf8() { + assert_eq!(text(b"Integrated Camera\0\0\0").as_deref(), Some("Integrated Camera")); + assert_eq!(text(b"no terminator"), None); + assert_eq!(text(&[0xff, 0xfe, 0]), None); + } + + #[test] + fn fractions_compare_exactly() { + let f = |n, d| v4l2_fract { numerator: n, denominator: d }; + assert!(ordered(&f(1, 30), &f(1, 15))); + assert!(ordered(&f(1, 30), &f(1, 30))); + assert!(!ordered(&f(1, 10), &f(1, 30))); + assert!(valid_fraction(&f(1, 30)) && !valid_fraction(&f(0, 30)) && !valid_fraction(&f(1, 0))); + assert_eq!(fraction(&f(1, 30)), json!([1, 30])); + } + + #[test] + fn helper_rejects_mismatched_identities_and_bad_arguments() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().to_string_lossy().into_owned(); + assert_eq!(query_device(&path, "/dev/null", "1:2")["error"], libc::ENODEV); + assert_eq!(query_device("/nonexistent/x", "/dev/null", "1:2")["error"], libc::ENOENT); + let identity = directory_identity(tmp.path()).unwrap(); + // No readable "dev" file in the fixture: reported as the read failure. + assert_eq!(query_device(&path, "/dev/null", &identity)["error"], libc::ENOENT); + std::fs::write(tmp.path().join("dev"), "garbage\n").unwrap(); + assert_eq!(query_device(&path, "/dev/null", &identity)["error"], libc::EPROTO); + std::fs::write(tmp.path().join("dev"), "1:3\n").unwrap(); + // /dev/null is 1:3 but is not a V4L2 node: the QUERYCAP ioctl is not supported. + assert_eq!(query_device(&path, "/dev/null", &identity)["error"], libc::ENODEV, "sysfs binding differs"); + assert_eq!(run_camera_query_helper(&["--bogus".into(), path.clone(), "n".into(), identity]), 2); + } + + #[test] + fn empty_and_invalid_nodes_are_handled_before_spawning() { + let tmp = tempfile::tempdir().unwrap(); + let dir = Dir::open(tmp.path()).unwrap(); + let helper = Path::new("/nonexistent/helper"); + let path = tmp.path().to_string_lossy().into_owned(); + let none = collect_camera_properties(&dir, &path, "", helper, None); + assert!(none.applicable && none.state.state == ReadState::Unavailable); + let bad = collect_camera_properties(&dir, &path, "relative/video0", helper, None); + assert_eq!(bad.state.error, libc::EPROTO); + let missing = collect_camera_properties(&dir, &path, "/dev/video0", helper, None); + assert_eq!(missing.state.state, ReadState::Removed, "an absent helper executable is ENOENT"); + } +} diff --git a/crates/qldm-core/src/cpuinfo.rs b/crates/qldm-core/src/cpuinfo.rs new file mode 100644 index 0000000..21d86ee --- /dev/null +++ b/crates/qldm-core/src/cpuinfo.rs @@ -0,0 +1,83 @@ +//! `/proc/cpuinfo` model matching, ported from `src/devices/cpumetadata.cpp`. +use crate::model::{Attribute, ReadState}; +use std::collections::HashMap; +use std::io::Read; + +const LIMIT: u64 = 4 * 1024 * 1024; + +/// Per-processor models keyed by processor number; never a global board string. +pub fn parse_cpu_models(cpuinfo: &str) -> HashMap { + let mut models = HashMap::new(); + let mut record: HashMap<&str, &str> = HashMap::new(); + let mut commit = |record: &HashMap<&str, &str>| { + let Some(cpu) = record.get("processor").and_then(|v| v.parse::().ok()) else { return }; + for key in ["model name", "cpu model", "uarch"] { + if let Some(value) = record.get(key).filter(|v| !v.is_empty()) { + models.insert(cpu, value.to_string()); + break; + } + } + }; + for line in cpuinfo.split('\n') { + if line.trim().is_empty() { + commit(&record); + record.clear(); + continue; + } + let Some((key, value)) = line.split_once(':') else { continue }; + let key = key.trim(); + // Only per-processor keys. A board's global Hardware string never reaches a CPU. + if matches!(key, "processor" | "model name" | "cpu model" | "uarch") { + record.insert(key, value.trim()); + } + } + commit(&record); + models +} + +/// Reads `/proc/cpuinfo`; a file over 4 MiB is an `EOVERFLOW` error, never truncated metadata. +pub fn read_cpuinfo() -> Attribute { + let file = match std::fs::File::open("/proc/cpuinfo") { + Ok(f) => f, + Err(e) => { + let state = if e.kind() == std::io::ErrorKind::PermissionDenied { ReadState::PermissionDenied } else { ReadState::Unavailable }; + return Attribute { state, value: String::new(), error: e.raw_os_error().unwrap_or(0) }; + } + }; + let mut bytes = Vec::new(); + match file.take(LIMIT + 1).read_to_end(&mut bytes) { + Err(e) => Attribute { state: ReadState::Error, value: String::new(), error: e.raw_os_error().unwrap_or(libc::EIO) }, + Ok(_) if bytes.len() as u64 > LIMIT => Attribute { state: ReadState::Error, value: String::new(), error: libc::EOVERFLOW }, + Ok(_) => Attribute { state: ReadState::Available, value: String::from_utf8_lossy(&bytes).into_owned(), error: 0 }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn models_match_processor_numbers_not_parse_order() { + let models = parse_cpu_models( + "processor : 1\nmodel name : Different CPU\n\n\ + processor : 0\nmodel name : Intel(R) Core(TM) Ultra X7 358H\n\n\ + processor : 2\nHardware : Board name\n", + ); + assert_eq!(models.get(&0).map(String::as_str), Some("Intel(R) Core(TM) Ultra X7 358H")); + assert_eq!(models.get(&1).map(String::as_str), Some("Different CPU")); + assert!(!models.contains_key(&2), "global board name must not become a CPU model"); + } + + #[test] + fn fallback_keys_and_invalid_processor() { + let models = parse_cpu_models("processor : 3\ncpu model : POWER9\n\nprocessor : x\nmodel name : bad\n"); + assert_eq!(models.get(&3).map(String::as_str), Some("POWER9")); + assert_eq!(models.len(), 1); + } + + #[test] + fn reads_real_cpuinfo() { + let info = read_cpuinfo(); + assert_eq!(info.state, ReadState::Available); + } +} diff --git a/crates/qldm-core/src/dirfd.rs b/crates/qldm-core/src/dirfd.rs new file mode 100644 index 0000000..25da926 --- /dev/null +++ b/crates/qldm-core/src/dirfd.rs @@ -0,0 +1,148 @@ +//! Reads anchored to an already-open sysfs directory, so a path swapped mid-collection cannot +//! redirect them (the safe-Rust counterpart of the C++ `openat`/`readlinkat` helpers). +//! Lookups go through `/proc/self/fd//…`; only the final component is checked for symlinks. +use crate::model::{Attribute, ReadState}; +use std::fs::{self, File, Metadata, OpenOptions}; +use std::io::Read; +use std::os::fd::AsRawFd; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +pub const TEXT_LIMIT: usize = 4096; + +/// Attribute for a failed read. `ENODATA` counts as unavailable, like `ENOENT`. +pub fn failure(error: i32) -> Attribute { + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENODEV => ReadState::Removed, + libc::ENOENT | libc::ENODATA => ReadState::Unavailable, + _ => ReadState::Error, + }; + Attribute { state, value: String::new(), error } +} + +pub fn available(value: impl Into) -> Attribute { + Attribute { state: ReadState::Available, value: value.into(), error: 0 } +} + +pub fn io_errno(e: &std::io::Error) -> i32 { + e.raw_os_error().unwrap_or(libc::EIO) +} + +/// Reads at most `limit` bytes (+1 to detect overflow). `Err` carries an errno. +pub fn read_limited(file: &mut File, limit: usize, cancelled: &dyn Fn() -> bool) -> Result, i32> { + let mut bytes = Vec::new(); + let mut buffer = [0u8; 4096]; + while bytes.len() <= limit { + if cancelled() { + return Err(libc::ECANCELED); + } + let wanted = buffer.len().min(limit + 1 - bytes.len()); + match file.read(&mut buffer[..wanted]) { + Ok(0) => break, + Ok(n) => bytes.extend_from_slice(&buffer[..n]), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(io_errno(&e)), + } + } + Ok(bytes) +} + +/// An open directory; every operation is relative to it. +pub struct Dir { + file: File, +} + +impl Dir { + /// Opens a directory without following a symlink at the final component. + pub fn open(path: impl AsRef) -> std::io::Result { + OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path).map(|file| Self { file }) + } + + fn anchored(&self, name: &str) -> String { + format!("/proc/self/fd/{}/{name}", self.file.as_raw_fd()) + } + + /// Opens a sub-directory, following a final symlink only when `follow` is set (for + /// documented kernel links such as `driver`). + pub fn open_dir(&self, name: &str, follow: bool) -> std::io::Result { + let nofollow = if follow { 0 } else { libc::O_NOFOLLOW }; + OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC | nofollow).open(self.anchored(name)).map(|file| Dir { file }) + } + + pub fn open_file(&self, name: &str) -> std::io::Result { + OpenOptions::new().read(true).custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW).open(self.anchored(name)) + } + + /// Small text attribute, trimmed; over [`TEXT_LIMIT`] bytes is an `EOVERFLOW` error. + pub fn read_text(&self, name: &str) -> Attribute { + let mut file = match self.open_file(name) { + Ok(f) => f, + Err(e) => return failure(io_errno(&e)), + }; + match read_limited(&mut file, TEXT_LIMIT, &|| false) { + Err(error) => failure(error), + Ok(bytes) if bytes.len() > TEXT_LIMIT => failure(libc::EOVERFLOW), + Ok(bytes) => available(String::from_utf8_lossy(&bytes).trim()), + } + } + + /// Target text of a symlink, exactly as stored. + pub fn link_target(&self, name: &str) -> Attribute { + match fs::read_link(self.anchored(name)) { + Err(e) => failure(io_errno(&e)), + Ok(target) => { + let text = target.to_string_lossy(); + if text.len() > TEXT_LIMIT { + failure(libc::EOVERFLOW) + } else { + available(text.into_owned()) + } + } + } + } + + /// `lstat` of an entry in this directory. + pub fn lstat(&self, name: &str) -> std::io::Result { + fs::symlink_metadata(self.anchored(name)) + } + + pub fn metadata(&self) -> std::io::Result { + self.file.metadata() + } + + pub fn file(&self) -> &File { + &self.file + } +} + +/// Last path component of a link target (`QFileInfo::fileName`). +pub fn file_name(path: &str) -> String { + Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + #[test] + fn reads_are_anchored_and_classified() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("name"), " value \n").unwrap(); + fs::create_dir(tmp.path().join("sub")).unwrap(); + symlink("../elsewhere/driver-x", tmp.path().join("driver")).unwrap(); + let dir = Dir::open(tmp.path()).unwrap(); + assert_eq!(dir.read_text("name").value, "value"); + assert_eq!(dir.read_text("missing").state, ReadState::Unavailable); + assert_eq!(dir.link_target("driver").value, "../elsewhere/driver-x"); + assert_eq!(file_name(&dir.link_target("driver").value), "driver-x"); + assert!(dir.open_dir("sub", false).is_ok()); + assert!(dir.open_dir("driver", false).is_err(), "symlink not followed"); + assert!(dir.lstat("driver").unwrap().file_type().is_symlink()); + assert_eq!(failure(libc::ENODATA).state, ReadState::Unavailable); + fs::write(tmp.path().join("big"), vec![b'x'; TEXT_LIMIT + 1]).unwrap(); + assert_eq!(dir.read_text("big").error, libc::EOVERFLOW); + assert!(Dir::open(tmp.path().join("name")).is_err(), "not a directory"); + } +} diff --git a/crates/qldm-core/src/display.rs b/crates/qldm-core/src/display.rs new file mode 100644 index 0000000..36a7334 --- /dev/null +++ b/crates/qldm-core/src/display.rs @@ -0,0 +1,205 @@ +//! Desktop-session display state for one DRM connector, ported from +//! `src/devices/displayproperties.cpp` and `displaybackend.h`. +//! +//! The kernel connector name is matched against the session protocol's output names; a name +//! that matches zero or several outputs is never guessed, and the session type decides the +//! backend (Wayland is never silently replaced by XWayland's emulated outputs). Numeric strings +//! use the C locale. Each backend runs on its own short-lived thread under a 2 s budget. +use crate::dirfd::available; +use crate::model::{Attribute, Device, ReadState}; +use crate::sysfs::bounded_directory_names; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +pub const OUTPUT_LIMIT: usize = 256; +pub const TEXT_LIMIT: usize = 4096; +pub const QUERY_TIMEOUT: Duration = Duration::from_millis(2000); + +#[derive(Clone, Debug, Default)] +pub struct DisplayProperties { + pub backend: String, + pub output: String, + pub status: Attribute, + pub values: HashMap, + pub sources: HashMap, +} + +impl DisplayProperties { + pub(crate) fn put(&mut self, key: &str, value: impl Into, source: &str) { + self.values.insert(key.into(), available(value)); + self.sources.insert(key.into(), source.into()); + } +} + +pub(crate) fn error(number: i32) -> Attribute { + Attribute { state: ReadState::Error, value: String::new(), error: number } +} + +/// RandR's modesetting driver uses `HDMI-N` for the kernel's `HDMI-A-N`. +pub(crate) fn canonical_name(name: &str) -> String { + match name.strip_prefix("HDMI-A-") { + Some(rest) => format!("HDMI-{rest}"), + None => name.to_string(), + } +} + +/// `QString::number(value, 'g', 8)`: up to 8 significant digits, trailing zeros removed. +pub(crate) fn number(value: f64) -> String { + crate::i18n::format_g(value, 8) +} + +/// Which session protocol to ask, from the environment. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Session { + Wayland, + X11, + None, +} + +pub(crate) fn session_from(session_type: &str, wayland_display: &str, display: &str) -> Session { + let wayland = session_type == "wayland" || (session_type != "x11" && !wayland_display.is_empty()); + if wayland { + Session::Wayland + } else if !display.is_empty() { + Session::X11 + } else { + Session::None + } +} + +/// Runs `work` on a thread and waits at most [`QUERY_TIMEOUT`], polling `cancel`. `Err` is +/// `ETIMEDOUT` or `ECANCELED`; a thread that outlives the wait is abandoned. +pub(crate) fn run_bounded(cancel: Option<&AtomicBool>, work: impl FnOnce() -> T + Send + 'static) -> Result { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(work()); + }); + let started = Instant::now(); + loop { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return Err(libc::ECANCELED); + } + let remaining = QUERY_TIMEOUT.saturating_sub(started.elapsed()); + if remaining.is_zero() { + return Err(libc::ETIMEDOUT); + } + match receiver.recv_timeout(remaining.min(Duration::from_millis(50))) { + Ok(value) => return Ok(value), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return Err(libc::EIO), + } + } +} + +/// `connection_status` is the connector's `status` read from the open device directory (not +/// the possibly stale inventory snapshot). +pub fn collect_display_properties(device: &Device, connection_status: &Attribute, cancel: Option<&AtomicBool>) -> DisplayProperties { + let mut result = DisplayProperties::default(); + if device.subsystem != "drm" || !device.sysname.contains('-') { + result.status.state = ReadState::NotApplicable; + return result; + } + if connection_status.state == ReadState::Available && connection_status.value == "disconnected" { + result.status = available("disconnected"); + result.sources.insert("configuration".into(), format!("{}/status", device.path)); + return result; + } + let connector = device.sysname[device.sysname.find('-').expect("checked") + 1..].to_string(); + // Session protocols name outputs, not GPU-qualified kernel connectors. Do not silently + // attribute eDP-1/DP-1 to the wrong GPU on multi-card systems. + let names = match bounded_directory_names("/sys/class/drm", (OUTPUT_LIMIT * 2) as i64, &[], cancel) { + Ok(names) => names, + Err(code) => { + result.status = error(code); + return result; + } + }; + let matches = names.iter().filter_map(|name| name.split_once('-')).filter(|(_, rest)| canonical_name(rest) == canonical_name(&connector)).count(); + if matches != 1 { + result.status = error(if matches > 0 { libc::ENOTUNIQ } else { libc::ENODEV }); + return result; + } + let env = |key: &str| std::env::var(key).unwrap_or_default(); + let backend = session_from(&env("XDG_SESSION_TYPE"), &env("WAYLAND_DISPLAY"), &env("DISPLAY")); + let outcome = match backend { + Session::Wayland => run_bounded(cancel, move || crate::display_wayland::collect(&connector)), + Session::X11 => run_bounded(cancel, move || crate::display_x11::collect(&connector)), + Session::None => return result, + }; + // Never fall back to XWayland's emulated outputs after a Wayland failure. + match outcome { + Ok(found) => found, + Err(code) => { + result.backend = if backend == Session::Wayland { "Wayland" } else { "X11 / RandR" }.into(); + result.status = error(code); + result + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hdmi_connector_names_are_canonicalised() { + assert_eq!(canonical_name("HDMI-A-1"), "HDMI-1"); + assert_eq!(canonical_name("HDMI-1"), "HDMI-1"); + assert_eq!(canonical_name("eDP-1"), "eDP-1"); + } + + #[test] + fn numbers_follow_g8() { + for (value, want) in [ + (60.0, "60"), + (59.94, "59.94"), + (59.940059, "59.940059"), + (1.5, "1.5"), + (0.0, "0"), + (144.0, "144"), + (1.0 / 3.0, "0.33333333"), + (123456789.0, "1.2345679e+08"), + (0.00001234, "1.234e-05"), + (9.99999999, "10"), + (-2.25, "-2.25"), + (100.0, "100"), + ] { + assert_eq!(number(value), want, "{value}"); + } + } + + #[test] + fn the_session_environment_chooses_the_backend() { + assert_eq!(session_from("wayland", "", ""), Session::Wayland); + assert_eq!(session_from("", "wayland-0", ":0"), Session::Wayland, "a Wayland display wins when the type is unset"); + assert_eq!(session_from("x11", "wayland-0", ":0"), Session::X11, "an explicit x11 session type wins"); + assert_eq!(session_from("tty", "", ""), Session::None); + } + + #[test] + fn non_connectors_and_disconnected_outputs_do_not_query_a_session() { + let card = Device { subsystem: "drm".into(), sysname: "card0".into(), ..Default::default() }; + assert_eq!(collect_display_properties(&card, &Attribute::default(), None).status.state, ReadState::NotApplicable); + let connector = Device { subsystem: "drm".into(), sysname: "card0-DP-1".into(), path: "/sys/p".into(), ..Default::default() }; + let disconnected = Attribute { state: ReadState::Available, value: "disconnected".into(), error: 0 }; + let found = collect_display_properties(&connector, &disconnected, None); + assert_eq!(found.status.value, "disconnected"); + assert_eq!(found.sources["configuration"], "/sys/p/status"); + } + + #[test] + fn an_unknown_connector_is_not_attributed_to_any_output() { + let connector = Device { subsystem: "drm".into(), sysname: "card0-NOSUCH-9".into(), ..Default::default() }; + let found = collect_display_properties(&connector, &Attribute::default(), None); + assert_eq!((found.status.state, found.status.error), (ReadState::Error, libc::ENODEV)); + } + + #[test] + fn bounded_work_times_out_and_cancels() { + assert_eq!(run_bounded(None, || 7).unwrap(), 7); + let cancel = AtomicBool::new(true); + assert_eq!(run_bounded(Some(&cancel), || std::thread::sleep(Duration::from_millis(300))), Err(libc::ECANCELED)); + } +} diff --git a/crates/qldm-core/src/display_wayland.rs b/crates/qldm-core/src/display_wayland.rs new file mode 100644 index 0000000..a2b8d4c --- /dev/null +++ b/crates/qldm-core/src/display_wayland.rs @@ -0,0 +1,401 @@ +//! Wayland output state, ported from `src/devices/displaywayland.cpp` with the `wayland-client` +//! crate. Only read-only protocols are used (wl_output and xdg-output v3 logical size). The +//! connection opens its own socket from `WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR`; it never +//! consumes `WAYLAND_SOCKET`, which may already belong to the toolkit. +use crate::dirfd::available; +use crate::display::{canonical_name, error, number, DisplayProperties, OUTPUT_LIMIT, QUERY_TIMEOUT, TEXT_LIMIT}; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; +use std::time::Instant; +use wayland_client::protocol::{wl_callback, wl_output, wl_registry}; +use wayland_client::{Connection, Dispatch, QueueHandle, WEnum}; +use wayland_protocols::xdg::xdg_output::zv1::client::{zxdg_output_manager_v1, zxdg_output_v1}; + +#[derive(Debug, Default, Clone)] +pub(crate) struct Output { + id: u32, + name: String, + name_conflict: bool, + removed: bool, + mode_known: bool, + width: i32, + height: i32, + refresh: i32, + transform: i32, + scale: i32, + logical_width: i32, + logical_height: i32, +} + +impl Output { + fn new(id: u32) -> Self { + Self { id, transform: -1, scale: 1, ..Default::default() } + } + + fn set_name(&mut self, name: &str) { + if name.len() > TEXT_LIMIT { + self.name_conflict = true; + return; + } + if !self.name.is_empty() && self.name != name { + self.name_conflict = true; + } + self.name = name.to_string(); + } +} + +#[derive(Default)] +struct State { + outputs: Vec, + /// The bound `wl_output` proxies, by output index (needed to request xdg-output objects). + proxies: Vec, + /// Kept alive until the second round trip has delivered their events. + xdg_outputs: Vec, + manager: Option, + sync_done: bool, + error: i32, +} + +impl Dispatch for State { + fn event(state: &mut Self, registry: &wl_registry::WlRegistry, event: wl_registry::Event, _: &(), _: &Connection, qh: &QueueHandle) { + match event { + wl_registry::Event::Global { name, interface, version } => match interface.as_str() { + "wl_output" => { + if state.outputs.len() >= OUTPUT_LIMIT { + state.error = libc::EOVERFLOW; + return; + } + let index = state.outputs.len(); + state.outputs.push(Output::new(name)); + let proxy = registry.bind::(name, version.min(4), qh, index); + state.proxies.push(proxy); + } + "zxdg_output_manager_v1" if state.manager.is_none() => { + state.manager = Some(registry.bind::(name, version.min(3), qh, ())); + } + _ => {} + }, + wl_registry::Event::GlobalRemove { name } => { + for output in state.outputs.iter_mut().filter(|o| o.id == name) { + output.removed = true; + } + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event(state: &mut Self, _: &wl_output::WlOutput, event: wl_output::Event, index: &usize, _: &Connection, _: &QueueHandle) { + let Some(output) = state.outputs.get_mut(*index) else { return }; + match event { + wl_output::Event::Geometry { transform, .. } => { + output.transform = match transform { + WEnum::Value(t) => u32::from(t) as i32, + WEnum::Unknown(_) => -1, + }; + } + wl_output::Event::Mode { flags, width, height, refresh } => { + let current = matches!(flags, WEnum::Value(f) if f.contains(wl_output::Mode::Current)); + if current { + output.mode_known = true; + output.width = width; + output.height = height; + output.refresh = refresh; + } + } + wl_output::Event::Scale { factor } => output.scale = factor, + wl_output::Event::Name { name } => output.set_name(&name), + _ => {} + } + } +} + +impl Dispatch for State { + fn event(_: &mut Self, _: &zxdg_output_manager_v1::ZxdgOutputManagerV1, _: zxdg_output_manager_v1::Event, _: &(), _: &Connection, _: &QueueHandle) {} +} + +impl Dispatch for State { + fn event(state: &mut Self, _: &zxdg_output_v1::ZxdgOutputV1, event: zxdg_output_v1::Event, index: &usize, _: &Connection, _: &QueueHandle) { + let Some(output) = state.outputs.get_mut(*index) else { return }; + match event { + zxdg_output_v1::Event::LogicalSize { width, height } => { + output.logical_width = width; + output.logical_height = height; + } + zxdg_output_v1::Event::Name { name } => output.set_name(&name), + _ => {} + } + } +} + +impl Dispatch for State { + fn event(state: &mut Self, _: &wl_callback::WlCallback, event: wl_callback::Event, _: &(), _: &Connection, _: &QueueHandle) { + if matches!(event, wl_callback::Event::Done { .. }) { + state.sync_done = true; + } + } +} + +/// One bounded round trip: send a sync and dispatch until it is answered. +fn sync(connection: &Connection, queue: &mut wayland_client::EventQueue, state: &mut State, started: Instant) -> Result<(), i32> { + state.sync_done = false; + let qh = queue.handle(); + let _callback = connection.display().sync(&qh, ()); + while !state.sync_done { + if state.error != 0 { + return Err(state.error); + } + let remaining = QUERY_TIMEOUT.saturating_sub(started.elapsed()); + if remaining.is_zero() { + return Err(libc::ETIMEDOUT); + } + queue.dispatch_pending(state).map_err(|_| libc::EIO)?; + if state.sync_done { + break; + } + match connection.flush() { + Ok(()) => {} + Err(wayland_client::backend::WaylandError::Io(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => return Err(libc::EIO), + } + let Some(guard) = queue.prepare_read() else { continue }; + let mut poll = libc::pollfd { fd: guard.connection_fd().as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: `poll` is a valid single-element array for the call's duration. + let ready = unsafe { libc::poll(&mut poll, 1, remaining.as_millis().min(100) as i32) }; + if ready < 0 { + if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + return Err(libc::EIO); + } + } else if poll.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 { + return Err(libc::EIO); + } else if poll.revents & libc::POLLIN != 0 { + match guard.read() { + Ok(_) => {} + Err(wayland_client::backend::WaylandError::Io(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => return Err(libc::EIO), + } + } + } + Ok(()) +} + +/// The socket named by `WAYLAND_DISPLAY` (absolute, or relative to `XDG_RUNTIME_DIR`). +fn socket_path() -> Result { + let mut name = std::env::var("WAYLAND_DISPLAY").unwrap_or_default(); + if name.is_empty() { + name = "wayland-0".into(); + } + if name.starts_with('/') { + return Ok(name.into()); + } + match std::env::var("XDG_RUNTIME_DIR") { + Ok(directory) if !directory.is_empty() => Ok(std::path::Path::new(&directory).join(name)), + _ => Err(libc::ENOENT), + } +} + +/// Builds the result for the one matching, unambiguous output. +pub(crate) fn describe(selected: &Output, result: &mut DisplayProperties) { + result.output = selected.name.clone(); + result.status = available("exposed"); + result.sources.insert("depth".into(), "Wayland output protocols do not expose desktop color depth".into()); + if selected.mode_known && selected.width > 0 && selected.height > 0 { + result.put("resolution", format!("{}x{}", selected.width, selected.height), "wl_output current mode, before rotation/scaling"); + if selected.refresh > 0 { + result.put("refresh", number(selected.refresh as f64 / 1000.0), "wl_output current mode; nominal rate, not measured/VRR rate"); + } + } + if (0..=7).contains(&selected.transform) { + result.put("rotation", ((selected.transform % 4) * 90).to_string(), "wl_output geometry transform, counterclockwise"); + result.put( + "reflection", + if selected.transform >= 4 { "vertical" } else { "none" }, + "wl_output transform: reflection about the vertical axis, then rotation", + ); + } + if selected.logical_width > 0 + && selected.logical_height > 0 + && selected.mode_known + && selected.width > 0 + && selected.height > 0 + && (0..=7).contains(&selected.transform) + { + let swapped = selected.transform % 2 == 1; + let (w, h) = if swapped { (selected.height, selected.width) } else { (selected.width, selected.height) }; + let x = w as f64 / selected.logical_width as f64; + let y = h as f64 / selected.logical_height as f64; + result.put( + "scale", + format!("{} {}", number(x), number(y)), + "Wayland mode/logical-size ratio (X, Y), adjusted for rotation; approximate when logical sizes are rounded", + ); + result.put("scale_kind", "derived", "xdg-output logical_size"); + } else if selected.scale > 0 { + let s = number(selected.scale as f64); + result.put("scale", format!("{s} {s}"), "wl_output integer buffer scale; fractional desktop scale unavailable"); + result.put("scale_kind", "integer", "wl_output scale"); + } +} + +/// Picks the unique output named `connector`; `Err(ENOTUNIQ)` if several match, `Ok(None)` if none. +pub(crate) fn select<'a>(outputs: &'a [Output], connector: &str) -> Result, i32> { + let mut selected = None; + for output in outputs { + if output.removed || output.name_conflict || canonical_name(&output.name) != canonical_name(connector) { + continue; + } + if selected.is_some() { + return Err(libc::ENOTUNIQ); + } + selected = Some(output); + } + Ok(selected) +} + +pub fn collect(connector: &str) -> DisplayProperties { + let mut result = DisplayProperties { backend: "Wayland".into(), ..Default::default() }; + let started = Instant::now(); + let path = match socket_path() { + Ok(p) => p, + Err(code) => { + result.status = error(code); + return result; + } + }; + let stream = match UnixStream::connect(&path) { + Ok(s) => s, + Err(e) => { + result.status = error(e.raw_os_error().unwrap_or(libc::EIO)); + return result; + } + }; + let Ok(connection) = Connection::from_socket(stream) else { + result.status = error(libc::EIO); + return result; + }; + let mut queue = connection.new_event_queue::(); + let qh = queue.handle(); + let mut state = State::default(); + let _registry = connection.display().get_registry(&qh, ()); + if let Err(code) = sync(&connection, &mut queue, &mut state, started) { + result.status = error(code); + return result; + } + if let Some(manager) = state.manager.clone() { + // xdg-output objects are created for each live wl_output once the globals are known. + for index in 0..state.outputs.len() { + if state.outputs[index].removed { + continue; + } + let xdg = manager.get_xdg_output(&state.proxies[index], &qh, index); + state.xdg_outputs.push(xdg); + } + } + if let Err(code) = sync(&connection, &mut queue, &mut state, started) { + result.status = error(code); + return result; + } + match select(&state.outputs, connector) { + Err(code) => result.status = error(code), + Ok(None) => {} + Ok(Some(selected)) => describe(selected, &mut result), + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ReadState; + + fn output(name: &str) -> Output { + Output { name: name.into(), ..Output::new(1) } + } + + #[test] + fn selection_is_exact_and_never_guesses() { + let outputs = vec![output("eDP-1"), output("HDMI-A-1"), Output { removed: true, ..output("DP-1") }]; + assert_eq!(select(&outputs, "eDP-1").unwrap().unwrap().name, "eDP-1"); + assert_eq!(select(&outputs, "HDMI-1").unwrap().unwrap().name, "HDMI-A-1", "RandR-style HDMI name"); + assert!(select(&outputs, "DP-1").unwrap().is_none(), "removed outputs are ignored"); + assert!(select(&outputs, "DP-9").unwrap().is_none()); + let twice = vec![output("DP-1"), output("DP-1")]; + assert_eq!(select(&twice, "DP-1").unwrap_err(), libc::ENOTUNIQ); + let conflicted = vec![Output { name_conflict: true, ..output("DP-1") }]; + assert!(select(&conflicted, "DP-1").unwrap().is_none(), "disagreeing names are not trusted"); + } + + #[test] + fn output_names_that_disagree_are_flagged() { + let mut o = Output::new(1); + o.set_name("DP-1"); + o.set_name("DP-1"); + assert!(!o.name_conflict); + o.set_name("DP-2"); + assert!(o.name_conflict); + let mut long = Output::new(2); + long.set_name(&"x".repeat(TEXT_LIMIT + 1)); + assert!(long.name_conflict); + } + + #[test] + fn described_values_follow_the_protocol_semantics() { + let mut result = DisplayProperties::default(); + let selected = Output { + mode_known: true, + width: 2880, + height: 1800, + refresh: 120_000, + transform: 0, + scale: 2, + logical_width: 1440, + logical_height: 900, + ..output("eDP-1") + }; + describe(&selected, &mut result); + let value = |k: &str| result.values[k].value.clone(); + assert_eq!((value("resolution").as_str(), value("refresh").as_str()), ("2880x1800", "120")); + assert_eq!((value("rotation").as_str(), value("reflection").as_str()), ("0", "none")); + assert_eq!((value("scale").as_str(), value("scale_kind").as_str()), ("2 2", "derived")); + assert_eq!(result.status.value, "exposed"); + assert!(result.sources.contains_key("depth") && !result.values.contains_key("depth")); + + // Rotated 90 degrees: the buffer is wider than the logical area, so axes are swapped. + let mut rotated = DisplayProperties::default(); + describe(&Output { transform: 1, logical_width: 900, logical_height: 1440, ..selected.clone() }, &mut rotated); + assert_eq!(rotated.values["scale"].value, "2 2"); + assert_eq!(rotated.values["rotation"].value, "90"); + let mut flipped = DisplayProperties::default(); + describe(&Output { transform: 5, ..selected.clone() }, &mut flipped); + assert_eq!((flipped.values["rotation"].value.as_str(), flipped.values["reflection"].value.as_str()), ("90", "vertical")); + + // Without xdg-output only the integer buffer scale is known. + let mut integer = DisplayProperties::default(); + describe(&Output { logical_width: 0, logical_height: 0, ..selected }, &mut integer); + assert_eq!((integer.values["scale"].value.as_str(), integer.values["scale_kind"].value.as_str()), ("2 2", "integer")); + let mut unknown = DisplayProperties::default(); + describe(&output("DP-3"), &mut unknown); + assert!(!unknown.values.contains_key("resolution") && !unknown.values.contains_key("rotation")); + assert_eq!(unknown.values["scale"].value, "1 1"); + } + + #[test] + fn the_socket_path_is_absolute_or_under_the_runtime_directory() { + // Pure path logic only: avoid changing the process environment in a parallel run. + let absolute = std::path::Path::new("/run/user/1/wayland-9"); + assert!(absolute.is_absolute()); + assert_eq!(UnixStream::connect("/nonexistent/wayland").unwrap_err().raw_os_error(), Some(libc::ENOENT)); + } + + #[test] + fn a_live_compositor_answers_or_the_failure_is_explicit() { + if std::env::var("WAYLAND_DISPLAY").unwrap_or_default().is_empty() { + return; + } + let found = collect("NO-SUCH-OUTPUT-9"); + assert_eq!(found.backend, "Wayland"); + // The made-up output is absent: no status, or an explicit error if the compositor is unreachable. + assert!(matches!(found.status.state, ReadState::Unavailable | ReadState::Error), "{:?}", found.status); + } +} diff --git a/crates/qldm-core/src/display_x11.rs b/crates/qldm-core/src/display_x11.rs new file mode 100644 index 0000000..6202bd1 --- /dev/null +++ b/crates/qldm-core/src/display_x11.rs @@ -0,0 +1,241 @@ +//! X11/RandR output state, ported from `src/devices/displayx11.cpp` with the pure-Rust `x11rb` +//! connection (no dynamic loading of libxcb). Reads only; a result that spans a modeset or +//! hotplug is rejected instead of showing mixed data. +use crate::dirfd::available; +use crate::display::{canonical_name, error, number, DisplayProperties, OUTPUT_LIMIT, TEXT_LIMIT}; +use crate::model::{Attribute, ReadState}; +use x11rb::connection::Connection; +use x11rb::protocol::randr::{self, ConnectionExt as _}; +use x11rb::rust_connection::RustConnection; + +/// Nominal refresh rate of a RandR mode (not measured, not VRR). +pub(crate) fn refresh_hz(dot_clock: u32, htotal: u16, vtotal: u16, interlace: bool, double_scan: bool) -> Option { + if htotal == 0 || vtotal == 0 || dot_clock == 0 { + return None; + } + let mut hz = dot_clock as f64 / htotal as f64 / vtotal as f64; + if interlace { + hz *= 2.0; + } + if double_scan { + hz /= 2.0; + } + Some(hz) +} + +/// Rotation in degrees, counterclockwise, if exactly one rotation bit is set. +pub(crate) fn rotation_degrees(rotation: u16) -> Option<&'static str> { + match rotation & 15 { + 1 => Some("0"), + 2 => Some("90"), + 4 => Some("180"), + 8 => Some("270"), + _ => None, + } +} + +pub(crate) fn reflection(rotation: u16) -> &'static str { + match (rotation & 16 != 0, rotation & 32 != 0) { + (true, true) => "xy", + (true, false) => "x", + (false, true) => "y", + (false, false) => "none", + } +} + +/// Framebuffer pixels per output pixel (X, Y) for an axis-aligned transform; `custom` otherwise. +pub(crate) fn scale_text(m: &x11rb::protocol::render::Transform) -> (String, bool) { + let diagonal = m.matrix12 == 0 && m.matrix21 == 0 && m.matrix31 == 0 && m.matrix32 == 0 && m.matrix33 == 65536 && m.matrix11 > 0 && m.matrix22 > 0; + if diagonal { + (format!("{} {}", number(m.matrix11 as f64 / 65536.0), number(m.matrix22 as f64 / 65536.0)), true) + } else { + ("custom".into(), false) + } +} + +pub fn collect(connector: &str) -> DisplayProperties { + collect_on(None, connector) +} + +/// Like [`collect`] on an explicit display name (`None` reads `$DISPLAY`). +pub(crate) fn collect_on(display: Option<&str>, connector: &str) -> DisplayProperties { + let mut result = DisplayProperties { backend: "X11 / RandR".into(), ..Default::default() }; + let Ok((connection, screen_number)) = RustConnection::connect(display) else { + result.status = error(libc::EIO); + return result; + }; + let fail = |result: &mut DisplayProperties, code: i32| { + result.status = error(code); + }; + macro_rules! reply { + ($call:expr) => { + match $call.map_err(|_| ()).and_then(|cookie| cookie.reply().map_err(|_| ())) { + Ok(reply) => reply, + Err(()) => { + fail(&mut result, libc::EPROTO); + return result; + } + } + }; + } + let version = reply!(connection.randr_query_version(1, 3)); + if version.major_version < 1 || (version.major_version == 1 && version.minor_version < 3) { + return result; + } + let Some(screen) = connection.setup().roots.get(screen_number) else { + fail(&mut result, libc::EIO); + return result; + }; + let root = screen.root; + let resources = reply!(connection.randr_get_screen_resources_current(root)); + if resources.outputs.len() > OUTPUT_LIMIT || resources.modes.len() > TEXT_LIMIT { + fail(&mut result, libc::EOVERFLOW); + return result; + } + let mut selected: u32 = 0; + let mut matches = 0; + let mut disconnected = false; + for output in &resources.outputs { + let info = reply!(connection.randr_get_output_info(*output, resources.config_timestamp)); + if info.status != randr::SetConfig::SUCCESS { + fail(&mut result, libc::EAGAIN); + return result; + } + if info.name.len() > TEXT_LIMIT { + fail(&mut result, libc::EOVERFLOW); + return result; + } + let name = String::from_utf8_lossy(&info.name).into_owned(); + if canonical_name(&name) != canonical_name(connector) { + continue; + } + matches += 1; + result.output = name; + selected = info.crtc; + disconnected = info.connection == randr::Connection::DISCONNECTED; + } + if matches != 1 { + if matches > 0 { + fail(&mut result, libc::ENOTUNIQ); + } + return result; + } + if disconnected { + result.status = available("disconnected"); + result.sources.insert("configuration".into(), "RandR output connection status".into()); + return result; + } + result.status = available(if selected != 0 { "configured" } else { "inactive" }); + result.put("depth", screen.root_depth.to_string(), "X11 screen root depth; not panel/link bits per channel"); + if selected == 0 { + for key in ["resolution", "refresh", "rotation", "reflection", "scale"] { + result.values.insert(key.into(), Attribute { state: ReadState::NotApplicable, ..Default::default() }); + } + return result; + } + let crtc = reply!(connection.randr_get_crtc_info(selected, resources.config_timestamp)); + if crtc.status != randr::SetConfig::SUCCESS || crtc.mode == 0 { + fail(&mut result, libc::EAGAIN); + return result; + } + let Some(mode) = resources.modes.iter().find(|m| m.id == crtc.mode) else { + fail(&mut result, libc::EAGAIN); + return result; + }; + result.put("resolution", format!("{}x{}", mode.width, mode.height), "RandR current mode, before rotation/scaling"); + let flags = u32::from(mode.mode_flags); + if let Some(hz) = refresh_hz( + mode.dot_clock, + mode.htotal, + mode.vtotal, + flags & u32::from(randr::ModeFlag::INTERLACE) != 0, + flags & u32::from(randr::ModeFlag::DOUBLE_SCAN) != 0, + ) { + result.put("refresh", number(hz), "RandR current mode pixel clock/totals; nominal rate, not measured/VRR rate"); + } + let rotation = u16::from(crtc.rotation); + if let Some(degrees) = rotation_degrees(rotation) { + result.put("rotation", degrees, "RandR CRTC rotation, counterclockwise"); + } + result.put("reflection", reflection(rotation), "RandR CRTC reflection flags"); + match connection.randr_get_crtc_transform(selected).map_err(|_| ()).and_then(|c| c.reply().map_err(|_| ())) { + Err(()) => { + result.values.insert("scale".into(), error(libc::EPROTO)); + } + Ok(transform) if !transform.has_transforms => { + result.sources.insert("scale".into(), "RandR CRTC transforms are not supported by this output".into()); + } + Ok(transform) => { + let (text, axis_aligned) = scale_text(&transform.current_transform); + result.put( + "scale", + text, + if axis_aligned { + "RandR transform: framebuffer pixels per output pixel (X, Y); excludes desktop DPI and toolkit scaling" + } else { + "RandR uses a non-axis-aligned transform; no single scale factor describes it" + }, + ); + } + } + // Reject a result spanning a modeset/hotplug rather than showing mixed data. + match connection.randr_get_screen_resources_current(root).map_err(|_| ()).and_then(|c| c.reply().map_err(|_| ())) { + Ok(after) if after.config_timestamp == resources.config_timestamp && after.timestamp == resources.timestamp => {} + other => { + result.values.clear(); + result.sources.clear(); + fail(&mut result, if other.is_ok() { libc::EAGAIN } else { libc::EPROTO }); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refresh_accounts_for_interlace_and_double_scan() { + let hz = refresh_hz(148_500_000, 2200, 1125, false, false).unwrap(); + assert!((hz - 60.0).abs() < 1e-9); + assert!((refresh_hz(74_250_000, 2200, 1125, true, false).unwrap() - 60.0).abs() < 1e-9); + assert!((refresh_hz(148_500_000, 2200, 1125, false, true).unwrap() - 30.0).abs() < 1e-9); + assert_eq!(refresh_hz(0, 2200, 1125, false, false), None); + assert_eq!(refresh_hz(1, 0, 1125, false, false), None); + } + + #[test] + fn rotation_and_reflection_flags() { + assert_eq!((rotation_degrees(1), rotation_degrees(2), rotation_degrees(4), rotation_degrees(8)), (Some("0"), Some("90"), Some("180"), Some("270"))); + assert_eq!(rotation_degrees(3), None, "two rotation bits are not a rotation"); + assert_eq!(rotation_degrees(1 | 16), Some("0"), "reflection bits do not affect rotation"); + assert_eq!((reflection(1), reflection(1 | 16), reflection(1 | 32), reflection(1 | 48)), ("none", "x", "y", "xy")); + } + + #[test] + fn transforms_report_scale_only_when_axis_aligned() { + let m = |m11, m22, m12, m33| x11rb::protocol::render::Transform { + matrix11: m11, + matrix12: m12, + matrix13: 0, + matrix21: 0, + matrix22: m22, + matrix23: 0, + matrix31: 0, + matrix32: 0, + matrix33: m33, + }; + assert_eq!(scale_text(&m(65536, 65536, 0, 65536)), ("1 1".into(), true)); + assert_eq!(scale_text(&m(131072, 98304, 0, 65536)), ("2 1.5".into(), true)); + assert_eq!(scale_text(&m(65536, 65536, 5, 65536)), ("custom".into(), false)); + assert!(!scale_text(&m(0, 65536, 0, 65536)).1, "non-positive scale"); + } + + #[test] + fn an_absent_server_is_an_io_error_not_a_guess() { + // A display that cannot exist: the connection fails before any query. + let found = collect_on(Some(":987"), "eDP-1"); + assert_eq!(found.backend, "X11 / RandR"); + assert_eq!((found.status.state, found.status.error), (ReadState::Error, libc::EIO)); + } +} diff --git a/crates/qldm-core/src/efivars.rs b/crates/qldm-core/src/efivars.rs new file mode 100644 index 0000000..8d99003 --- /dev/null +++ b/crates/qldm-core/src/efivars.rs @@ -0,0 +1,224 @@ +//! UEFI variable directory inventory, ported from `src/devices/efivariables.cpp`. +//! +//! Metadata only: the fixed efivarfs directory is opened without following a symlink, at +//! most [`ENTRY_LIMIT`] entries are examined, and no variable value is ever opened. +use crate::model::Device; +use std::fs::{self, OpenOptions}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub const EFIVARS_DIRECTORY: &str = "/sys/firmware/efi/efivars"; +pub const ENTRY_LIMIT: usize = 4096; + +/// Splits `-`; the GUID is lower-cased. Names may contain hyphens. +pub fn split_efi_variable_filename(filename: &str) -> Option<(String, String)> { + let guid = filename.get(filename.len().checked_sub(36)?..)?; + let name = filename.get(..filename.len() - 37)?; + let separator = filename.as_bytes().get(filename.len() - 37)?; + let groups: Vec<&str> = guid.split('-').collect(); + let shape_ok = groups.len() == 5 && groups.iter().zip([8, 4, 4, 4, 12]).all(|(g, n)| g.len() == n && g.bytes().all(|b| b.is_ascii_hexdigit())); + (shape_ok && *separator == b'-' && !name.is_empty()).then(|| (name.to_string(), guid.to_lowercase())) +} + +/// Readable label for a variable name. Standard mixed-case spellings are preserved and unknown +/// capital runs stay intact; no vendor meaning is decoded. +pub fn efi_variable_label(name: &str) -> String { + const PRESERVED: [&str; 6] = ["WiMAX", "WiFi", "NVMe", "PCIe", "IPv4", "IPv6"]; + let chars: Vec = name.chars().collect(); + let preserved_at = |offset: usize| -> Option<&'static str> { + PRESERVED.iter().copied().find(|word| { + let end = offset + word.chars().count(); + end <= chars.len() && chars[offset..end].iter().copied().eq(word.chars()) && (end == chars.len() || !chars[end].is_lowercase()) + }) + }; + let separator = |c: char| c.is_whitespace() || c == '_' || c == '-'; + let mut words: Vec = Vec::new(); + let mut offset = 0; + while offset < chars.len() { + if separator(chars[offset]) { + offset += 1; + continue; + } + if let Some(word) = preserved_at(offset) { + words.push(word.to_string()); + offset += word.chars().count(); + continue; + } + let start = offset; + offset += 1; + while offset < chars.len() { + let (previous, current) = (chars[offset - 1], chars[offset]); + if separator(current) || preserved_at(offset).is_some() { + break; + } + let next_lower = chars.get(offset + 1).is_some_and(|c| c.is_lowercase()); + if (previous.is_lowercase() && current.is_uppercase()) + || (previous.is_uppercase() && current.is_uppercase() && next_lower) + || (previous.is_ascii_digit() && current.is_uppercase() && next_lower) + { + break; + } + offset += 1; + } + let word: String = chars[start..offset].iter().collect(); + // A known compound joining an identifier and the endorsement-key acronym; other + // runs such as EDID, UUID and ordinary words like IDLE stay whole. + if word == "IDEK" { + words.extend(["ID".to_string(), "EK".to_string()]); + } else { + words.push(word); + } + } + let mut label = words.join(" "); + for prefix in ["Boot", "Driver", "SysPrep"] { + if let Some(hex) = name.strip_prefix(prefix) { + if hex.len() == 4 && hex.bytes().all(|b| b.is_ascii_hexdigit()) { + label = format!("{} {hex}", if prefix == "SysPrep" { "Sys Prep" } else { prefix }); + } + } + } + label.split_whitespace().collect::>().join(" ") +} + +/// Result of scanning the variable directory. +#[derive(Default)] +pub struct EfiScan { + pub devices: Vec, + /// Entries that could not be examined, plus one if the entry limit cut the scan short. + pub skipped: usize, +} + +/// Lists public (world-readable, regular-file) variables in `directory`, sorted by path. +/// A missing or inaccessible directory is normal and yields an empty result. +pub fn scan_efi_variables(directory: &Path, cancel: Option<&AtomicBool>) -> EfiScan { + let mut scan = EfiScan::default(); + let Ok(handle) = OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(directory) else { + return scan; // EFI/efivarfs may be absent or unavailable to this account. + }; + // Every lookup goes through the opened directory, so a swapped path cannot redirect it. + let anchored = format!("/proc/self/fd/{}", handle.as_raw_fd()); + let Ok(entries) = fs::read_dir(&anchored) else { return scan }; + let dir_text = directory.to_string_lossy(); + let mut examined = 0; + for entry in entries { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + break; + } + let Ok(entry) = entry else { + scan.skipped += 1; + continue; + }; + examined += 1; + if examined > ENTRY_LIMIT { + scan.skipped += 1; + break; + } + let filename = entry.file_name().to_string_lossy().into_owned(); + let Ok(info) = fs::symlink_metadata(format!("{anchored}/{filename}")) else { + scan.skipped += 1; + continue; + }; + // Root execution must not broaden the public inventory. Never follow links. + if !info.file_type().is_file() || info.mode() & 0o004 == 0 { + continue; + } + let Some((name, guid)) = split_efi_variable_filename(&filename) else { continue }; + let mut device = Device { + path: format!("{dir_text}/{filename}"), + parent_path: dir_text.to_string(), + sysname: filename, + subsystem: "efivarfs".into(), + category: "efi", + hidden: true, // Firmware records follow the virtual/internal visibility toggle. + name, + name_source: "UEFI variable filename; contents not read".into(), + incarnation: format!("{}:{}", info.dev(), info.ino()), + ..Default::default() + }; + device.properties.insert("EFI_VENDOR_GUID".into(), guid); + scan.devices.push(device); + } + scan.devices.sort_by(|a, b| a.path.cmp(&b.path)); + scan +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::{symlink, PermissionsExt}; + + const GUID: &str = "8BE4DF61-93CA-11D2-AA0D-00E098032B8C"; + + #[test] + fn filenames_split_name_and_lowercase_guid() { + let (name, guid) = split_efi_variable_filename(&format!("BootCurrent-{GUID}")).unwrap(); + assert_eq!((name.as_str(), guid.as_str()), ("BootCurrent", "8be4df61-93ca-11d2-aa0d-00e098032b8c")); + let (hyphenated, _) = split_efi_variable_filename(&format!("Vendor-Setting-{}", GUID.to_lowercase())).unwrap(); + assert_eq!(hyphenated, "Vendor-Setting"); + assert!(split_efi_variable_filename("BootCurrent-not-a-guid").is_none()); + assert!(split_efi_variable_filename(&format!("-{GUID}")).is_none(), "name must not be empty"); + assert!(split_efi_variable_filename("é").is_none()); + } + + #[test] + fn labels_split_words_and_keep_acronyms() { + for (name, want) in [ + ("BootCurrent", "Boot Current"), + ("Boot000A", "Boot 000A"), + ("SecureBoot", "Secure Boot"), + ("PKDefault", "PK Default"), + ("WiMAXModuleID", "WiMAX Module ID"), + ("OfflineUniqueIDEKPubCRC", "Offline Unique ID EK Pub CRC"), + ("LoaderDevicePartUUID", "Loader Device Part UUID"), + ("AMITCGPPIVAR", "AMITCGPPIVAR"), + ("EDID1Nv", "EDID1 Nv"), + ("SmbiosV3EntryPointTable", "Smbios V3 Entry Point Table"), + ("SysPrep0001", "Sys Prep 0001"), + ("Driver00AB", "Driver 00AB"), + ("Vendor-Setting", "Vendor Setting"), + ] { + assert_eq!(efi_variable_label(name), want, "{name}"); + } + } + + #[test] + fn directory_scan_is_metadata_only_public_and_sorted() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let file = |name: &str, mode: u32| { + let path = root.join(name); + fs::write(&path, b"secret").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + }; + file(&format!("Zeta-{GUID}"), 0o644); + file(&format!("Alpha-{GUID}"), 0o604); + file(&format!("Private-{GUID}"), 0o600); + file("NoGuid", 0o644); + symlink(root.join(format!("Alpha-{GUID}")), root.join(format!("Link-{GUID}"))).unwrap(); + let scan = scan_efi_variables(root, None); + let names: Vec<_> = scan.devices.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["Alpha", "Zeta"], "only public regular files with a GUID, sorted by path"); + let alpha = &scan.devices[0]; + assert_eq!((alpha.category, alpha.subsystem.as_str(), alpha.hidden), ("efi", "efivarfs", true)); + assert_eq!(alpha.properties["EFI_VENDOR_GUID"], GUID.to_lowercase()); + assert_eq!(alpha.parent_path, root.to_string_lossy()); + } + + #[test] + fn entry_limit_and_missing_directory() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..ENTRY_LIMIT + 5 { + fs::write(dir.path().join(format!("V{i}-{GUID}")), b"").unwrap(); + } + let scan = scan_efi_variables(dir.path(), None); + assert!(scan.devices.len() <= ENTRY_LIMIT); + assert_eq!(scan.skipped, 1); + let gone = scan_efi_variables(&dir.path().join("missing"), None); + assert!(gone.devices.is_empty() && gone.skipped == 0); + let link = dir.path().join("dirlink"); + symlink(dir.path(), &link).unwrap(); + assert!(scan_efi_variables(&link, None).devices.is_empty(), "a directory symlink is not followed"); + } +} diff --git a/crates/qldm-core/src/enumerate.rs b/crates/qldm-core/src/enumerate.rs new file mode 100644 index 0000000..3495822 --- /dev/null +++ b/crates/qldm-core/src/enumerate.rs @@ -0,0 +1,279 @@ +//! udev enumeration, ported from `Enumerator::collect()` in `src/devices/enumerator.cpp`. +//! Only identity, binding, an explicit udev property allowlist and a fixed set of +//! documented sysfs attributes are read: no recursive reads or hardware queries. +use crate::cpuinfo::{parse_cpu_models, read_cpuinfo}; +use crate::efivars::{scan_efi_variables, EFIVARS_DIRECTORY}; +use crate::model::{classify, Attribute, Device, ReadState}; +use crate::modulenames::ModuleNames; +use crate::naming::name_device; +use crate::presentation::refine_presentation; +use crate::resources::{collect_device_resources, DeviceResources, ResourceIssue}; +use crate::sysfs::read_attribute; +use std::collections::HashMap; +use std::os::unix::fs::MetadataExt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +const PROPERTY_ALLOWLIST: [&str; 19] = [ + "ID_MODEL_FROM_DATABASE", + "ID_MODEL", + "NAME", + "ID_V4L_PRODUCT", + "MODALIAS", + "PCI_CLASS", + "PCI_ID", + "ID_VENDOR_FROM_DATABASE", + "ID_VENDOR", + "ID_BUS", + "ID_INPUT_KEYBOARD", + "ID_INPUT_MOUSE", + "ID_INPUT_TOUCHPAD", + "ID_INPUT_POINTINGSTICK", + "ID_INPUT_SWITCH", + "PRODUCT", + "PHYS", + "DEVNUM", + "USEC_INITIALIZED", +]; + +fn text(value: Option<&std::ffi::OsStr>) -> String { + value.map(|v| v.to_string_lossy().into_owned()).unwrap_or_default() +} + +/// Lazily read `/proc/cpuinfo` once per scan; only per-processor models are kept. +struct CpuModels { + read: bool, + state: Attribute, + models: HashMap, +} + +impl CpuModels { + fn attribute_for(&mut self, sysname: &str) -> Attribute { + if !self.read { + let info = read_cpuinfo(); + if info.state == ReadState::Available { + self.models = parse_cpu_models(&info.value); + } + self.state = Attribute { value: String::new(), ..info }; + self.read = true; + } + let mut model = Attribute { state: self.state.state, value: String::new(), error: self.state.error }; + let number = sysname.strip_prefix("cpu").and_then(|n| n.parse::().ok()); + match number.and_then(|n| self.models.get(&n)) { + Some(value) => model.value = value.clone(), + None if model.state == ReadState::Available => model.state = ReadState::Unavailable, + None => {} + } + model + } +} + +/// The documented per-subsystem attributes, each read from this device's own directory. +fn read_attributes(d: &mut Device) { + let single = match d.subsystem.as_str() { + "input" | "video4linux" => Some("name"), + "sound" if d.sysname.starts_with("card") => Some("id"), + "power_supply" => Some("model_name"), + "nvme" => Some("model"), + "usb" if d.devtype == "usb_device" => Some("product"), + _ => None, + }; + let mut keys: Vec<&str> = Vec::new(); + match d.subsystem.as_str() { + "hdaudio" => keys.extend(["vendor_name", "chip_name"]), + "power_supply" => keys.extend(["type", "manufacturer"]), + "usb" if d.devtype == "usb_device" => keys.push("manufacturer"), + "drm" if d.sysname.contains('-') => keys.extend(["status", "enabled"]), + _ => {} + } + keys.extend(single); + for key in keys { + let attribute = read_attribute(format!("{}/{key}", d.path)); + d.attributes.insert(key.to_string(), attribute); + } +} + +fn driver_module(d: &Device) -> String { + if d.driver.is_empty() { + return String::new(); + } + match std::fs::canonicalize(format!("{}/driver/module", d.path)) { + Ok(target) if target.starts_with("/sys/module/") => target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + _ => String::new(), + } +} + +/// Fills missing database names from the udev hwdb via the device's own MODALIAS (pci/usb only). +fn supplement_from_hwdb(hwdb: &udev::Hwdb, d: &mut Device) { + let wants = |d: &Device, key: &str| d.properties.get(key).is_none_or(|v| v.is_empty()); + if !(d.subsystem == "pci" || d.subsystem == "usb") || !(wants(d, "ID_MODEL_FROM_DATABASE") || (d.subsystem == "usb" && wants(d, "ID_VENDOR_FROM_DATABASE"))) + { + return; + } + let alias = d.properties.get("MODALIAS").cloned().unwrap_or_default(); + if alias.is_empty() { + return; + } + for key in ["ID_MODEL_FROM_DATABASE", "ID_VENDOR_FROM_DATABASE"] { + if !wants(d, key) { + continue; + } + if let Some(value) = hwdb.query_one(alias.as_str(), key) { + d.properties.insert(key.to_string(), value.to_string_lossy().into_owned()); + d.property_sources.insert(key.to_string(), format!("hwdb: {key} ({alias})")); + } + } +} + +/// Opens the device directory without following a symlink and reads its resources; an open +/// failure becomes one explicit issue instead of a silent absence. +fn resources_for(d: &Device) -> DeviceResources { + use std::os::unix::fs::OpenOptionsExt; + let opened = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(&d.path); + match opened { + Ok(dir) => collect_device_resources(&dir, d, None), + Err(e) => { + let error = e.raw_os_error().unwrap_or(libc::EIO); + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENOENT => ReadState::Unavailable, + libc::ENODEV => ReadState::Removed, + _ => ReadState::Error, + }; + DeviceResources { + issues: vec![ResourceIssue { source: d.path.clone(), error: Attribute { state, value: String::new(), error } }], + ..Default::default() + } + } + } +} + +/// Devices plus how many records could not be examined. +pub struct ScanResult { + pub devices: Vec, + pub skipped: usize, +} + +pub fn scan() -> Result, String> { + scan_cancellable(None).map(|result| result.devices) +} + +/// Like [`scan`], but stops between records once `cancel` is set (the partial result is then +/// the caller's to discard) and reports skipped records. +pub fn scan_cancellable(cancel: Option<&AtomicBool>) -> Result { + let mut enumerator = udev::Enumerator::new().map_err(|e| format!("Could not create the udev context: {e}"))?; + let found = enumerator.scan_devices().map_err(|e| format!("Could not enumerate kernel devices: {e}"))?; + // hwdb is an optional local naming database, still accessed through libudev. + let hwdb = udev::Hwdb::new().ok(); + let mut module_names = ModuleNames::new(); + let mut cpu_models = CpuModels { read: false, state: Attribute::default(), models: HashMap::new() }; + let mut devices = Vec::new(); + let mut skipped = 0; + for raw in found { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + break; + } + let path = raw.syspath().to_string_lossy().into_owned(); + if !path.starts_with("/sys/devices/") { + skipped += 1; + continue; + } + let mut properties = HashMap::new(); + for key in PROPERTY_ALLOWLIST { + if let Some(value) = raw.property_value(key) { + properties.insert(key.to_string(), value.to_string_lossy().into_owned()); + } + } + let mut device = Device { + path, + parent_path: raw.parent().map(|p| p.syspath().to_string_lossy().into_owned()).unwrap_or_default(), + subsystem: text(raw.subsystem()), + sysname: raw.sysname().to_string_lossy().into_owned(), + devtype: text(raw.devtype()), + driver: text(raw.driver()), + properties, + ..Default::default() + }; + if let Some(hwdb) = &hwdb { + supplement_from_hwdb(hwdb, &mut device); + } + device.driver_module = driver_module(&device); + if device.subsystem == "cpu" { + let model = cpu_models.attribute_for(&device.sysname); + device.attributes.insert("cpu_model".into(), model); + } + let Ok(before) = std::fs::metadata(&device.path) else { + skipped += 1; + continue; + }; + device.incarnation = format!("{}:{}:{}", before.dev(), before.ino(), device.properties.get("USEC_INITIALIZED").map_or("", String::as_str)); + read_attributes(&mut device); + if device.subsystem == "pci" || device.subsystem == "pnp" { + device.resources = Some(Arc::new(resources_for(&device))); + } + name_device(&mut device); + module_names.supplement(&mut device); + // A path replaced while its attributes were read describes two devices: drop it. + match std::fs::metadata(&device.path) { + Ok(after) if after.dev() == before.dev() && after.ino() == before.ino() => {} + _ => { + skipped += 1; + continue; + } + } + classify(&mut device); + devices.push(device); + } + if !cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + refine_presentation(&mut devices); + let efi = scan_efi_variables(std::path::Path::new(EFIVARS_DIRECTORY), cancel); + skipped += efi.skipped; + devices.extend(efi.devices); + } + Ok(ScanResult { devices, skipped }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scan_finds_devices_with_a_specific_category() { + let devices = scan().expect("udev scan"); + assert!(!devices.is_empty()); + assert!(devices.iter().any(|d| d.category != "other")); + } + + #[test] + fn every_device_has_an_incarnation_and_drm_connectors_report_status() { + let devices = scan().expect("udev scan"); + assert!(devices.iter().filter(|d| d.subsystem != "efivarfs").all(|d| d.incarnation.split(':').count() == 3)); + for connector in devices.iter().filter(|d| d.subsystem == "drm" && d.sysname.contains('-')) { + let status = &connector.attributes["status"]; + assert_eq!(status.state, ReadState::Available, "{}", connector.path); + } + } + + #[test] + fn every_device_has_a_name_and_source() { + let devices = scan().expect("udev scan"); + assert!(devices.iter().all(|d| !d.name.is_empty() && !d.name_source.is_empty())); + let cpu = devices.iter().find(|d| d.subsystem == "cpu").expect("a cpu device"); + assert!(cpu.name_source.starts_with("/proc/cpuinfo"), "{}", cpu.name_source); + } + + #[test] + fn pci_devices_carry_a_resource_snapshot() { + let devices = scan().expect("udev scan"); + let pci: Vec<_> = devices.iter().filter(|d| d.subsystem == "pci").collect(); + assert!(!pci.is_empty() && pci.iter().all(|d| d.resources.is_some())); + assert!(pci.iter().any(|d| d.resources.as_ref().is_some_and(|r| r.items.iter().any(|i| i.pci)))); + } + + #[test] + fn cpu_devices_get_a_model_attribute() { + let devices = scan().expect("udev scan"); + let cpu = devices.iter().find(|d| d.subsystem == "cpu").expect("a cpu device"); + assert!(cpu.attributes.contains_key("cpu_model")); + } +} diff --git a/crates/qldm-core/src/ethernet.rs b/crates/qldm-core/src/ethernet.rs new file mode 100644 index 0000000..72149c5 --- /dev/null +++ b/crates/qldm-core/src/ethernet.rs @@ -0,0 +1,710 @@ +//! Read-only ethtool snapshot for one Ethernet-style, non-wireless interface, ported from +//! `src/devices/ethernetproperties.cpp`. +//! +//! Uses a private, non-blocking generic-netlink socket with single `GET` requests only: no +//! dumps, no notification subscriptions and no `SET` commands, so link state and modes are +//! observed without ever changing an interface. A shared 2 s budget covers the family lookup +//! and every reply for one interface; replies are strictly validated, and a snapshot whose +//! carrier changed between two reads is rejected whole. +use crate::dirfd::{available, io_errno}; +use crate::model::{Attribute, ReadState}; +use std::collections::HashSet; +use std::ffi::CString; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +const REPLY_LIMIT: usize = 65536; +const BIT_LIMIT: u32 = 4096; +const BUDGET: Duration = Duration::from_millis(2000); + +// Generic netlink and ethtool netlink constants (linux/genetlink.h, linux/ethtool_netlink.h). +const NLMSG_HDRLEN: usize = 16; +const GENL_HDRLEN: usize = 4; +const NLA_HDRLEN: usize = 4; +const NLA_F_NESTED: u16 = 0x8000; +const NLA_TYPE_MASK: u16 = 0x3fff; +const NLM_F_REQUEST: u16 = 1; +const NLM_F_MULTI: u16 = 2; +const NLM_F_DUMP_INTR: u16 = 16; +const NLMSG_ERROR: u16 = 2; +const GENL_ID_CTRL: u16 = 0x10; +const GENL_MIN_ID: u16 = 0x10; +const CTRL_CMD_NEWFAMILY: u8 = 1; +const CTRL_CMD_GETFAMILY: u8 = 3; +const CTRL_ATTR_FAMILY_ID: u16 = 1; +const CTRL_ATTR_FAMILY_NAME: u16 = 2; +const ETHTOOL_GENL_VERSION: u8 = 1; +const ETHTOOL_MSG_LINKMODES_GET: u8 = 4; +const ETHTOOL_MSG_LINKSTATE_GET: u8 = 6; +const A_HEADER_DEV_INDEX: u16 = 1; +const A_LINKSTATE_HEADER: u16 = 1; +const A_LINKSTATE_LINK: u16 = 2; +const A_LINKMODES_HEADER: u16 = 1; +const A_LINKMODES_AUTONEG: u16 = 2; +const A_LINKMODES_OURS: u16 = 3; +const A_LINKMODES_PEER: u16 = 4; +const A_LINKMODES_SPEED: u16 = 5; +const A_LINKMODES_DUPLEX: u16 = 6; +const A_BITSET_NOMASK: u16 = 1; +const A_BITSET_SIZE: u16 = 2; +const A_BITSET_BITS: u16 = 3; +const A_BITSET_BITS_BIT: u16 = 1; +const A_BITSET_BIT_INDEX: u16 = 1; +const A_BITSET_BIT_NAME: u16 = 2; +const A_BITSET_BIT_VALUE: u16 = 3; + +#[derive(Clone, Debug)] +pub struct EthernetProperties { + pub applicable: bool, + pub scope: Attribute, + pub carrier: Attribute, + /// Mbps, not measured throughput. + pub speed: Attribute, + /// Linux `DUPLEX_*` value. + pub duplex: Attribute, + /// Linux `AUTONEG_*` value. + pub autoneg: Attribute, + /// Kernel-provided bit names, one per line. + pub supported_modes: Attribute, + pub partner_modes: Attribute, + pub source: String, +} + +impl Default for EthernetProperties { + fn default() -> Self { + let not_applicable = Attribute { state: ReadState::NotApplicable, ..Default::default() }; + Self { + applicable: false, + scope: not_applicable, + carrier: Attribute::default(), + speed: Attribute::default(), + duplex: Attribute::default(), + autoneg: Attribute::default(), + supported_modes: Attribute::default(), + partner_modes: Attribute::default(), + source: String::new(), + } + } +} + +impl EthernetProperties { + /// Replaces every value with `reason` (the interface changed or was withdrawn). + pub fn invalidate(&mut self, reason: &Attribute) { + for value in + [&mut self.scope, &mut self.carrier, &mut self.speed, &mut self.duplex, &mut self.autoneg, &mut self.supported_modes, &mut self.partner_modes] + { + *value = reason.clone(); + } + } +} + +fn failure(error: i32) -> Attribute { + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENODEV | libc::ENXIO => ReadState::Removed, + libc::EOPNOTSUPP | libc::ENOSYS | libc::ENOENT | libc::EPROTONOSUPPORT | libc::EAFNOSUPPORT => ReadState::Unsupported, + libc::ENODATA => ReadState::Unavailable, + _ => ReadState::Error, + }; + Attribute { state, value: String::new(), error } +} + +// --------------------------------------------------------------------------------------- +// Netlink attribute encoding / decoding (pure, unit-tested) +// --------------------------------------------------------------------------------------- + +type Items = Vec<(u16, Vec)>; + +fn align4(n: usize) -> usize { + (n + 3) & !3 +} + +/// Splits a netlink attribute stream; any malformed length rejects the whole stream. +fn attributes(bytes: &[u8]) -> Option { + let mut items = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + if bytes.len() - offset < NLA_HDRLEN { + return None; + } + let length = u16::from_ne_bytes([bytes[offset], bytes[offset + 1]]) as usize; + let kind = u16::from_ne_bytes([bytes[offset + 2], bytes[offset + 3]]) & NLA_TYPE_MASK; + if length < NLA_HDRLEN || length > bytes.len() - offset || align4(length) > bytes.len() - offset { + return None; + } + items.push((kind, bytes[offset + NLA_HDRLEN..offset + length].to_vec())); + offset += align4(length); + } + Some(items) +} + +/// The single attribute of `kind`; `Err` if it appears more than once. +fn item(items: &Items, kind: u16) -> Result, ()> { + let mut found = None; + for (k, data) in items { + if *k == kind { + if found.is_some() { + return Err(()); + } + found = Some(data.as_slice()); + } + } + Ok(found) +} + +fn number(data: Option<&[u8]>) -> Option<[u8; N]> { + data.and_then(|d| d.try_into().ok()) +} + +fn append_attribute(bytes: &mut Vec, kind: u16, value: &[u8]) { + let length = NLA_HDRLEN + value.len(); + bytes.extend_from_slice(&(length as u16).to_ne_bytes()); + bytes.extend_from_slice(&kind.to_ne_bytes()); + bytes.extend_from_slice(value); + bytes.resize(bytes.len() + align4(length) - length, 0); +} + +fn device_header(index: u32) -> Vec { + let mut header = Vec::new(); + append_attribute(&mut header, A_HEADER_DEV_INDEX, &index.to_ne_bytes()); + header +} + +/// The reply header must nest this interface's index. +fn matches_device(items: &Items, header_kind: u16, index: u32) -> bool { + let Ok(Some(header)) = item(items, header_kind) else { return false }; + let Some(fields) = attributes(header) else { return false }; + matches!(item(&fields, A_HEADER_DEV_INDEX), Ok(value) if number::<4>(value).map(u32::from_ne_bytes) == Some(index)) +} + +fn integer(items: &Items, kind: u16, byte: bool) -> Attribute { + let Ok(value) = item(items, kind) else { return failure(libc::EPROTO) }; + let Some(value) = value else { return failure(libc::ENODATA) }; + if byte { + match number::<1>(Some(value)) { + Some([n]) => available(n.to_string()), + None => failure(libc::EPROTO), + } + } else { + match number::<4>(Some(value)) { + Some(n) => available(u32::from_ne_bytes(n).to_string()), + None => failure(libc::EPROTO), + } + } +} + +/// Decodes a compact or full bit-set into the kernel's names, one per line. +fn modes(items: &Items, kind: u16, supported: bool, cancel: Option<&AtomicBool>) -> Attribute { + let Ok(data) = item(items, kind) else { return failure(libc::EPROTO) }; + let Some(data) = data else { return failure(libc::ENODATA) }; + let Some(fields) = attributes(data) else { return failure(libc::EPROTO) }; + let (Ok(nomask), Ok(size_data), Ok(bits)) = (item(&fields, A_BITSET_NOMASK), item(&fields, A_BITSET_SIZE), item(&fields, A_BITSET_BITS)) else { + return failure(libc::EPROTO); + }; + let Some(bits) = bits else { return failure(libc::EPROTO) }; + if nomask.is_some_and(|n| !n.is_empty()) || (supported && nomask.is_some()) { + return failure(libc::EPROTO); + } + let mut size = BIT_LIMIT; + if let Some(raw) = size_data { + match number::<4>(Some(raw)) { + Some(n) => size = u32::from_ne_bytes(n), + None => return failure(libc::EPROTO), + } + } + if size > BIT_LIMIT { + return failure(libc::EOVERFLOW); + } + let Some(entries) = attributes(bits) else { return failure(libc::EPROTO) }; + if entries.len() > BIT_LIMIT as usize { + return failure(libc::EOVERFLOW); + } + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for (entry_kind, entry) in &entries { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return failure(libc::ECANCELED); + } + if *entry_kind != A_BITSET_BITS_BIT { + return failure(libc::EPROTO); + } + let Some(bit) = attributes(entry) else { return failure(libc::EPROTO) }; + let (Ok(index_data), Ok(name), Ok(set)) = (item(&bit, A_BITSET_BIT_INDEX), item(&bit, A_BITSET_BIT_NAME), item(&bit, A_BITSET_BIT_VALUE)) else { + return failure(libc::EPROTO); + }; + let Some(index) = number::<4>(index_data).map(u32::from_ne_bytes) else { return failure(libc::EPROTO) }; + if index >= size || !seen.insert(index) || set.is_some_and(|s| !s.is_empty()) { + return failure(libc::EPROTO); + } + let mut label = String::new(); + if let Some(name) = name { + // A NUL-terminated string with no embedded NUL. + let terminated = name.last() == Some(&0) && name.iter().position(|b| *b == 0) == Some(name.len() - 1); + if name.is_empty() || name.len() > 128 || !terminated { + return failure(libc::EPROTO); + } + label = String::from_utf8_lossy(&name[..name.len() - 1]).into_owned(); + } + if label.is_empty() { + label = format!("bit {index}"); + } + // OURS mask = supported bits (every listed entry); PEER NOMASK = advertised bits + // (every entry). In value/mask form only VALUE bits apply. + if supported || nomask.is_some() || set.is_some() { + names.push(label); + } + } + if names.is_empty() { + failure(libc::ENODATA) + } else { + available(names.join("\n")) + } +} + +// --------------------------------------------------------------------------------------- +// The request socket +// --------------------------------------------------------------------------------------- + +struct Query { + fd: Option, + error: i32, + sequence: u32, + started: Instant, +} + +impl Query { + fn new() -> Self { + // SAFETY: plain socket(2) call; the returned descriptor is owned by `OwnedFd`. + let raw = unsafe { libc::socket(libc::AF_NETLINK, libc::SOCK_RAW | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK, libc::NETLINK_GENERIC) }; + let (fd, error) = if raw < 0 { + (None, io_errno(&std::io::Error::last_os_error())) + } else { + // SAFETY: `raw` is a freshly created descriptor nobody else owns. + (Some(unsafe { OwnedFd::from_raw_fd(raw) }), 0) + }; + Self { fd, error, sequence: 0, started: Instant::now() } + } + + /// Sends one request and returns the attributes of the matching reply. + fn request(&mut self, family: u16, command: u8, reply_command: u8, attrs: &[u8], cancel: Option<&AtomicBool>) -> Option { + let fd = self.fd.as_ref()?.as_raw_fd(); + self.error = 0; + if self.started.elapsed() >= BUDGET { + self.error = libc::ETIMEDOUT; + return None; + } + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + self.error = libc::ECANCELED; + return None; + } + self.sequence = self.sequence.wrapping_add(1); + let total = NLMSG_HDRLEN + GENL_HDRLEN + attrs.len(); + let mut message = Vec::with_capacity(total); + message.extend_from_slice(&(total as u32).to_ne_bytes()); + message.extend_from_slice(&family.to_ne_bytes()); + message.extend_from_slice(&NLM_F_REQUEST.to_ne_bytes()); + message.extend_from_slice(&self.sequence.to_ne_bytes()); + message.extend_from_slice(&0u32.to_ne_bytes()); // port id: let the kernel pick + message.push(command); + message.push(if family == GENL_ID_CTRL { 1 } else { ETHTOOL_GENL_VERSION }); + message.extend_from_slice(&[0, 0]); + message.extend_from_slice(attrs); + // SAFETY: sockaddr_nl is plain data; the pointers are valid for the call. + let sent = unsafe { + let mut kernel: libc::sockaddr_nl = std::mem::zeroed(); + kernel.nl_family = libc::AF_NETLINK as u16; + libc::sendto( + fd, + message.as_ptr().cast(), + message.len(), + 0, + (&kernel as *const libc::sockaddr_nl).cast(), + std::mem::size_of::() as u32, + ) + }; + if sent != message.len() as isize { + self.error = if sent < 0 { io_errno(&std::io::Error::last_os_error()) } else { libc::EIO }; + return None; + } + let mut buffer = vec![0u8; REPLY_LIMIT]; + while self.started.elapsed() < BUDGET { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + self.error = libc::ECANCELED; + return None; + } + let remaining = BUDGET.saturating_sub(self.started.elapsed()).as_millis() as i32; + let mut ready = libc::pollfd { fd, events: libc::POLLIN, revents: 0 }; + // SAFETY: `ready` is a valid single-element array for the call's duration. + let status = unsafe { libc::poll(&mut ready, 1, remaining.clamp(0, 50)) }; + if status < 0 { + let error = io_errno(&std::io::Error::last_os_error()); + if error == libc::EINTR { + continue; + } + self.error = error; + return None; + } + if status == 0 { + continue; + } + if ready.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 { + self.error = libc::EIO; + return None; + } + // SAFETY: the buffer and the out-parameters are valid for the call. + let (count, sender_ok) = unsafe { + let mut sender: libc::sockaddr_nl = std::mem::zeroed(); + let mut size = std::mem::size_of::() as u32; + let count = + libc::recvfrom(fd, buffer.as_mut_ptr().cast(), REPLY_LIMIT, libc::MSG_TRUNC, (&mut sender as *mut libc::sockaddr_nl).cast(), &mut size); + (count, size as usize == std::mem::size_of::() && sender.nl_family == libc::AF_NETLINK as u16 && sender.nl_pid == 0) + }; + if count < 0 { + let error = io_errno(&std::io::Error::last_os_error()); + if error == libc::EAGAIN || error == libc::EINTR { + continue; + } + self.error = error; + return None; + } + let count = count as usize; + if count > REPLY_LIMIT { + self.error = libc::EOVERFLOW; + return None; + } + if !sender_ok { + continue; // Only the kernel may answer. + } + match parse_reply(&buffer[..count], self.sequence, family, reply_command) { + Reply::Items(items) => return Some(items), + Reply::Ignore => {} + Reply::Error(error) => { + self.error = error; + return None; + } + } + } + self.error = libc::ETIMEDOUT; + None + } +} + +enum Reply { + Items(Items), + /// Nothing for this request in the datagram (other sequence, or an ACK). + Ignore, + Error(i32), +} + +/// Walks the messages in one datagram for the reply to `sequence`. +fn parse_reply(datagram: &[u8], sequence: u32, family: u16, reply_command: u8) -> Reply { + let mut offset = 0; + while offset < datagram.len() { + if datagram.len() - offset < NLMSG_HDRLEN { + return Reply::Error(libc::EPROTO); + } + let header = &datagram[offset..offset + NLMSG_HDRLEN]; + let length = u32::from_ne_bytes(header[0..4].try_into().expect("4 bytes")) as usize; + let kind = u16::from_ne_bytes([header[4], header[5]]); + let flags = u16::from_ne_bytes([header[6], header[7]]); + let seq = u32::from_ne_bytes(header[8..12].try_into().expect("4 bytes")); + if length < NLMSG_HDRLEN || length > datagram.len() - offset || align4(length) > datagram.len() - offset { + return Reply::Error(libc::EPROTO); + } + let data = &datagram[offset + NLMSG_HDRLEN..offset + length]; + offset += align4(length); + if seq != sequence { + continue; + } + if kind == NLMSG_ERROR { + if data.len() < 4 { + return Reply::Error(libc::EPROTO); + } + let code = i32::from_ne_bytes(data[0..4].try_into().expect("4 bytes")); + if !(-4095..=0).contains(&code) { + return Reply::Error(libc::EPROTO); + } + if code == 0 { + continue; + } + return Reply::Error(-code); + } + if kind != family || flags & (NLM_F_MULTI | NLM_F_DUMP_INTR) != 0 || data.len() < GENL_HDRLEN { + return Reply::Error(libc::EPROTO); + } + if data[0] != reply_command { + return Reply::Error(libc::EPROTO); + } + return match attributes(&data[GENL_HDRLEN..]) { + Some(items) => Reply::Items(items), + None => Reply::Error(libc::EPROTO), + }; + } + Reply::Ignore +} + +fn if_index(name: &str) -> u32 { + CString::new(name).map_or(0, |c| { + // SAFETY: `c` is a valid NUL-terminated string. + unsafe { libc::if_nametoindex(c.as_ptr()) } + }) +} + +pub fn valid_interface_name(name: &str, index: u32) -> bool { + !name.is_empty() && name.len() < libc::IFNAMSIZ && !name.contains('\0') && index != 0 +} + +/// Collects link state and modes for a non-wireless Ethernet interface identified by name and +/// ifindex; a name that no longer maps to that index is reported as removed. +pub fn collect_ethernet_properties(name: &str, index: u32, cancel: Option<&AtomicBool>) -> EthernetProperties { + let mut result = EthernetProperties { + applicable: true, + scope: available(""), + source: format!("ethtool generic netlink: LINKSTATE_GET / LINKMODES_GET; ifindex {index} ({name})"), + ..Default::default() + }; + if !valid_interface_name(name, index) { + result.invalidate(&failure(libc::EINVAL)); + return result; + } + if if_index(name) != index { + result.invalidate(&failure(libc::ENODEV)); + return result; + } + let mut query = Query::new(); + if query.fd.is_none() { + result.invalidate(&failure(query.error)); + return result; + } + let mut lookup = Vec::new(); + append_attribute(&mut lookup, CTRL_ATTR_FAMILY_NAME, b"ethtool\0"); + let Some(family) = query.request(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, CTRL_CMD_NEWFAMILY, &lookup, cancel) else { + result.invalidate(&failure(query.error)); + return result; + }; + let id = item(&family, CTRL_ATTR_FAMILY_ID).ok().flatten().and_then(|d| number::<2>(Some(d))).map(u16::from_ne_bytes); + let Some(id) = id.filter(|id| *id >= GENL_MIN_ID) else { + result.invalidate(&failure(libc::EPROTO)); + return result; + }; + let mut state_request = Vec::new(); + append_attribute(&mut state_request, A_LINKSTATE_HEADER | NLA_F_NESTED, &device_header(index)); + let mut modes_request = Vec::new(); + append_attribute(&mut modes_request, A_LINKMODES_HEADER | NLA_F_NESTED, &device_header(index)); + + let read_carrier = |query: &mut Query| -> Attribute { + match query.request(id, ETHTOOL_MSG_LINKSTATE_GET, ETHTOOL_MSG_LINKSTATE_GET, &state_request, cancel) { + Some(state) if matches_device(&state, A_LINKSTATE_HEADER, index) => integer(&state, A_LINKSTATE_LINK, true), + Some(_) => failure(libc::EPROTO), + None => failure(query.error), + } + }; + result.carrier = read_carrier(&mut query); + // Settings and carrier support are independent; a failed carrier query must not hide + // available capabilities or autonegotiation configuration. + match query.request(id, ETHTOOL_MSG_LINKMODES_GET, ETHTOOL_MSG_LINKMODES_GET, &modes_request, cancel) { + Some(settings) if matches_device(&settings, A_LINKMODES_HEADER, index) => { + result.speed = integer(&settings, A_LINKMODES_SPEED, false); + result.duplex = integer(&settings, A_LINKMODES_DUPLEX, true); + result.autoneg = integer(&settings, A_LINKMODES_AUTONEG, true); + result.supported_modes = modes(&settings, A_LINKMODES_OURS, true, cancel); + result.partner_modes = modes(&settings, A_LINKMODES_PEER, false, cancel); + } + Some(_) => { + let bad = failure(libc::EPROTO); + (result.speed, result.duplex, result.autoneg) = (bad.clone(), bad.clone(), bad.clone()); + (result.supported_modes, result.partner_modes) = (bad.clone(), bad); + } + None => { + let bad = failure(query.error); + (result.speed, result.duplex, result.autoneg) = (bad.clone(), bad.clone(), bad.clone()); + (result.supported_modes, result.partner_modes) = (bad.clone(), bad); + } + } + if result.carrier.state == ReadState::Available { + let after = read_carrier(&mut query); + if after.state != ReadState::Available { + result.invalidate(&after); + } else if after.value != result.carrier.value { + result.invalidate(&failure(libc::EAGAIN)); + } + } + let not_applicable = Attribute { state: ReadState::NotApplicable, ..Default::default() }; + if result.carrier.state == ReadState::Available && result.carrier.value == "0" { + (result.speed, result.duplex, result.partner_modes) = (not_applicable.clone(), not_applicable.clone(), not_applicable.clone()); + } else if result.carrier.state != ReadState::Available { + let carrier = result.carrier.clone(); + (result.speed, result.duplex, result.partner_modes) = (carrier.clone(), carrier.clone(), carrier); + } + if result.autoneg.state == ReadState::Available && result.autoneg.value == "0" { + result.partner_modes = not_applicable; + } + if if_index(name) != index { + result.invalidate(&failure(libc::ENODEV)); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nested(kind: u16, children: &[Vec]) -> Vec { + let mut out = Vec::new(); + append_attribute(&mut out, kind | NLA_F_NESTED, &children.concat()); + out + } + fn leaf(kind: u16, value: &[u8]) -> Vec { + let mut out = Vec::new(); + append_attribute(&mut out, kind, value); + out + } + fn bit(index: u32, name: Option<&str>, value: bool) -> Vec { + let mut parts = vec![leaf(A_BITSET_BIT_INDEX, &index.to_ne_bytes())]; + if let Some(name) = name { + parts.push(leaf(A_BITSET_BIT_NAME, format!("{name}\0").as_bytes())); + } + if value { + parts.push(leaf(A_BITSET_BIT_VALUE, &[])); + } + nested(A_BITSET_BITS_BIT, &parts) + } + fn bitset(kind: u16, extra: &[Vec], bits: &[Vec]) -> Items { + let mut children = extra.to_vec(); + children.push(nested(A_BITSET_BITS, bits)); + attributes(&nested(kind, &children)).unwrap() + } + + #[test] + fn attribute_streams_are_strict() { + let good = [leaf(1, &[1, 2, 3]), leaf(2, &7u32.to_ne_bytes())].concat(); + let items = attributes(&good).unwrap(); + assert_eq!((items[0].0, items[0].1.len(), items[1].0), (1, 3, 2)); + assert!(attributes(&good[..good.len() - 1]).is_none(), "truncated"); + assert!(attributes(&[3, 0, 1, 0]).is_none(), "length below header size"); + assert!(attributes(&[0xff, 0xff, 1, 0, 0, 0, 0, 0]).is_none(), "length past the end"); + assert!(item(&attributes(&[leaf(1, &[1]), leaf(1, &[2])].concat()).unwrap(), 1).is_err(), "duplicates rejected"); + assert_eq!(attributes(&nested(5, &[])).unwrap()[0].0, 5, "nested flag masked from the type"); + } + + #[test] + fn integers_and_device_headers() { + let items = attributes(&[leaf(2, &[1]), leaf(5, &1000u32.to_ne_bytes()), leaf(9, &[1, 2])].concat()).unwrap(); + assert_eq!(integer(&items, 2, true).value, "1"); + assert_eq!(integer(&items, 5, false).value, "1000"); + assert_eq!(integer(&items, 5, true).error, libc::EPROTO, "wrong width"); + assert_eq!(integer(&items, 7, true).state, ReadState::Unavailable, "absent is ENODATA"); + let reply = attributes(&nested(A_LINKSTATE_HEADER, &[leaf(A_HEADER_DEV_INDEX, &3u32.to_ne_bytes())])).unwrap(); + assert!(matches_device(&reply, A_LINKSTATE_HEADER, 3)); + assert!(!matches_device(&reply, A_LINKSTATE_HEADER, 4), "another interface"); + assert!(!matches_device(&attributes(&[]).unwrap(), A_LINKSTATE_HEADER, 3), "missing header"); + } + + #[test] + fn supported_modes_list_every_entry_and_partner_modes_use_the_value_bits() { + let supported = bitset( + A_LINKMODES_OURS, + &[leaf(A_BITSET_SIZE, &8u32.to_ne_bytes())], + &[bit(0, Some("10baseT/Half"), false), bit(5, Some("1000baseT/Full"), false), bit(6, None, false)], + ); + assert_eq!(modes(&supported, A_LINKMODES_OURS, true, None).value, "10baseT/Half\n1000baseT/Full\nbit 6"); + + let partner = + bitset(A_LINKMODES_PEER, &[leaf(A_BITSET_SIZE, &8u32.to_ne_bytes())], &[bit(1, Some("10baseT/Full"), true), bit(5, Some("1000baseT/Full"), false)]); + assert_eq!(modes(&partner, A_LINKMODES_PEER, false, None).value, "10baseT/Full", "only value bits when not nomask"); + let advertised = bitset( + A_LINKMODES_PEER, + &[leaf(A_BITSET_NOMASK, &[]), leaf(A_BITSET_SIZE, &8u32.to_ne_bytes())], + &[bit(1, Some("10baseT/Full"), false), bit(5, Some("1000baseT/Full"), false)], + ); + assert_eq!(modes(&advertised, A_LINKMODES_PEER, false, None).value, "10baseT/Full\n1000baseT/Full", "nomask: every entry"); + let none = bitset(A_LINKMODES_PEER, &[leaf(A_BITSET_SIZE, &8u32.to_ne_bytes())], &[bit(1, Some("x"), false)]); + assert_eq!(modes(&none, A_LINKMODES_PEER, false, None).state, ReadState::Unavailable, "no set bits"); + } + + #[test] + fn malformed_bit_sets_are_rejected() { + let duplicate = bitset(A_LINKMODES_OURS, &[leaf(A_BITSET_SIZE, &8u32.to_ne_bytes())], &[bit(1, Some("a"), false), bit(1, Some("b"), false)]); + assert_eq!(modes(&duplicate, A_LINKMODES_OURS, true, None).error, libc::EPROTO); + let out_of_range = bitset(A_LINKMODES_OURS, &[leaf(A_BITSET_SIZE, &4u32.to_ne_bytes())], &[bit(4, Some("a"), false)]); + assert_eq!(modes(&out_of_range, A_LINKMODES_OURS, true, None).error, libc::EPROTO); + let too_big = bitset(A_LINKMODES_OURS, &[leaf(A_BITSET_SIZE, &9000u32.to_ne_bytes())], &[bit(1, Some("a"), false)]); + assert_eq!(modes(&too_big, A_LINKMODES_OURS, true, None).error, libc::EOVERFLOW); + let unterminated = { + let parts = vec![leaf(A_BITSET_BIT_INDEX, &1u32.to_ne_bytes()), leaf(A_BITSET_BIT_NAME, b"abc")]; + bitset(A_LINKMODES_OURS, &[leaf(A_BITSET_SIZE, &8u32.to_ne_bytes())], &[nested(A_BITSET_BITS_BIT, &parts)]) + }; + assert_eq!(modes(&unterminated, A_LINKMODES_OURS, true, None).error, libc::EPROTO); + let nomask_on_supported = bitset(A_LINKMODES_OURS, &[leaf(A_BITSET_NOMASK, &[])], &[bit(1, Some("a"), false)]); + assert_eq!(modes(&nomask_on_supported, A_LINKMODES_OURS, true, None).error, libc::EPROTO); + assert_eq!(modes(&attributes(&[]).unwrap(), A_LINKMODES_OURS, true, None).state, ReadState::Unavailable); + let cancelled = AtomicBool::new(true); + assert_eq!(modes(&duplicate, A_LINKMODES_OURS, true, Some(&cancelled)).error, libc::ECANCELED); + } + + fn datagram(kind: u16, flags: u16, seq: u32, payload: &[u8]) -> Vec { + let total = NLMSG_HDRLEN + payload.len(); + let mut out = Vec::new(); + out.extend_from_slice(&(total as u32).to_ne_bytes()); + out.extend_from_slice(&kind.to_ne_bytes()); + out.extend_from_slice(&flags.to_ne_bytes()); + out.extend_from_slice(&seq.to_ne_bytes()); + out.extend_from_slice(&0u32.to_ne_bytes()); + out.extend_from_slice(payload); + out.resize(out.len() + align4(total) - total, 0); + out + } + + #[test] + fn replies_are_matched_by_sequence_family_and_command() { + let family = 0x1c; + let body = [&[6u8, 1, 0, 0][..], &leaf(2, &[1])].concat(); + match parse_reply(&datagram(family, 0, 7, &body), 7, family, 6) { + Reply::Items(items) => assert_eq!(items[0].0, 2), + _ => panic!("expected items"), + } + assert!(matches!(parse_reply(&datagram(family, 0, 8, &body), 7, family, 6), Reply::Ignore), "other sequence"); + assert!(matches!(parse_reply(&datagram(family, 0, 7, &body), 7, family, 4), Reply::Error(libc::EPROTO)), "wrong command"); + assert!(matches!(parse_reply(&datagram(family + 1, 0, 7, &body), 7, family, 6), Reply::Error(libc::EPROTO)), "wrong family"); + assert!(matches!(parse_reply(&datagram(family, NLM_F_MULTI, 7, &body), 7, family, 6), Reply::Error(libc::EPROTO)), "multipart"); + let err = |code: i32| [&code.to_ne_bytes()[..], &[0u8; 16][..]].concat(); + assert!(matches!(parse_reply(&datagram(NLMSG_ERROR, 0, 7, &err(-95)), 7, family, 6), Reply::Error(95))); + assert!(matches!(parse_reply(&datagram(NLMSG_ERROR, 0, 7, &err(0)), 7, family, 6), Reply::Ignore), "ACK"); + assert!(matches!(parse_reply(&datagram(NLMSG_ERROR, 0, 7, &err(5)), 7, family, 6), Reply::Error(libc::EPROTO)), "positive code"); + assert!(matches!(parse_reply(&[1, 2, 3], 7, family, 6), Reply::Error(libc::EPROTO)), "short"); + } + + #[test] + fn invalid_names_and_withdrawn_interfaces_are_reported_before_any_socket_use() { + assert_eq!(collect_ethernet_properties("", 1, None).carrier.error, libc::EINVAL); + assert_eq!(collect_ethernet_properties("eth0", 0, None).carrier.error, libc::EINVAL); + assert_eq!(collect_ethernet_properties(&"x".repeat(16), 1, None).carrier.error, libc::EINVAL); + let gone = collect_ethernet_properties("qldm-no-such", 99_999, None); + assert_eq!((gone.carrier.state, gone.scope.state), (ReadState::Removed, ReadState::Removed)); + assert!(gone.applicable); + } + + #[test] + fn real_ethernet_interfaces_answer_or_fail_explicitly() { + for entry in std::fs::read_dir("/sys/class/net").unwrap().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let is_ether = std::fs::read_to_string(entry.path().join("type")).is_ok_and(|t| t.trim() == "1"); + let wireless = entry.path().join("wireless").exists() || entry.path().join("phy80211").exists(); + if !is_ether || wireless { + continue; + } + let index = if_index(&name); + let found = collect_ethernet_properties(&name, index, None); + assert_eq!(found.scope.state, ReadState::Available, "{name}"); + // A reachable kernel answers carrier with 0/1; otherwise the failure is explicit. + match found.carrier.state { + ReadState::Available => assert!(matches!(found.carrier.value.as_str(), "0" | "1"), "{name}"), + other => assert_ne!(other, ReadState::NotApplicable, "{name}: {:?}", found.carrier), + } + if found.carrier.value == "0" { + assert_eq!(found.speed.state, ReadState::NotApplicable, "{name}"); + } + } + } +} diff --git a/crates/qldm-core/src/events.rs b/crates/qldm-core/src/events.rs new file mode 100644 index 0000000..0bf2e6c --- /dev/null +++ b/crates/qldm-core/src/events.rs @@ -0,0 +1,342 @@ +//! Bounded device-event history and session-local live identities, ported from +//! `src/devices/deviceevents.{h,cpp}`. A record is an owned udev observation, not evidence of +//! hardware health. Tokens are independent of paths and serial numbers; monitor gaps retire +//! every association conservatively. +use crate::model::Device; +use std::collections::{HashMap, HashSet}; +use std::time::SystemTime; + +const FIELD_LIMIT: usize = 4096; + +/// Wall time is the receipt time; monotonic elapsed time and sequence keep ordering. +#[derive(Clone, Debug, Default)] +pub struct DeviceEvent { + pub observed: Option, + pub elapsed_ms: i64, + pub sequence: u64, + pub instance: u64, + pub path: String, + pub action: String, + pub subsystem: String, + pub devtype: String, + pub driver: String, + pub initialized: String, + pub incarnation: String, + pub old_path: String, +} + +#[derive(Clone, Debug)] +pub struct DeviceEventsSnapshot { + pub started: SystemTime, + pub records: Vec, + pub evicted: u64, + pub unassociated: u64, + pub gaps: u64, + pub monitoring: bool, +} + +impl DeviceEventsSnapshot { + pub const LIMIT: usize = 1024; + pub const PER_DEVICE_LIMIT: usize = 256; +} + +impl Default for DeviceEventsSnapshot { + fn default() -> Self { + Self { started: SystemTime::now(), records: Vec::new(), evicted: 0, unassociated: 0, gaps: 0, monitoring: false } + } +} + +#[derive(Clone, Debug, Default)] +struct Identity { + token: u64, + incarnation: String, + initialized: String, + retired: bool, + /// Last observed sequence for this path; holes are normal. + sequence: u64, +} + +/// Worker-thread ownership only. +pub struct DeviceEventHistory { + identities: HashMap, + snapshot: DeviceEventsSnapshot, + next_token: u64, +} + +fn bounded(value: &mut String) { + if value.len() > FIELD_LIMIT { + let cut = (0..=FIELD_LIMIT).rev().find(|i| value.is_char_boundary(*i)).unwrap_or(0); + value.truncate(cut); + } +} + +impl DeviceEventHistory { + pub const IDENTITY_LIMIT: usize = 16384; + + pub fn new(started: SystemTime) -> Self { + Self { identities: HashMap::new(), snapshot: DeviceEventsSnapshot { started, ..Default::default() }, next_token: 0 } + } + + /// Whether `path` has a registry slot, creating one while there is room. + fn ensure_identity(&mut self, path: &str) -> bool { + if self.identities.contains_key(path) { + return true; + } + if self.identities.len() >= Self::IDENTITY_LIMIT { + return false; + } + self.identities.insert(path.to_string(), Identity::default()); + true + } + + fn retire(&mut self, path: &str) { + let prefix = format!("{path}/"); + for (key, identity) in &mut self.identities { + if key == path || key.starts_with(&prefix) { + identity.retired = true; + } + } + } + + pub fn observe(&mut self, mut event: DeviceEvent) { + if !event.path.starts_with("/sys/devices/") || event.path.len() > FIELD_LIMIT { + return; + } + // Bound all caller-owned strings too, including fixture/future collectors. + for value in [ + &mut event.path, + &mut event.action, + &mut event.subsystem, + &mut event.devtype, + &mut event.driver, + &mut event.initialized, + &mut event.incarnation, + &mut event.old_path, + ] { + bounded(value); + } + event.instance = 0; // Only this tracker may assign an association. + let mut stale = false; + let mut retire_self = false; + if self.ensure_identity(&event.path) { + let next = &mut self.next_token; + let current = self.identities.get_mut(&event.path).expect("ensured"); + // Reordered/duplicate payloads cannot change identity state. + stale = event.sequence != 0 && current.sequence != 0 && event.sequence <= current.sequence; + if !stale { + let stamp_changed = !current.initialized.is_empty() && !event.initialized.is_empty() && current.initialized != event.initialized; + let inode_changed = !current.incarnation.is_empty() && !event.incarnation.is_empty() && current.incarnation != event.incarnation; + let fresh = |next: &mut u64, event: &DeviceEvent| { + *next += 1; + Identity { + token: *next, + incarnation: event.incarnation.clone(), + initialized: event.initialized.clone(), + retired: false, + sequence: event.sequence, + } + }; + if event.action == "add" { + if current.token == 0 || current.retired || stamp_changed || inode_changed { + *current = fresh(next, &event); + } + event.instance = current.token; + } else if current.token != 0 && !stamp_changed && !inode_changed && (!current.retired || event.action == "remove") { + // Removal may have no readable inode, but can use a known stream identity. + // A late event with conflicting evidence stays unassociated. + event.instance = current.token; + } else if current.token == 0 && event.action != "remove" && !event.incarnation.is_empty() { + *current = fresh(next, &event); + event.instance = current.token; + } + if event.sequence > current.sequence { + current.sequence = event.sequence; + } + retire_self = event.action == "remove" && !stamp_changed && !inode_changed; + } + } + if retire_self { + self.retire(&event.path); + } + if !stale && !event.old_path.is_empty() && event.old_path != event.path { + let old = event.old_path.clone(); + self.retire(&old); + } + if event.instance == 0 { + self.snapshot.unassociated += 1; + } + if self.snapshot.records.len() == DeviceEventsSnapshot::LIMIT { + self.snapshot.records.remove(0); + self.snapshot.evicted += 1; + } + self.snapshot.records.push(event); + } + + /// A monitor gap: no association made before it can be trusted afterwards. + pub fn lose(&mut self) { + self.identities.clear(); + self.snapshot.gaps += 1; + } + + /// Matches the confirmed inventory to live identities. Missing or withheld records keep no + /// live association; their already-observed events stay in the ring for open dialogs. + pub fn reconcile(&mut self, devices: &mut [Device]) { + let mut present: HashSet = HashSet::new(); + for device in devices.iter_mut() { + present.insert(device.path.clone()); + device.event_instance = 0; + if !self.ensure_identity(&device.path) { + continue; + } + let initialized = device.properties.get("USEC_INITIALIZED").cloned().unwrap_or_default(); + let current = self.identities.get_mut(&device.path).expect("ensured"); + let same = current.token != 0 + && !current.retired + && ((!current.incarnation.is_empty() && current.incarnation == device.incarnation) + || (current.incarnation.is_empty() && !current.initialized.is_empty() && current.initialized == initialized)); + if same { + current.incarnation = device.incarnation.clone(); + current.initialized = initialized; + } else { + self.next_token += 1; + *current = Identity { token: self.next_token, incarnation: device.incarnation.clone(), initialized, retired: false, sequence: 0 }; + } + device.event_instance = current.token; + } + self.identities.retain(|path, _| present.contains(path)); + } + + pub fn snapshot(&self, monitoring: bool) -> DeviceEventsSnapshot { + DeviceEventsSnapshot { monitoring, ..self.snapshot.clone() } + } +} + +impl Default for DeviceEventHistory { + fn default() -> Self { + Self::new(SystemTime::now()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn fixture(path: &str, stamp: &str) -> Device { + let mut device = Device { path: path.into(), incarnation: format!("inode:{stamp}"), ..Default::default() }; + device.properties.insert("USEC_INITIALIZED".into(), stamp.into()); + device + } + + fn observation(device: &Device, action: &str, sequence: u64) -> DeviceEvent { + DeviceEvent { + path: device.path.clone(), + incarnation: if action == "remove" { String::new() } else { device.incarnation.clone() }, + initialized: device.properties.get("USEC_INITIALIZED").cloned().unwrap_or_default(), + action: action.into(), + sequence, + observed: Some(SystemTime::UNIX_EPOCH + Duration::from_millis(100_000 + sequence)), + elapsed_ms: sequence as i64, + ..Default::default() + } + } + + #[test] + fn identities_follow_observed_adds_removes_and_losses() { + let mut history = DeviceEventHistory::new(SystemTime::UNIX_EPOCH + Duration::from_secs(100)); + let parent = fixture("/sys/devices/usb/fixture", "old"); + let child = fixture(&format!("{}/input", parent.path), "old-child"); + let mut devices = vec![parent.clone(), child.clone()]; + history.reconcile(&mut devices); + let (old, old_child) = (devices[0].event_instance, devices[1].event_instance); + assert!(old != 0 && old_child != 0 && old != old_child, "distinct devices, distinct identities"); + + history.observe(observation(&parent, "change", 1)); + history.observe(observation(&parent, "remove", 2)); + history.observe(observation(&child, "remove", 3)); + let events = history.snapshot(true); + assert_eq!( + (events.records[0].instance, events.records[1].instance, events.records[2].instance), + (old, old, old_child), + "removal retains the observed parent/child instances" + ); + + // Even identical inode/initialization hints cannot undo observed removal. + history.observe(observation(&parent, "add", 4)); + history.observe(observation(&child, "add", 5)); + history.reconcile(&mut devices); + assert!(devices[0].event_instance != old && devices[1].event_instance != old_child, "reused path, new identities"); + assert_eq!(history.snapshot(true).records[3].instance, devices[0].event_instance, "add matches confirmed inventory"); + + history.observe(observation(&parent, "remove", 2)); + assert_eq!(history.snapshot(true).records.last().unwrap().instance, 0, "stale removal cannot retire a replacement"); + let replacement = devices[0].event_instance; + history.reconcile(&mut devices); + assert_eq!(devices[0].event_instance, replacement); + + let stale = fixture(&parent.path, "stale-other-instance"); + history.observe(observation(&stale, "change", 6)); + assert_eq!(history.snapshot(true).records.last().unwrap().instance, 0, "conflicting evidence stays unassociated"); + + history.lose(); + history.reconcile(&mut devices); + let after = history.snapshot(false); + assert!(devices[0].event_instance != replacement && after.gaps == 1 && !after.monitoring); + let stable = devices[0].event_instance; + history.reconcile(&mut devices); + assert_eq!(devices[0].event_instance, stable, "ordinary snapshots neither rotate identity nor manufacture events"); + + let before = history.snapshot(true).records.len(); + let mut invalid = observation(&parent, "change", 7); + invalid.path = "/sys/class/usb".into(); + history.observe(invalid); + assert_eq!(history.snapshot(true).records.len(), before, "noncanonical paths excluded"); + } + + #[test] + fn history_is_bounded_and_payloads_are_truncated() { + let parent = fixture("/sys/devices/usb/fixture", "old"); + let mut bounded = DeviceEventHistory::default(); + bounded.reconcile(&mut [parent.clone()]); + for i in 0..DeviceEventsSnapshot::LIMIT + 3 { + bounded.observe(observation(&parent, "change", i as u64 + 1)); + } + let events = bounded.snapshot(true); + assert_eq!(events.records.len(), DeviceEventsSnapshot::LIMIT); + assert_eq!((events.evicted, events.records[0].sequence), (3, 4)); + + let mut long = observation(&parent, "change", 2000); + long.driver = "x".repeat(10000); + bounded.observe(long); + assert_eq!(bounded.snapshot(true).records.last().unwrap().driver.len(), 4096); + } + + #[test] + fn stale_add_payload_is_not_authoritative_and_registry_is_capped() { + let parent = fixture("/sys/devices/usb/fixture", "old"); + let mut pending = DeviceEventHistory::default(); + pending.observe(observation(&parent, "add", 1)); + let mut unrelated = vec![fixture(&parent.path, "different")]; + pending.reconcile(&mut unrelated); + assert_ne!(unrelated[0].event_instance, pending.snapshot(true).records[0].instance); + + let mut capped = DeviceEventHistory::default(); + let mut many: Vec = (0..=DeviceEventHistory::IDENTITY_LIMIT).map(|i| fixture(&format!("/sys/devices/fixture/{i}"), "stamp")).collect(); + capped.reconcile(&mut many); + assert!(many[DeviceEventHistory::IDENTITY_LIMIT - 1].event_instance != 0); + assert_eq!(many.last().unwrap().event_instance, 0, "overflow leaves the device viewable but unassociated"); + } + + #[test] + fn rename_retires_the_old_path() { + let mut history = DeviceEventHistory::default(); + let device = fixture("/sys/devices/a/old", "1"); + let mut devices = vec![device.clone()]; + history.reconcile(&mut devices); + let mut moved = observation(&fixture("/sys/devices/a/new", "2"), "move", 9); + moved.old_path = device.path.clone(); + history.observe(moved); + history.observe(observation(&device, "change", 10)); + assert_eq!(history.snapshot(true).records.last().unwrap().instance, 0, "retired old path takes no late events"); + } +} diff --git a/crates/qldm-core/src/events_view.rs b/crates/qldm-core/src/events_view.rs new file mode 100644 index 0000000..c90aeca --- /dev/null +++ b/crates/qldm-core/src/events_view.rs @@ -0,0 +1,228 @@ +//! What the Events tab shows, ported from `PropertiesDialog::updateEvents` and its helpers in +//! `src/ui/propertiesdialog.cpp`: the retained udev events of exactly one device instance, newest +//! first, with an explanation of the scope and its gaps. A removed device keeps the events it had +//! displayed, as a read-only instance snapshot. +use crate::events::{DeviceEvent, DeviceEventsSnapshot}; +use crate::i18n::{arg, argn, format_local_iso, format_system_time_iso_ms, tr}; +use crate::model::Device; + +pub fn event_description(event: &DeviceEvent) -> String { + match event.action.as_str() { + "add" => tr("Device addition observed"), + "remove" => tr("Device removal observed"), + "change" => tr("Device change reported"), + "bind" => tr("Driver binding reported"), + "unbind" => tr("Driver unbinding reported"), + "move" => tr("Device path move reported"), + "online" => tr("Device online event reported"), + "offline" => tr("Device offline event reported"), + other => arg(tr("udev action: %1"), other), + } +} + +fn observed(event: &DeviceEvent) -> String { + event.observed.map(format_system_time_iso_ms).unwrap_or_default() +} + +/// Full detail text of one event. Each field is formatted once, so a literal `%9` inside a device +/// string can never become a later placeholder. +pub fn event_text(event: &DeviceEvent) -> String { + let value = |text: &str| if text.is_empty() { tr("Unavailable in event payload") } else { text.to_string() }; + [ + arg(tr("Observed: %1"), &observed(event)), + arg(tr("Elapsed since monitor startup: %1 ms"), &event.elapsed_ms.to_string()), + tr("Source: live udev observation"), + arg(tr("Action: %1"), &value(&event.action)), + arg(tr("Description: %1"), &event_description(event)), + arg(tr("Kernel path: %1"), &event.path), + arg(tr("Subsystem: %1"), &value(&event.subsystem)), + arg(tr("Device type: %1"), &value(&event.devtype)), + arg(tr("Driver in event payload: %1"), &value(&event.driver)), + arg(tr("udev sequence: %1"), &if event.sequence != 0 { event.sequence.to_string() } else { value("") }), + arg(tr("Initialization stamp: %1"), &value(&event.initialized)), + arg(tr("Local instance token: %1"), &event.instance.to_string()), + arg(tr("Previous path: %1"), &value(&event.old_path)), + ] + .join("\n") +} + +/// One table row of the Events tab: observed time, event type, description. +pub fn event_row(event: &DeviceEvent) -> [String; 3] { + [observed(event), event.action.clone(), event_description(event)] +} + +fn same_event(a: &DeviceEvent, b: &DeviceEvent) -> bool { + a.sequence == b.sequence && a.elapsed_ms == b.elapsed_ms && a.observed == b.observed && a.action == b.action +} + +/// Whether two displayed lists are identical, so unrelated events do not reset selection or +/// scroll position. +pub fn events_unchanged(a: &[DeviceEvent], b: &[DeviceEvent]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| same_event(x, y) && x.path == y.path && x.instance == y.instance) +} + +pub struct EventsUpdate { + /// Explanation of what is covered, shown as the tab notice's tooltip and prefixed to copies. + pub scope: String, + pub events: Vec, +} + +/// Computes the displayed events for `device`. `displayed` is what the tab currently shows; +/// once the device is `removed`, events it had displayed are kept even if globally evicted. +pub fn update_events(history: &DeviceEventsSnapshot, monitor_note: &str, device: &Device, removed: bool, displayed: &[DeviceEvent]) -> EventsUpdate { + let started = format_local_iso(history.started.duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)); + let mut scope = argn( + tr("Live udev events since %1. Receipt times; exact device instance only. Latest %3 events per device; %2 retained globally. Historical logs are not queried."), + &[ + &started.unwrap_or_else(|| tr("monitor startup")), + &DeviceEventsSnapshot::LIMIT.to_string(), + &DeviceEventsSnapshot::PER_DEVICE_LIMIT.to_string(), + ], + ); + let mut note = |text: String| { + scope.push('\n'); + scope.push_str(&text); + }; + if history.evicted > 0 { + note(arg(tr("%1 older events evicted from the global history."), &history.evicted.to_string())); + } + if history.unassociated > 0 { + note(arg(tr("%1 events could not be associated safely with an inventory instance."), &history.unassociated.to_string())); + } + if history.gaps > 0 { + note(arg(tr("%1 event-identity resets after monitor failures/losses; coverage is incomplete."), &history.gaps.to_string())); + } + if !history.monitoring { + note(tr("Live event monitoring is unavailable. Viewing and retained events remain available.")); + } + if !monitor_note.is_empty() { + note(monitor_note.to_string()); + } + if device.event_instance == 0 { + note(tr("No safe live-event association is available for this instance.")); + } + if device.subsystem == "efivarfs" { + note(tr("EFI variables are reconciled by inventory scans; libudev does not supply their value-change history.")); + } + let mut next: Vec = Vec::new(); + for event in history.records.iter().rev() { + if device.event_instance == 0 || event.instance != device.event_instance || event.path != device.path { + continue; + } + next.push(event.clone()); + if next.len() == DeviceEventsSnapshot::PER_DEVICE_LIMIT { + break; + } + } + if removed { + for old in displayed { + let retained = next.iter().any(|e| same_event(e, old)); + if !retained && next.len() < DeviceEventsSnapshot::PER_DEVICE_LIMIT { + next.push(old.clone()); + } + } + note(tr("Removed/replaced device: retained events are a read-only instance snapshot.")); + } + if next.is_empty() { + note(tr("No retained live events for this instance. This does not establish that the device has had no errors.")); + } + EventsUpdate { scope, events: next } +} + +/// Text copied from the Events tab: the scope followed by the chosen events. +pub fn events_copy_text(scope: &str, events: &[&DeviceEvent]) -> String { + let mut text = vec![scope.to_string()]; + text.extend(events.iter().map(|e| event_text(e))); + text.join("\n\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, SystemTime}; + + fn event(sequence: u64, action: &str, instance: u64, path: &str) -> DeviceEvent { + DeviceEvent { + observed: Some(SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_000 + sequence)), + elapsed_ms: sequence as i64, + sequence, + instance, + path: path.into(), + action: action.into(), + subsystem: "usb".into(), + ..Default::default() + } + } + fn device(instance: u64) -> Device { + Device { path: "/sys/devices/usb1".into(), event_instance: instance, ..Default::default() } + } + fn history(records: Vec) -> DeviceEventsSnapshot { + DeviceEventsSnapshot { started: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000), records, monitoring: true, ..Default::default() } + } + + #[test] + fn only_the_exact_instance_is_shown_newest_first() { + let records = vec![ + event(1, "add", 7, "/sys/devices/usb1"), + event(2, "change", 8, "/sys/devices/usb1"), + event(3, "change", 7, "/sys/devices/other"), + event(4, "remove", 7, "/sys/devices/usb1"), + ]; + let update = update_events(&history(records), "", &device(7), false, &[]); + assert_eq!(update.events.iter().map(|e| e.sequence).collect::>(), [4, 1]); + assert!(update.scope.starts_with("Live udev events since 20")); + assert!(update.scope.contains("Latest 256 events per device; 1024 retained globally.")); + assert!(!update.scope.contains("No retained")); + let none = update_events(&history(vec![]), "", &device(7), false, &[]); + assert!(none.scope.contains("No retained live events for this instance.")); + let unassociated = update_events(&history(vec![event(1, "add", 0, "/sys/devices/usb1")]), "", &device(0), false, &[]); + assert!(unassociated.events.is_empty() && unassociated.scope.contains("No safe live-event association")); + } + + #[test] + fn scope_reports_gaps_and_unavailable_monitoring() { + let mut h = history(vec![]); + h.evicted = 3; + h.unassociated = 2; + h.gaps = 1; + h.monitoring = false; + let scope = update_events(&h, "Changing device branches are being reconciled", &device(1), false, &[]).scope; + for part in [ + "3 older events evicted from the global history.", + "2 events could not be associated safely with an inventory instance.", + "1 event-identity resets after monitor failures/losses; coverage is incomplete.", + "Live event monitoring is unavailable.", + "Changing device branches are being reconciled", + ] { + assert!(scope.contains(part), "{part}"); + } + let efi = Device { subsystem: "efivarfs".into(), ..device(1) }; + assert!(update_events(&history(vec![]), "", &efi, false, &[]).scope.contains("libudev does not supply their value-change history")); + } + + #[test] + fn a_removed_device_keeps_what_it_displayed() { + let shown = vec![event(9, "change", 7, "/sys/devices/usb1"), event(1, "add", 7, "/sys/devices/usb1")]; + let h = history(vec![event(9, "change", 7, "/sys/devices/usb1")]); + let update = update_events(&h, "", &device(7), true, &shown); + assert_eq!(update.events.iter().map(|e| e.sequence).collect::>(), [9, 1], "the evicted add event is retained"); + assert!(update.scope.contains("Removed/replaced device")); + assert!(events_unchanged(&update.events, &update.events.clone())); + assert!(!events_unchanged(&update.events, &update.events[..1])); + } + + #[test] + fn event_text_marks_missing_payload_and_describes_actions() { + let text = event_text(&event(5, "bind", 3, "/sys/devices/usb1")); + assert!(text.contains("Action: bind") && text.contains("Description: Driver binding reported")); + assert!(text.contains("Driver in event payload: Unavailable in event payload")); + assert!(text.contains("udev sequence: 5") && text.contains("Local instance token: 3")); + assert_eq!(event_description(&event(1, "weird", 0, "p")), "udev action: weird"); + let literal = DeviceEvent { path: "/sys/devices/%9".into(), action: "add".into(), ..Default::default() }; + assert!(event_text(&literal).contains("Kernel path: /sys/devices/%9"), "device strings are never reinterpreted"); + let row = event_row(&event(5, "add", 3, "p")); + assert!(row[0].len() == 23 && row[1] == "add" && row[2] == "Device addition observed", "{row:?}"); + let copied = events_copy_text("scope", &[&event(1, "add", 1, "p")]); + assert!(copied.starts_with("scope\n\nObserved: ")); + } +} diff --git a/crates/qldm-core/src/functionnames.rs b/crates/qldm-core/src/functionnames.rs new file mode 100644 index 0000000..e5e3f0b --- /dev/null +++ b/crates/qldm-core/src/functionnames.rs @@ -0,0 +1,129 @@ +//! Static function captions for devices without model metadata, ported from +//! `src/devices/functionnames.cpp`. Only exact standard identifiers match. +use crate::model::Device; + +#[derive(Debug, PartialEq, Eq)] +pub struct FunctionName { + /// Untranslated English source text; the UI translates. + pub label: &'static str, + pub source: String, +} + +const ACPI_IDS: [(&str, &str); 23] = [ + ("ACPI0003", "ACPI power source"), + ("ACPI0007", "ACPI processor device"), + ("ACPI0008", "Ambient light sensor"), + ("ACPI000C", "ACPI processor aggregator"), + ("ACPI000E", "ACPI time and alarm device"), + ("ACPI000F", "User presence detector"), + ("PNP0103", "High precision event timer"), + ("PNP0303", "PS/2 keyboard interface"), + ("PNP0A03", "PCI bus"), + ("PNP0A08", "PCI Express bus"), + ("PNP0B00", "Real-time clock"), + ("PNP0C02", "Motherboard resources"), + ("PNP0C09", "ACPI embedded controller"), + ("PNP0C0A", "ACPI control-method battery"), + ("PNP0C0B", "ACPI fan"), + ("PNP0C0C", "ACPI power button"), + ("PNP0C0D", "ACPI lid switch"), + ("PNP0C0E", "ACPI sleep button"), + ("PNP0C14", "ACPI WMI interface"), + ("PNP0C50", "I2C human interface device"), + ("PNP0C60", "Display sensor"), + ("LNXTHERM", "ACPI thermal zone"), + ("LNXVIDEO", "ACPI video controller"), +]; + +const PLATFORM_ALIASES: [(&str, &str); 3] = [ + ("platform:coretemp", "Intel CPU temperature monitor"), + ("platform:rtc-efi", "EFI real-time clock interface"), + ("platform:alarmtimer", "Kernel alarm timer"), +]; + +const PCI_SERVICES: [(&str, &str); 4] = [ + ("aer", "PCI Express advanced error reporting service"), + ("pcie_bwctrl", "PCI Express bandwidth control service"), + ("pcie_pme", "PCI Express power-management event service"), + ("pciehp", "PCI Express hot-plug service"), +]; + +const FAUX_FUNCTIONS: [(&str, &str); 4] = [ + ("microcode", "CPU microcode interface"), + ("reg-dummy", "Dummy voltage regulator"), + ("regulatory", "Wireless regulatory interface"), + ("snd-soc-dummy", "Dummy SoC audio component"), +]; + +fn prop<'a>(d: &'a Device, key: &str) -> &'a str { + d.properties.get(key).map_or("", String::as_str) +} + +/// Verified Linux USB root hubs only; external hubs have port-style names. +pub fn usb_root_hub(d: &Device) -> Option { + if d.subsystem != "usb" || d.devtype != "usb_device" { + return None; + } + let bus: u32 = d.sysname.strip_prefix("usb")?.parse().ok()?; + let address: u32 = prop(d, "DEVNUM").parse().ok()?; + let product: Vec<&str> = prop(d, "PRODUCT").split('/').collect(); + if product.len() != 3 || bus == 0 || address != 1 { + return None; + } + let vendor = u32::from_str_radix(product[0], 16).ok()?; + let id = u32::from_str_radix(product[1], 16).ok()?; + if vendor != 0x1d6b { + return None; + } + let label = match id { + 1 => "USB 1.1 root hub", + 2 => "USB 2.0 root hub", + 3 => "USB 3.x root hub", + _ => return None, + }; + Some(FunctionName { label, source: "Linux USB root-hub topology and descriptor identity".into() }) +} + +pub fn known_function_name(d: &Device) -> Option { + if let Some(hub) = usb_root_hub(d) { + return Some(hub); + } + let alias = prop(d, "MODALIAS"); + let identifiers: Vec<&str> = match d.subsystem.as_str() { + "acpi" | "platform" | "i2c" if alias.starts_with("acpi:") => alias[5..].split(':').filter(|s| !s.is_empty()).collect(), + // PNP modaliases encode each identifier as d, including compatible IDs. + "pnp" if alias.starts_with("pnp:") => alias[4..].split('d').filter(|s| !s.is_empty()).collect(), + _ => Vec::new(), + }; + for id in identifiers { + if let Some((_, label)) = ACPI_IDS.iter().find(|(known, _)| known.eq_ignore_ascii_case(id)) { + return Some(FunctionName { label, source: format!("standard ACPI/PNP ID: {id}") }); + } + } + let table = |entries: &[(&'static str, &'static str)], key: &str| entries.iter().find(|(id, _)| *id == key).map(|(_, label)| *label); + if d.subsystem == "platform" { + if let Some(label) = table(&PLATFORM_ALIASES, alias) { + return Some(FunctionName { label, source: format!("documented kernel platform alias: {alias}") }); + } + } + if d.subsystem == "pci_express" { + if let Some(label) = table(&PCI_SERVICES, &d.driver) { + return Some(FunctionName { label, source: format!("PCI Express service; direct driver: {}", d.driver) }); + } + } + if d.subsystem == "faux" { + if let Some(label) = table(&FAUX_FUNCTIONS, &d.sysname) { + return Some(FunctionName { label, source: format!("documented kernel faux function: {}", d.sysname) }); + } + } + if d.subsystem == "serial-base" && d.driver == "port" { + return Some(FunctionName { label: "Serial port function", source: "serial-base subsystem; direct port binding".into() }); + } + if d.subsystem == "serial-base" && d.driver == "ctrl" { + return Some(FunctionName { label: "Serial controller function", source: "serial-base subsystem; direct ctrl binding".into() }); + } + if d.subsystem == "bluetooth" && d.sysname.starts_with("hci") && !d.sysname.contains(':') { + return Some(FunctionName { label: "Bluetooth host controller", source: "Bluetooth subsystem; host-controller interface".into() }); + } + None +} diff --git a/crates/qldm-core/src/i18n.rs b/crates/qldm-core/src/i18n.rs new file mode 100644 index 0000000..42936a2 --- /dev/null +++ b/crates/qldm-core/src/i18n.rs @@ -0,0 +1,193 @@ +//! Translation entry point. Captions are English source text (the gettext msgid); a catalogue in +//! the bound text domain translates them, and anything untranslated stays English. +//! Core stays free of any UI toolkit. +use std::ffi::{c_char, c_int, CStr, CString}; +use std::path::Path; + +// glibc ships gettext in libc itself, so no extra library or crate is needed. +extern "C" { + fn setlocale(category: c_int, locale: *const c_char) -> *mut c_char; + fn bindtextdomain(domain: *const c_char, dir: *const c_char) -> *mut c_char; + fn bind_textdomain_codeset(domain: *const c_char, codeset: *const c_char) -> *mut c_char; + fn dgettext(domain: *const c_char, msgid: *const c_char) -> *mut c_char; +} +const LC_ALL: c_int = 6; + +/// Text domain of the installed catalogues (`//LC_MESSAGES/.mo`). +pub const TEXT_DOMAIN: &str = "qlinuxdevicemanager"; +const DOMAIN: &CStr = c"qlinuxdevicemanager"; + +/// Selects the user's locale and binds the catalogue directory. `QLDM_LOCALE_DIR` overrides the +/// compiled-in `/share/locale` (used by tests and uninstalled runs). Call once at +/// startup; failures leave the English source text in place. +pub fn init() { + let dir = std::env::var_os("QLDM_LOCALE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| Path::new(option_env!("QLDM_LOCALE_DIR").unwrap_or("/usr/share/locale")).to_path_buf()); + let Ok(dir) = CString::new(std::os::unix::ffi::OsStrExt::as_bytes(dir.as_os_str())) else { return }; + // SAFETY: all pointers are valid NUL-terminated strings for the duration of each call. + unsafe { + setlocale(LC_ALL, c"".as_ptr()); + bindtextdomain(DOMAIN.as_ptr(), dir.as_ptr()); + bind_textdomain_codeset(DOMAIN.as_ptr(), c"UTF-8".as_ptr()); + } +} + +/// Translates an English source string through the bound catalogue (identity when untranslated). +pub fn tr(text: &str) -> String { + let Ok(msgid) = CString::new(text) else { return text.to_string() }; + if text.is_empty() { + return String::new(); // dgettext("") would return the catalogue header. + } + // SAFETY: dgettext returns msgid itself or a pointer into the loaded catalogue, valid here. + unsafe { + let translated = dgettext(DOMAIN.as_ptr(), msgid.as_ptr()); + if translated.is_null() { + text.to_string() + } else { + CStr::from_ptr(translated).to_string_lossy().into_owned() + } + } +} + +/// Replaces the first `%1` placeholder, as `QString::arg` did. +pub fn arg(text: String, value: &str) -> String { + text.replacen("%1", value, 1) +} + +/// Replaces `%1` and `%2` (in that order of appearance, as `QString::arg` twice would). +pub fn arg2(text: String, first: &str, second: &str) -> String { + text.replacen("%1", first, 1).replacen("%2", second, 1) +} + +/// Replaces `%1`..`%9` with `values` in order (multi-argument `QString::arg`). +pub fn argn(text: String, values: &[&str]) -> String { + let mut text = text; + for (index, value) in values.iter().enumerate().take(9) { + text = text.replacen(&format!("%{}", index + 1), value, 1); + } + text +} + +/// `%g`-style number (`QLocale::toString(x, 'g', digits)`): up to `digits` significant digits, +/// trailing zeros removed, exponent form outside 1e-4 ..< 10^digits. +pub fn format_g(value: f64, digits: usize) -> String { + if value == 0.0 || !value.is_finite() { + return if value.is_nan() { + "nan".into() + } else if value.is_infinite() { + if value > 0.0 { + "inf".into() + } else { + "-inf".into() + } + } else { + "0".into() + }; + } + let exponent = value.abs().log10().floor() as i32; + // Rounding to 8 digits can carry into the next decade (9.9999999 -> 10). + let rounded: f64 = format!("{:.*e}", digits - 1, value).parse().unwrap_or(value); + let exponent = if rounded.abs() >= 10f64.powi(exponent + 1) { exponent + 1 } else { exponent }; + let trim = |text: String| { + if text.contains('.') { + text.trim_end_matches('0').trim_end_matches('.').to_string() + } else { + text + } + }; + if !(-4..digits as i32).contains(&exponent) { + let text = format!("{:.*e}", digits - 1, value); + let (mantissa, power) = text.split_once('e').expect("exponent form"); + let power: i32 = power.parse().expect("integer exponent"); + return format!("{}e{}{:02}", trim(mantissa.to_string()), if power < 0 { '-' } else { '+' }, power.abs()); + } + trim(format!("{:.*}", (digits as i32 - 1 - exponent).max(0) as usize, value)) +} + +/// Integer with thousands separators, as `QLocale::toString(qlonglong)` gives for English. +/// Locale-specific separators arrive with the gettext work. +pub fn format_integer(value: u64) -> String { + let digits = value.to_string(); + let mut grouped = String::new(); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + grouped.push(','); + } + grouped.push(c); + } + grouped +} + +/// Fixed-point number (`QLocale::toString(double, 'f', precision)`). +pub fn format_fixed(value: f64, precision: usize) -> String { + format!("{value:.precision$}") +} + +/// IEC data size like `QLocale::formattedDataSize(bytes)`: plain bytes below 1 KiB, else two +/// decimals with a binary unit. +pub fn format_data_size(bytes: u64) -> String { + format_data_size_with(bytes, 2) +} + +/// Like [`format_data_size`] with `precision` decimals (capped at `3 * power`, as Qt does). +pub fn format_data_size_with(bytes: u64, precision: usize) -> String { + const UNITS: [&str; 7] = ["bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + if bytes == 0 { + return "0 bytes".into(); + } + let power = ((63 - bytes.leading_zeros()) / 10) as usize; + if power == 0 { + return format!("{bytes} bytes"); + } + format!("{} {}", format_fixed(bytes as f64 / 1024f64.powi(power as i32), precision.min(3 * power)), UNITS[power]) +} + +/// `YYYY-MM-DDTHH:MM:SS` in local time (`Qt::ISODate`), or `None` if the time is unrepresentable. +pub fn format_local_iso(seconds: i64) -> Option { + let time = seconds as libc::time_t; + // SAFETY: `tm` is a plain-old-data out-parameter, and localtime_r/strftime only write + // within the buffers given. + unsafe { + let mut tm: libc::tm = std::mem::zeroed(); + if libc::localtime_r(&time, &mut tm).is_null() { + return None; + } + let mut buffer = [0u8; 32]; + let format = c"%Y-%m-%dT%H:%M:%S"; + let written = libc::strftime(buffer.as_mut_ptr().cast(), buffer.len(), format.as_ptr(), &tm); + (written > 0).then(|| String::from_utf8_lossy(&buffer[..written]).into_owned()) + } +} + +/// `YYYY-MM-DDTHH:MM:SS.mmm` in local time (`Qt::ISODateWithMs`). +pub fn format_system_time_iso_ms(time: std::time::SystemTime) -> String { + let since = time.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let seconds = format_local_iso(since.as_secs() as i64).unwrap_or_default(); + format!("{seconds}.{:03}", since.subsec_millis()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_group_and_sizes_follow_qt() { + assert_eq!(format_integer(0), "0"); + assert_eq!(format_integer(999), "999"); + assert_eq!(format_integer(1234567), "1,234,567"); + assert_eq!(format_data_size(0), "0 bytes"); + assert_eq!(format_data_size(512), "512 bytes"); + assert_eq!(format_data_size(1024), "1.00 KiB"); + assert_eq!(format_data_size(1234), "1.21 KiB"); + assert_eq!(format_data_size(1_000_204_886_016), "931.51 GiB"); + assert_eq!(format_fixed(42.0, 1), "42.0"); + assert_eq!(arg("%1 bytes".into(), "5"), "5 bytes"); + } + + #[test] + fn local_time_is_iso_formatted() { + let text = format_local_iso(1_792_023_126).unwrap(); + assert_eq!((text.len(), &text[4..5], &text[10..11]), (19, "-", "T")); + } +} diff --git a/crates/qldm-core/src/label.rs b/crates/qldm-core/src/label.rs new file mode 100644 index 0000000..2c4c0cd --- /dev/null +++ b/crates/qldm-core/src/label.rs @@ -0,0 +1,224 @@ +//! Display captions, ported from `src/devices/devicelabel.cpp`. Reads owned metadata +//! only: no filesystem access. Static English captions pass through [`tr`]. +use crate::efivars::efi_variable_label; +use crate::i18n::{arg, tr}; +use crate::model::{input_device_name, is_audio_jack_switch, Device, ReadState}; + +fn prop<'a>(d: &'a Device, key: &str) -> &'a str { + d.properties.get(key).map_or("", String::as_str) +} + +/// A successful attribute read wins; a failed one yields nothing rather than the udev property. +fn supply_field(d: &Device, attribute: &str, property: &str) -> String { + match d.attributes.get(attribute) { + Some(a) => { + if a.state == ReadState::Available { + a.value.trim().to_string() + } else { + String::new() + } + } + None => prop(d, property).trim().to_string(), + } +} + +/// `^([A-Z0-9]{4,8}:[0-9]{2}) ([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4}) (Mouse|Touchpad)$` +fn generated_hid_name(name: &str) -> Option<(&str, u32, u32, &str)> { + let mut parts = name.split(' '); + let (id, ids, kind) = (parts.next()?, parts.next()?, parts.next()?); + if parts.next().is_some() { + return None; + } + let (head, tail) = id.split_once(':')?; + let head_ok = (4..=8).contains(&head.len()) && head.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()); + if !head_ok || tail.len() != 2 || !tail.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let (vendor, product) = ids.split_once(':')?; + let hex4 = |s: &str| (s.len() == 4 && s.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(s, 16).ok()).flatten(); + if !matches!(kind, "Mouse" | "Touchpad") { + return None; + } + Some((id, hex4(vendor)?, hex4(product)?, kind)) +} + +fn friendly_label(d: &Device) -> String { + if d.subsystem == "power_supply" { + match supply_field(d, "type", "POWER_SUPPLY_TYPE").as_str() { + "Mains" => return tr("AC power adapter"), + "Battery" => { + let vendor = supply_field(d, "manufacturer", "POWER_SUPPLY_MANUFACTURER"); + let mut model = supply_field(d, "model_name", "POWER_SUPPLY_MODEL_NAME"); + // Firmware role placeholders add no useful model identity. + if ["primary", "secondary", "battery"].iter().any(|p| model.eq_ignore_ascii_case(p)) { + model.clear(); + } + let mut label = if vendor.is_empty() { tr("Battery") } else { arg(tr("%1 battery"), &vendor) }; + if !model.is_empty() { + label += &format!(" ({model})"); + } + return label; + } + "USB" => { + // UCSI names append the connector number to the controller's name. + // Match the recorded direct parent, not an ambiguous trailing digit. + let controller = d.parent_path.rsplit('/').next().unwrap_or(""); + let prefix = format!("ucsi-source-psy-{controller}"); + let port = d.sysname.strip_prefix(&prefix).and_then(|s| s.parse::().ok()).filter(|p| *p > 0); + return match port { + Some(port) if !controller.is_empty() => arg(tr("USB-C power supply (port %1)"), &port.to_string()), + _ => tr("USB power supply"), + }; + } + _ => {} + } + } + if is_audio_jack_switch(d) { + let name = input_device_name(d); + if name.ends_with(" Headphone") || name == "Headphone" { + return tr("Headphone jack detection"); + } + if name.ends_with(" Mic") || name == "Mic" { + return tr("Microphone jack detection"); + } + let pcm = name.rsplit_once("HDMI/DP,pcm=").map_or("", |(_, n)| n); + return arg(tr("HDMI/DisplayPort jack detection (PCM %1)"), pcm); + } + if d.subsystem == "input" { + // Shorten only the kernel-generated I2C HID identity form. Product names + // from USB/Bluetooth and descriptive input names remain untouched. + let product: Vec<&str> = prop(d, "PRODUCT").split('/').collect(); + let bus = if product.len() == 4 { u32::from_str_radix(product[0], 16).ok() } else { None }; + if let (Some((id, vendor, product_id, kind)), Some(0x18)) = (generated_hid_name(input_device_name(d)), bus) { + let matches = u32::from_str_radix(product[1], 16).ok() == Some(vendor) && u32::from_str_radix(product[2], 16).ok() == Some(product_id); + if !matches { + return String::new(); + } + if kind == "Touchpad" && prop(d, "ID_INPUT_TOUCHPAD") == "1" { + return arg(tr("Touchpad (%1)"), id); + } + if kind == "Mouse" && prop(d, "ID_INPUT_MOUSE") == "1" && prop(d, "ID_INPUT_POINTINGSTICK") != "1" { + return arg(tr("Mouse (%1)"), id); + } + } + } + String::new() +} + +/// Caption for the tree and dialogs. Equal names are disambiguated by the kernel +/// identifier; `include_identifiers` also adds it to friendly captions. +pub fn device_display_name(d: &Device, include_identifiers: bool) -> String { + if d.subsystem == "efivarfs" { + let label = efi_variable_label(&d.name); + return if include_identifiers { format!("{label} [{}]", d.properties.get("EFI_VENDOR_GUID").map_or("", String::as_str)) } else { label }; + } + let label = friendly_label(d); + if !label.is_empty() { + return if include_identifiers { format!("{label} [{}]", d.sysname) } else { label }; + } + if d.name == d.sysname || d.name_source == "direct kernel driver and kernel name" { + return d.name.clone(); + } + let mut label = if d.name_translated { tr(&d.name) } else { d.name.clone() }; + if d.name_candidate { + label = arg(tr("%1 (module candidate)"), &label); + } + format!("{label} [{}]", d.sysname) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Attribute; + + fn attr(value: &str) -> Attribute { + Attribute { state: ReadState::Available, value: value.into(), error: 0 } + } + fn named(name: &str, sysname: &str) -> Device { + Device { name: name.into(), sysname: sysname.into(), name_source: "udev: ID_MODEL".into(), ..Default::default() } + } + + #[test] + fn plain_names_get_kernel_id_suffix_but_kernel_names_do_not() { + assert_eq!(device_display_name(&named("Wi-Fi 7", "wlo1"), false), "Wi-Fi 7 [wlo1]"); + assert_eq!(device_display_name(&named("wlo1", "wlo1"), false), "wlo1"); + let mut candidate = named("Some driver", "dev0"); + candidate.name_candidate = true; + assert_eq!(device_display_name(&candidate, false), "Some driver (module candidate) [dev0]"); + } + + #[test] + fn battery_and_mains_labels() { + let mut bat = Device { subsystem: "power_supply".into(), sysname: "BAT0".into(), ..Default::default() }; + bat.attributes.insert("type".into(), attr("Battery")); + bat.attributes.insert("manufacturer".into(), attr("HP")); + bat.attributes.insert("model_name".into(), attr("Primary")); + assert_eq!(device_display_name(&bat, false), "HP battery"); + bat.attributes.insert("model_name".into(), attr("L1234")); + assert_eq!(device_display_name(&bat, false), "HP battery (L1234)"); + let mut ac = Device { subsystem: "power_supply".into(), ..Default::default() }; + ac.attributes.insert("type".into(), attr("Mains")); + assert_eq!(device_display_name(&ac, false), "AC power adapter"); + } + + #[test] + fn usbc_port_uses_direct_parent() { + let mut d = Device { + subsystem: "power_supply".into(), + sysname: "ucsi-source-psy-USBC000:0012".into(), + parent_path: "/sys/devices/x/USBC000:001".into(), + ..Default::default() + }; + d.attributes.insert("type".into(), attr("USB")); + assert_eq!(device_display_name(&d, false), "USB-C power supply (port 2)"); + d.parent_path = "/sys/devices/x/OTHER".into(); + assert_eq!(device_display_name(&d, false), "USB power supply"); + } + + #[test] + fn touchpad_generated_identity_is_shortened_only_when_ids_match() { + let mut d = Device { subsystem: "input".into(), sysname: "input14".into(), ..Default::default() }; + d.properties.insert("NAME".into(), "\"SYNA3517:00 06CB:CFC6 Touchpad\"".into()); + d.properties.insert("PRODUCT".into(), "18/6cb/cfc6/100".into()); + d.properties.insert("ID_INPUT_TOUCHPAD".into(), "1".into()); + assert_eq!(device_display_name(&d, false), "Touchpad (SYNA3517:00)"); + d.properties.insert("PRODUCT".into(), "18/6cb/1111/100".into()); + assert_ne!(device_display_name(&d, false), "Touchpad (SYNA3517:00)"); + } + + #[test] + fn efi_variables_use_readable_labels() { + let mut d = Device { subsystem: "efivarfs".into(), name: "SecureBoot".into(), ..Default::default() }; + d.properties.insert("EFI_VENDOR_GUID".into(), "8be4df61".into()); + assert_eq!(device_display_name(&d, false), "Secure Boot"); + assert_eq!(device_display_name(&d, true), "Secure Boot [8be4df61]"); + } + + #[test] + fn stale_or_failed_battery_metadata_is_not_reused() { + let mut bat = Device { subsystem: "power_supply".into(), sysname: "BAT0".into(), name: "Primary".into(), ..Default::default() }; + bat.properties.insert("POWER_SUPPLY_TYPE".into(), "Battery".into()); + bat.properties.insert("POWER_SUPPLY_MANUFACTURER".into(), "ExampleVendor".into()); + bat.properties.insert("POWER_SUPPLY_MODEL_NAME".into(), "Primary".into()); + assert_eq!(device_display_name(&bat, false), "ExampleVendor battery"); + assert_eq!(bat.name, "Primary", "raw name preserved"); + bat.properties.remove("POWER_SUPPLY_MANUFACTURER"); + assert_eq!(device_display_name(&bat, false), "Battery"); + bat.properties.insert("POWER_SUPPLY_MODEL_NAME".into(), "Real model".into()); + assert_eq!(device_display_name(&bat, false), "Battery (Real model)"); + bat.attributes.insert("manufacturer".into(), Attribute { state: ReadState::PermissionDenied, value: "Stale vendor".into(), error: 13 }); + bat.properties.insert("POWER_SUPPLY_MANUFACTURER".into(), "Stale vendor".into()); + assert!(!device_display_name(&bat, false).contains("Stale")); + } + + #[test] + fn jack_labels() { + let mut d = Device { subsystem: "input".into(), sysname: "input12".into(), ..Default::default() }; + d.properties.insert("ID_INPUT_SWITCH".into(), "1".into()); + d.properties.insert("PHYS".into(), "\"ALSA\"".into()); + d.properties.insert("NAME".into(), "\"HDA Intel PCH Mic\"".into()); + assert_eq!(device_display_name(&d, false), "Microphone jack detection"); + d.properties.insert("NAME".into(), "\"HDA Intel PCH HDMI/DP,pcm=7\"".into()); + assert_eq!(device_display_name(&d, false), "HDMI/DisplayPort jack detection (PCM 7)"); + } +} diff --git a/crates/qldm-core/src/lib.rs b/crates/qldm-core/src/lib.rs new file mode 100644 index 0000000..4579a50 --- /dev/null +++ b/crates/qldm-core/src/lib.rs @@ -0,0 +1,45 @@ +pub mod camera; +pub mod cpuinfo; +pub mod dirfd; +pub mod display; +pub mod display_wayland; +pub mod display_x11; +pub mod efivars; +pub mod enumerate; +pub mod ethernet; +pub mod events; +pub mod events_view; +pub mod functionnames; +pub mod i18n; +pub mod label; +pub mod model; +pub mod modulenames; +pub mod monitor; +pub mod monitor_edid; +pub mod monitorstate; +pub mod naming; +pub mod network; +pub mod presentation; +pub mod properties; +pub mod propertytext; +pub mod report; +pub mod resourceformat; +pub mod resources; +pub mod search; +pub mod secureboot; +pub mod storage; +pub mod storage_presentation; +pub mod sysfs; +pub mod system; +pub mod system_report; +pub mod tree; +pub mod v4l2_sys; + +pub use enumerate::scan; + +/// Application identity from `project.json`, fixed at build time. +pub mod app { + pub const NAME: &str = env!("QLDM_NAME"); + pub const VERSION: &str = env!("QLDM_VERSION"); + pub const REPOSITORY: &str = env!("QLDM_REPOSITORY"); +} diff --git a/crates/qldm-core/src/model.rs b/crates/qldm-core/src/model.rs new file mode 100644 index 0000000..b09a3a2 --- /dev/null +++ b/crates/qldm-core/src/model.rs @@ -0,0 +1,393 @@ +//! Device model and classification, ported from `src/devices/device.{h,cpp}` +//! and `isAudioJackSwitch` in `devicelabel.cpp`. +use crate::events::DeviceEventsSnapshot; +use crate::resources::DeviceResources; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum ReadState { + Available, + #[default] + Unavailable, + PermissionDenied, + Removed, + Error, + Unsupported, + NotApplicable, +} + +/// One sysfs attribute read: unknown or failed reads must never look like values. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Attribute { + pub state: ReadState, + pub value: String, + pub error: i32, +} + +#[derive(Clone, Debug, Default)] +pub struct Device { + /// Canonical sysfs path; lookup key, not a hardware ID. + pub path: String, + pub parent_path: String, + pub subsystem: String, + pub sysname: String, + pub devtype: String, + /// Direct binding only; never inherited. + pub driver: String, + /// Observed `driver/module` link target name; absence is undetermined. + pub driver_module: String, + pub properties: HashMap, + pub attributes: HashMap, + /// `st_dev:st_ino:USEC_INITIALIZED`; separates a reused path from the same instance. + pub incarnation: String, + /// Best available name and where it came from; raw kernel names are never rewritten. + pub name: String, + pub name_source: String, + /// `name` is a static English caption that the UI translates. + pub name_translated: bool, + /// `name` came from an installed-module alias match, not a binding claim. + pub name_candidate: bool, + /// Source text for properties not read straight from udev (e.g. hwdb lookups). + pub property_sources: HashMap, + /// Immutable resource snapshot, collected by the worker for pci and pnp devices. + pub resources: Option>, + /// Presentation grouping only: the row that stands in for this one, never an ownership edge. + pub represented_by_path: String, + /// Session-local live-event association token; zero is unassociated. + pub event_instance: u64, + /// Assigned by inventory reconciliation; changes when a path is reused by another instance. + pub generation: u64, + /// Untranslated category id from `CATEGORIES`. + pub category: &'static str, + pub hidden: bool, +} + +/// One published scan: devices plus how trustworthy and complete it is. +#[derive(Clone, Debug, Default)] +pub struct Inventory { + pub devices: Vec, + pub error: String, + pub skipped: usize, + pub request: u64, + /// Paths with removal evidence, including descendants of a removed ancestor. + pub removed_paths: HashSet, + /// Event loss: no old generation can be trusted. + pub identity_lost: bool, + pub monitor_note: String, + pub events: DeviceEventsSnapshot, +} + +pub struct Category { + pub id: &'static str, + pub label: &'static str, + pub icon: &'static str, +} + +pub const CATEGORIES: [Category; 17] = [ + Category { id: "audio", label: "Audio devices", icon: "audio-card" }, + Category { id: "battery", label: "Batteries and power supplies", icon: "battery" }, + Category { id: "bluetooth", label: "Bluetooth", icon: "bluetooth" }, + Category { id: "camera", label: "Cameras and video devices", icon: "camera-web" }, + Category { id: "disk", label: "Disk drives", icon: "drive-harddisk" }, + Category { id: "display", label: "Display adapters", icon: "video-display" }, + Category { id: "hid", label: "Human interface devices", icon: "input-gaming" }, + Category { id: "keyboard", label: "Keyboards", icon: "input-keyboard" }, + Category { id: "mouse", label: "Mice and pointing devices", icon: "input-mouse" }, + Category { id: "monitor", label: "Display outputs", icon: "video-display" }, + Category { id: "network", label: "Network adapters", icon: "network-wired" }, + Category { id: "cpu", label: "Processors", icon: "cpu" }, + Category { id: "storage", label: "Storage controllers", icon: "drive-harddisk" }, + Category { id: "system", label: "System devices", icon: "computer" }, + Category { id: "usb", label: "USB controllers and devices", icon: "drive-removable-media-usb" }, + Category { id: "efi", label: "UEFI variables", icon: "preferences-system" }, + Category { id: "other", label: "Other devices", icon: "preferences-system" }, +]; + +/// Translated caption for a category id; unknown ids read as "Other devices". +pub fn category_label(id: &str) -> String { + crate::i18n::tr(category(id).label) +} + +pub fn category(id: &str) -> &'static Category { + CATEGORIES.iter().find(|c| c.id == id).unwrap_or(&CATEGORIES[16]) +} + +/// Assigns generations: an unchanged instance (same incarnation and event token at the same +/// path) keeps its old one, anything else gets a fresh one. Selection survives only on a match. +pub fn reconcile(next: &mut [Device], previous: &[Device], generation: &mut u64) { + let old: HashMap<&str, &Device> = previous.iter().map(|d| (d.path.as_str(), d)).collect(); + for d in next { + d.generation = match old.get(d.path.as_str()) { + Some(before) if before.incarnation == d.incarnation && before.event_instance == d.event_instance => before.generation, + _ => { + *generation += 1; + *generation + } + }; + } +} + +impl Device { + fn prop(&self, key: &str) -> &str { + self.properties.get(key).map_or("", String::as_str) + } + fn flag(&self, key: &str) -> bool { + self.prop(key) == "1" + } +} + +fn unquote(value: &str) -> &str { + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + &value[1..value.len() - 1] + } else { + value + } +} + +/// Port of `inputDeviceName`: sysfs `name` attribute first, else the unquoted udev NAME. +pub fn input_device_name(d: &Device) -> &str { + match d.attributes.get("name") { + Some(a) if a.state == ReadState::Available && !a.value.is_empty() => &a.value, + _ => unquote(d.prop("NAME")), + } +} + +/// Matches `(?:^| )(?:HDMI/DP,pcm=[0-9]+|Headphone|Mic)$` without a regex dependency. +fn is_jack_name(name: &str) -> bool { + let at_boundary = |prefix: &str| prefix.is_empty() || prefix.ends_with(' '); + for word in ["Headphone", "Mic"] { + if let Some(prefix) = name.strip_suffix(word) { + if at_boundary(prefix) { + return true; + } + } + } + const PCM: &str = "HDMI/DP,pcm="; + if let Some(at) = name.rfind(PCM) { + let digits = &name[at + PCM.len()..]; + return !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) && at_boundary(&name[..at]); + } + false +} + +pub fn is_audio_jack_switch(d: &Device) -> bool { + d.subsystem == "input" + && d.flag("ID_INPUT_SWITCH") + && unquote(d.prop("PHYS")) == "ALSA" + && !d.flag("ID_INPUT_KEYBOARD") + && !d.flag("ID_INPUT_MOUSE") + && !d.flag("ID_INPUT_TOUCHPAD") + && !d.flag("ID_INPUT_POINTINGSTICK") + && is_jack_name(input_device_name(d)) +} + +/// Port of `classify()` in `src/devices/device.cpp`. +pub fn classify(d: &mut Device) { + let virt = d.path.starts_with("/sys/devices/virtual/"); + d.category = "other"; + d.hidden = virt; + let n = d.sysname.clone(); + match d.subsystem.as_str() { + "pci" => { + let code = u32::from_str_radix(d.prop("PCI_CLASS"), 16).ok(); + d.category = match code.map(|c| (c >> 16, (c >> 8) & 0xff)) { + Some((1, _)) => "storage", + Some((2, _)) => "network", + Some((3, _)) => "display", + Some((4, _)) => "audio", + Some((0x0c, 3)) => "usb", + _ => "system", + }; + } + "usb" => { + d.category = "usb"; + d.hidden |= d.devtype != "usb_device"; + } + "block" => { + d.category = "disk"; + d.hidden |= d.devtype != "disk"; + } + "input" => { + if is_audio_jack_switch(d) { + d.category = "audio"; + d.hidden = true; + } else if d.flag("ID_INPUT_KEYBOARD") { + d.category = "keyboard"; + } else if d.flag("ID_INPUT_MOUSE") || d.flag("ID_INPUT_TOUCHPAD") || d.flag("ID_INPUT_POINTINGSTICK") { + d.category = "mouse"; + } else { + d.category = "hid"; + } + // Keep inputN functions; event/js/mouse endpoints are redundant in the type view. + d.hidden |= !n.starts_with("input"); + } + "hid" | "hidraw" => { + d.category = "hid"; + d.hidden = true; + } + "sound" => { + d.category = "audio"; + d.hidden |= !n.starts_with("card"); + } + "drm" => { + let connector = n.contains('-'); + d.category = if connector { "monitor" } else { "display" }; + let read = |key: &str, want: &str| d.attributes.get(key).is_some_and(|a| a.state == ReadState::Available && a.value == want); + // Hide a connector only when both reads establish disconnected + disabled; + // unknown or failed reads must not masquerade as an unused output. + let unused = read("status", "disconnected") && read("enabled", "disabled"); + d.hidden |= !connector || unused; + } + "net" => d.category = "network", + "bluetooth" => { + d.category = "bluetooth"; + d.hidden |= !n.starts_with("hci") || n.contains(':'); + } + "video4linux" => d.category = "camera", + "power_supply" => d.category = "battery", + "cpu" => d.category = "cpu", + s @ ("nvme" | "scsi_host" | "ata_port") => { + d.category = "storage"; + d.hidden |= s != "nvme"; + } + s @ ("platform" | "acpi" | "pnp") => { + d.category = "system"; + d.hidden |= s == "acpi" && d.driver.is_empty(); + } + // Unrecognized directly bound physical functions remain useful fallbacks. + _ => d.hidden |= d.driver.is_empty(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keyboard() -> Device { + let mut d = + Device { path: "/sys/devices/pci0000:00/usb1/1-1/input/input7".into(), subsystem: "input".into(), sysname: "input7".into(), ..Default::default() }; + d.properties.insert("ID_INPUT_KEYBOARD".into(), "1".into()); + d + } + + #[test] + fn keyboard_and_audio_functions_stay_visible() { + let mut k = keyboard(); + classify(&mut k); + assert_eq!((k.category, k.hidden), ("keyboard", false)); + let mut a = keyboard(); + a.subsystem = "sound".into(); + a.sysname = "card1".into(); + classify(&mut a); + assert_eq!((a.category, a.hidden), ("audio", false)); + } + + #[test] + fn input_endpoint_is_hidden() { + let mut e = keyboard(); + e.sysname = "event3".into(); + classify(&mut e); + assert!(e.hidden); + } + + #[test] + fn virtual_device_keeps_category_but_hides() { + let mut d = Device { path: "/sys/devices/virtual/net/lo".into(), subsystem: "net".into(), ..Default::default() }; + classify(&mut d); + assert_eq!((d.category, d.hidden), ("network", true)); + } + + #[test] + fn partition_is_hidden() { + let mut d = Device { subsystem: "block".into(), devtype: "partition".into(), ..Default::default() }; + classify(&mut d); + assert!(d.hidden); + } + + #[test] + fn pci_class_decoding() { + for (class, want) in [("0c0330", "usb"), ("020000", "network"), ("030000", "display"), ("040300", "audio"), ("010802", "storage"), ("060400", "system")] + { + let mut d = Device { subsystem: "pci".into(), ..Default::default() }; + d.properties.insert("PCI_CLASS".into(), class.into()); + classify(&mut d); + assert_eq!(d.category, want, "PCI_CLASS {class}"); + } + let mut bad = Device { subsystem: "pci".into(), ..Default::default() }; + classify(&mut bad); + assert_eq!(bad.category, "system"); + } + + #[test] + fn unknown_bound_function_is_visible_unbound_is_hidden() { + let mut bound = Device { subsystem: "unknown_bus".into(), driver: "example".into(), ..Default::default() }; + classify(&mut bound); + assert_eq!((bound.category, bound.hidden), ("other", false)); + let mut unbound = Device { subsystem: "unknown_bus".into(), ..Default::default() }; + classify(&mut unbound); + assert!(unbound.hidden); + } + + #[test] + fn audio_jack_switch_is_hidden_audio() { + let mut d = Device { subsystem: "input".into(), sysname: "input12".into(), ..Default::default() }; + d.properties.insert("ID_INPUT_SWITCH".into(), "1".into()); + d.properties.insert("PHYS".into(), "\"ALSA\"".into()); + d.properties.insert("NAME".into(), "\"HDA Intel PCH Headphone\"".into()); + classify(&mut d); + assert_eq!((d.category, d.hidden), ("audio", true)); + d.properties.insert("NAME".into(), "\"HDA Intel PCH HDMI/DP,pcm=3\"".into()); + assert!(is_audio_jack_switch(&d)); + d.properties.insert("NAME".into(), "\"Headphones\"".into()); + assert!(!is_audio_jack_switch(&d)); + } + + #[test] + fn drm_connector_hidden_only_when_disconnected_and_disabled() { + let attr = |v: &str| Attribute { state: ReadState::Available, value: v.into(), error: 0 }; + let mut d = Device { subsystem: "drm".into(), sysname: "card0-HDMI-A-1".into(), ..Default::default() }; + d.attributes.insert("status".into(), attr("disconnected")); + classify(&mut d); + assert!(!d.hidden, "enabled read missing must not hide"); + d.attributes.insert("enabled".into(), attr("disabled")); + classify(&mut d); + assert_eq!((d.category, d.hidden), ("monitor", true)); + } + + #[test] + fn reconcile_keeps_generation_only_for_the_same_instance() { + let mut keyboard = keyboard(); + keyboard.incarnation = "first".into(); + let mut counter = 0; + let mut first = vec![keyboard.clone()]; + reconcile(&mut first, &[], &mut counter); + let mut same = vec![keyboard.clone()]; + reconcile(&mut same, &first, &mut counter); + assert_eq!(same[0].generation, first[0].generation, "same instance keeps selection identity"); + + let mut reused = vec![Device { incarnation: "second".into(), ..keyboard.clone() }]; + reconcile(&mut reused, &first, &mut counter); + assert_ne!(reused[0].generation, first[0].generation, "reused path must not keep the old generation"); + + let mut other = keyboard.clone(); + other.path.push('x'); + let mut pair = vec![keyboard.clone(), other]; + reconcile(&mut pair, &[], &mut counter); + assert_ne!(pair[0].generation, pair[1].generation, "equal metadata must not merge devices"); + + let mut before = vec![Device { event_instance: 1, ..keyboard.clone() }]; + reconcile(&mut before, &[], &mut counter); + let mut after = vec![Device { event_instance: 2, ..keyboard }]; + reconcile(&mut after, &before, &mut counter); + assert_ne!(after[0].generation, before[0].generation, "retired live-event identity gets a new generation"); + } + + #[test] + fn category_table_matches_cpp_order() { + let ids: Vec<_> = CATEGORIES.iter().map(|c| c.id).collect(); + assert_eq!(ids.first(), Some(&"audio")); + assert_eq!(ids.last(), Some(&"other")); + assert_eq!(category("nope").id, "other"); + } +} diff --git a/crates/qldm-core/src/modulenames.rs b/crates/qldm-core/src/modulenames.rs new file mode 100644 index 0000000..910ce68 --- /dev/null +++ b/crates/qldm-core/src/modulenames.rs @@ -0,0 +1,394 @@ +//! Installed-module descriptions via libkmod, ported from `src/devices/modulenames.cpp`. +//! +//! libkmod is opened at run time (`dlopen`) like the C++ build's optional backends, so no +//! development headers are needed and its absence only disables module-based naming. +//! The context reads the running kernel's indexes with no modprobe.d overrides and no +//! subprocess. At most [`CACHE_LIMIT`] results, misses included, are kept per instance. +use crate::model::{Attribute, Device, ReadState}; +use std::collections::HashMap; +#[cfg(feature = "kmod")] +use std::ffi::c_int; +#[cfg(feature = "kmod")] +use std::ffi::{c_char, c_void, CStr, CString}; +#[cfg(feature = "kmod")] +use std::ptr; + +pub const CACHE_LIMIT: usize = 512; +#[cfg(feature = "kmod")] +const DESCRIPTION_LIMIT: usize = 4096; + +#[derive(Clone, Debug, Default)] +struct Lookup { + description: Attribute, + module: String, +} + +/// Fresh-context lookup of one installed module by name (no cache), as the Driver tab does. +pub fn module_details(name: &str) -> ModuleLookup { + match Kmod::open() { + Some(kmod) => kmod.details(name), + None => ModuleLookup::Unsupported, + } +} + +/// Worker-owned libkmod context plus a per-refresh lookup cache. +#[derive(Default)] +pub struct ModuleNames { + kmod: Option, + cache: HashMap, +} + +#[cfg(feature = "kmod")] +fn error_attribute(status: c_int) -> Attribute { + let error = -status; + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENOENT => ReadState::Unavailable, + _ => ReadState::Error, + }; + Attribute { state, value: String::new(), error } +} + +/// Everything libkmod knows about one installed module, for the Driver tab. +pub struct ModuleDetails { + pub filename: Attribute, + /// `modinfo` key/value pairs as bounded reads (a field over 4096 bytes is an error value). + pub info: Result, i32>, + /// More than 4096 entries were present; the list is cut off. + pub too_many: bool, +} + +pub enum ModuleLookup { + /// libkmod is not built in or could not be opened. + Unsupported, + /// Lookup failed with this errno. + Failed(i32), + /// The lookup succeeded but returned no module. + Missing, + Found(ModuleDetails), +} + +/// A text field no longer than `DESCRIPTION_LIMIT`, else an overflow error value. +#[cfg(feature = "kmod")] +fn bounded_field(bytes: &[u8]) -> Attribute { + if bytes.len() > DESCRIPTION_LIMIT { + Attribute { state: ReadState::Error, value: String::new(), error: libc::EOVERFLOW } + } else { + Attribute { state: ReadState::Available, value: String::from_utf8_lossy(bytes).into_owned(), error: 0 } + } +} + +#[cfg(feature = "kmod")] +mod backend { + use super::*; + type NewFn = unsafe extern "C" fn(*const c_char, *const *const c_char) -> *mut c_void; + type UnrefFn = unsafe extern "C" fn(*mut c_void) -> *mut c_void; + type LookupNameFn = unsafe extern "C" fn(*mut c_void, *const c_char, *mut *mut c_void) -> c_int; + type LookupFn = unsafe extern "C" fn(*mut c_void, *const c_char, *mut *mut c_void) -> c_int; + type ListNextFn = unsafe extern "C" fn(*const c_void, *const c_void) -> *mut c_void; + type GetModuleFn = unsafe extern "C" fn(*const c_void) -> *mut c_void; + type UnrefListFn = unsafe extern "C" fn(*mut c_void) -> *mut c_void; + type GetNameFn = unsafe extern "C" fn(*const c_void) -> *const c_char; + type GetPathFn = unsafe extern "C" fn(*const c_void) -> *const c_char; + type GetInfoFn = unsafe extern "C" fn(*const c_void, *mut *mut c_void) -> c_int; + type InfoStrFn = unsafe extern "C" fn(*const c_void) -> *const c_char; + type InfoFreeFn = unsafe extern "C" fn(*mut c_void); + + pub struct Kmod { + // Keeps the shared object mapped for as long as the function pointers below live. + _library: libloading::Library, + context: *mut c_void, + unref: UnrefFn, + module_unref: UnrefFn, + lookup_name: LookupNameFn, + lookup: LookupFn, + list_next: ListNextFn, + get_module: GetModuleFn, + unref_list: UnrefListFn, + get_name: GetNameFn, + get_path: GetPathFn, + get_info: GetInfoFn, + info_key: InfoStrFn, + info_value: InfoStrFn, + info_free: InfoFreeFn, + } + + impl Kmod { + pub fn open() -> Option { + // SAFETY: loading libkmod runs only its own initializers; every symbol is resolved + // with the exact C signature declared in libkmod.h before use. + unsafe { + let library = libloading::Library::new("libkmod.so.2").ok()?; + macro_rules! sym { + ($name:literal, $ty:ty) => { + *library.get::<$ty>(concat!($name, "\0").as_bytes()).ok()? + }; + } + let new: NewFn = sym!("kmod_new", NewFn); + let no_configuration: [*const c_char; 1] = [ptr::null()]; + let context = new(ptr::null(), no_configuration.as_ptr()); + if context.is_null() { + return None; + } + Some(Self { + unref: sym!("kmod_unref", UnrefFn), + module_unref: sym!("kmod_module_unref", UnrefFn), + lookup_name: sym!("kmod_module_new_from_name_lookup", LookupNameFn), + lookup: sym!("kmod_module_new_from_lookup", LookupFn), + list_next: sym!("kmod_list_next", ListNextFn), + get_module: sym!("kmod_module_get_module", GetModuleFn), + unref_list: sym!("kmod_module_unref_list", UnrefListFn), + get_name: sym!("kmod_module_get_name", GetNameFn), + get_path: sym!("kmod_module_get_path", GetPathFn), + get_info: sym!("kmod_module_get_info", GetInfoFn), + info_key: sym!("kmod_module_info_get_key", InfoStrFn), + info_value: sym!("kmod_module_info_get_value", InfoStrFn), + info_free: sym!("kmod_module_info_free_list", InfoFreeFn), + context, + _library: library, + }) + } + } + } + + impl Drop for Kmod { + fn drop(&mut self) { + // SAFETY: `context` came from kmod_new and is released exactly once, before the + // library field is dropped. + unsafe { + (self.unref)(self.context); + } + } + } + + impl Kmod { + pub fn details(&self, name: &str) -> ModuleLookup { + let Ok(encoded) = CString::new(name) else { return ModuleLookup::Failed(libc::EINVAL) }; + // SAFETY: as in `describe`: valid pointers for each call, every libkmod object + // released on every path. + unsafe { + let mut module: *mut c_void = ptr::null_mut(); + let status = (self.lookup_name)(self.context, encoded.as_ptr(), &mut module); + if status < 0 { + return ModuleLookup::Failed(-status); + } + if module.is_null() { + return ModuleLookup::Missing; + } + let path = (self.get_path)(module); + let filename = if path.is_null() { Attribute::default() } else { bounded_field(CStr::from_ptr(path).to_bytes()) }; + let mut list: *mut c_void = ptr::null_mut(); + let count = (self.get_info)(module, &mut list); + let mut too_many = false; + let info = if count < 0 { + Err(-count) + } else { + let mut entries = Vec::new(); + let mut entry = list; + while !entry.is_null() { + if entries.len() >= 4096 { + too_many = true; + break; + } + let (key, value) = ((self.info_key)(entry), (self.info_value)(entry)); + let field = |p: *const c_char| { + if p.is_null() { + Attribute::default() + } else { + bounded_field(CStr::from_ptr(p).to_bytes()) + } + }; + entries.push((field(key), field(value))); + entry = (self.list_next)(list, entry); + } + Ok(entries) + }; + if !list.is_null() { + (self.info_free)(list); + } + (self.module_unref)(module); + ModuleLookup::Found(ModuleDetails { filename, info, too_many }) + } + } + + pub fn describe(&self, key: &str, exact_name: bool) -> Lookup { + let kmod = self; + let mut result = Lookup::default(); + let Ok(encoded) = CString::new(key) else { return result }; + // SAFETY: all pointers passed are valid for the call; every object libkmod hands + // back is released with its matching unref/free function on every path below. + unsafe { + let mut module: *mut c_void = ptr::null_mut(); + if exact_name { + let status = (kmod.lookup_name)(kmod.context, encoded.as_ptr(), &mut module); + if status < 0 { + result.description = error_attribute(status); + } + } else { + let mut matches: *mut c_void = ptr::null_mut(); + let status = (kmod.lookup)(kmod.context, encoded.as_ptr(), &mut matches); + // Never select an arbitrary first module from ambiguous aliases. + if status == 0 && !matches.is_null() && (kmod.list_next)(matches, matches).is_null() { + module = (kmod.get_module)(matches); + } else if status < 0 { + result.description = error_attribute(status); + } + if !matches.is_null() { + (kmod.unref_list)(matches); + } + } + if !module.is_null() { + let name = (kmod.get_name)(module); + if !name.is_null() { + result.module = CStr::from_ptr(name).to_string_lossy().into_owned(); + } + let mut info: *mut c_void = ptr::null_mut(); + let status = (kmod.get_info)(module, &mut info); + if status < 0 { + result.description = error_attribute(status); + } else { + let mut entry = info; + while !entry.is_null() { + let (field, value) = ((kmod.info_key)(entry), (kmod.info_value)(entry)); + if !field.is_null() && !value.is_null() && CStr::from_ptr(field).to_bytes() == b"description" { + let bytes = CStr::from_ptr(value).to_bytes(); + if bytes.len() > DESCRIPTION_LIMIT { + result.description = Attribute { state: ReadState::Error, value: String::new(), error: libc::EOVERFLOW }; + break; + } + let text = String::from_utf8_lossy(bytes).trim().to_string(); + if !text.is_empty() { + result.description = Attribute { state: ReadState::Available, value: text, error: 0 }; + break; + } + } + entry = (kmod.list_next)(info, entry); + } + } + if !info.is_null() { + (kmod.info_free)(info); + } + (kmod.module_unref)(module); + } + } + result + } + } +} +#[cfg(not(feature = "kmod"))] +mod backend { + use super::*; + + pub struct Kmod; + + impl Kmod { + pub fn open() -> Option { + None + } + + pub fn describe(&self, _key: &str, _exact_name: bool) -> Lookup { + Lookup::default() + } + + pub fn details(&self, _name: &str) -> ModuleLookup { + ModuleLookup::Unsupported + } + } +} +use backend::Kmod; + +impl ModuleNames { + pub fn new() -> Self { + Self { kmod: Kmod::open(), cache: HashMap::new() } + } + + pub fn available(&self) -> bool { + self.kmod.is_some() + } + + fn lookup(&mut self, key: &str, exact_name: bool) -> Lookup { + let cache_key = format!("{}{key}", if exact_name { "module:" } else { "alias:" }); + if let Some(hit) = self.cache.get(&cache_key) { + return hit.clone(); + } + let Some(kmod) = &self.kmod else { return Lookup::default() }; + if self.cache.len() >= CACHE_LIMIT { + return Lookup::default(); + } + let result = kmod.describe(key, exact_name); + if self.cache.len() < CACHE_LIMIT { + self.cache.insert(cache_key, result.clone()); // Cache misses too. + } + result + } + + /// Supplements a device that still has only a kernel-derived name. A direct + /// `driver/module` link is evidence; a unique modalias match is only a candidate. + pub fn supplement(&mut self, d: &mut Device) { + if self.kmod.is_none() { + return; + } + if d.subsystem == "cpu" || (d.name_source != "kernel name" && d.name_source != "direct kernel driver and kernel name") { + return; + } + let link = std::fs::symlink_metadata(format!("{}/driver/module", d.path)); + let module = if link.is_ok() { d.driver_module.clone() } else { String::new() }; + let candidate = module.is_empty(); + let key = if candidate { d.properties.get("MODALIAS").cloned().unwrap_or_default() } else { module }; + if key.is_empty() { + return; // No module link does not prove a built-in driver. + } + let result = self.lookup(&key, !candidate); + d.attributes.insert("module_description".into(), result.description.clone()); + if result.description.state != ReadState::Available { + return; + } + d.name = result.description.value; + d.name_candidate = candidate; + d.name_source = format!( + "installed module metadata: {}; {}: {key}", + result.module, + if candidate { "unique modalias candidate" } else { "direct driver/module link" } + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_lookup_resolves_a_loaded_module_when_libkmod_is_present() { + let mut names = ModuleNames::new(); + if !names.available() { + return; // libkmod.so.2 absent: naming degrades silently, as designed. + } + let modules = std::fs::read_to_string("/proc/modules").unwrap_or_default(); + let Some(module) = modules.lines().next().and_then(|l| l.split(' ').next()) else { return }; + let found = names.lookup(module, true); + assert_eq!(found.module, module); + // Second call is served from the cache and is identical. + assert_eq!(names.lookup(module, true).module, module); + assert_eq!(names.cache.len(), 1); + } + + #[test] + fn missing_module_is_cached_and_not_an_available_description() { + let mut names = ModuleNames::new(); + if !names.available() { + return; + } + let miss = names.lookup("definitely_not_a_module_xyz", true); + assert_ne!(miss.description.state, ReadState::Available); + assert_eq!(names.cache.len(), 1); + } + + #[test] + fn supplement_ignores_devices_that_already_have_a_real_name() { + let mut names = ModuleNames::new(); + let mut d = Device { name: "Real".into(), name_source: "udev: ID_MODEL".into(), ..Default::default() }; + names.supplement(&mut d); + assert_eq!(d.name, "Real"); + assert!(!d.attributes.contains_key("module_description")); + } +} diff --git a/crates/qldm-core/src/monitor.rs b/crates/qldm-core/src/monitor.rs new file mode 100644 index 0000000..179e34d --- /dev/null +++ b/crates/qldm-core/src/monitor.rs @@ -0,0 +1,351 @@ +//! The live inventory session, ported from `Enumerator::run()` in `src/devices/enumerator.cpp`. +//! +//! One worker thread owns the udev monitor, the event history and every scan. At most one +//! snapshot is outstanding: the UI calls [`Session::acknowledge`] after applying it. Refresh +//! requests coalesce, event bursts are debounced, and a lost or overflowed monitor never lets +//! old identities pass as current. +use crate::enumerate::scan_cancellable; +use crate::events::{DeviceEvent, DeviceEventHistory}; +use crate::model::Inventory; +use crate::monitorstate::MonitorState; +use std::os::fd::AsRawFd; +use std::os::unix::fs::MetadataExt; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime}; +use udev::AsRaw; + +const RETRY_MS: i64 = 2000; +const PERIODIC_MS: i64 = 30000; +const QUIET_MS: i64 = 100; +const BURST_MS: i64 = 500; + +struct Shared { + request: AtomicU64, + acknowledged: AtomicBool, + interrupt: AtomicBool, +} + +/// Handle to the worker. Dropping it requests interruption and waits for the worker. +pub struct Session { + shared: Arc, + thread: Option>, +} + +impl Session { + /// Starts the worker. `on_inventory` runs on the worker thread for every published + /// snapshot; hand the value to the UI thread there and call [`Session::acknowledge`] + /// once it has been applied. Nothing further is published until then. + pub fn start(on_inventory: impl Fn(Inventory) + Send + 'static) -> Self { + let shared = Arc::new(Shared { request: AtomicU64::new(0), acknowledged: AtomicBool::new(true), interrupt: AtomicBool::new(false) }); + let worker = Arc::clone(&shared); + let thread = + std::thread::Builder::new().name("qldm-enumerator".into()).spawn(move || Worker::new(worker).run(&on_inventory)).expect("spawn enumerator thread"); + Self { shared, thread: Some(thread) } + } + + /// Requests a manual refresh; coalesces with other pending requests. + pub fn request_refresh(&self) -> u64 { + self.shared.request.fetch_add(1, Ordering::SeqCst) + 1 + } + + pub fn acknowledge(&self) { + self.shared.acknowledged.store(true, Ordering::SeqCst); + } + + /// Asks the worker to stop between records without waiting. A kernel metadata read already + /// in progress cannot be cancelled, so [`Session::join`] can still wait briefly. + pub fn request_stop(&self) { + self.shared.interrupt.store(true, Ordering::SeqCst); + } + + pub fn join(&mut self) { + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +impl Drop for Session { + fn drop(&mut self) { + self.request_stop(); + self.join(); + } +} + +fn clear_errno() { + // SAFETY: __errno_location returns this thread's valid errno slot. + unsafe { *libc::__errno_location() = 0 } +} + +struct Worker { + shared: Arc, + clock: Instant, + monitor: Option, + events: MonitorState, + history: DeviceEventHistory, + history_gap_pending: bool, + retry_at: i64, + first_event: i64, + last_event: i64, + pending: bool, + monitor_note: String, +} + +impl Worker { + fn new(shared: Arc) -> Self { + Self { + shared, + clock: Instant::now(), + monitor: None, + events: MonitorState::default(), + history: DeviceEventHistory::default(), + history_gap_pending: false, + retry_at: 0, + first_event: -1, + last_event: 0, + pending: true, + monitor_note: String::new(), + } + } + + fn now(&self) -> i64 { + self.clock.elapsed().as_millis() as i64 + } + + fn interrupted(&self) -> bool { + self.shared.interrupt.load(Ordering::Relaxed) + } + + fn failed(&mut self) { + self.monitor = None; + self.history.lose(); + self.history_gap_pending = true; + self.events.lose(); + self.pending = true; + self.retry_at = self.now() + RETRY_MS; + self.monitor_note = "Live monitoring unavailable; retrying (F5 remains available)".into(); + } + + fn open_monitor(&mut self) { + let Ok(socket) = udev::MonitorBuilder::new().and_then(|builder| builder.listen()) else { + return self.failed(); + }; + // Best effort, unprivileged. Failure to enlarge the socket is harmless; an overflow + // below triggers a fresh reconciliation and invalidates identities. + // SAFETY: the pointer comes from a live monitor owned by `socket`. + unsafe { + udev::ffi::udev_monitor_set_receive_buffer_size(socket.as_raw(), 1024 * 1024); + } + self.monitor = Some(socket); + self.monitor_note.clear(); + self.events.lose(); // A reopened monitor cannot vouch for instances during its gap. + self.pending = true; + } + + /// Bounded drain, even under a continuous stream. On overflow the monitor is reopened + /// before scanning so an abandoned queue cannot masquerade as current state. + fn drain(&mut self) { + for _ in 0..MonitorState::LIMIT { + let Some(monitor) = &self.monitor else { return }; + let mut poll = libc::pollfd { fd: monitor.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + clear_errno(); + // SAFETY: `poll` is a valid single-element array for the call's duration. + let ready = unsafe { libc::poll(&mut poll, 1, 0) }; + if ready < 0 { + if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { + return; + } + return self.failed(); + } + if poll.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 { + return self.failed(); + } + if ready == 0 || poll.revents & libc::POLLIN == 0 { + return; + } + clear_errno(); + let Some(event) = monitor.iter().next() else { + let error = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); + if matches!(error, libc::EAGAIN | libc::EINTR) { + return; + } + return self.failed(); + }; + let text = |value: Option<&std::ffi::OsStr>| value.map(|v| v.to_string_lossy().into_owned()).unwrap_or_default(); + let action = text(event.action()); + let path = event.syspath().to_string_lossy().into_owned(); + let initialized = text(event.property_value("USEC_INITIALIZED")); + // Do not read sysfs contents during event reception. An inode hint is usable only + // while this path is still present; removals use the already-observed instance, + // never a newly reused path's inode. + let incarnation = match std::fs::metadata(&path) { + Ok(info) if action != "remove" => format!("{}:{}:{initialized}", info.dev(), info.ino()), + _ => String::new(), + }; + let old_path = text(event.property_value("DEVPATH_OLD")).strip_prefix("/devices/").map(|rest| format!("/sys/devices/{rest}")).unwrap_or_default(); + let observation = DeviceEvent { + observed: Some(SystemTime::now()), + elapsed_ms: self.now(), + sequence: event.sequence_number(), + path: path.clone(), + action: action.clone(), + subsystem: text(event.subsystem()), + devtype: text(event.devtype()), + driver: text(event.driver()), + initialized, + incarnation, + old_path: old_path.clone(), + ..Default::default() + }; + drop(event); + self.history.observe(observation); + self.events.observe(&path, action == "remove"); + if path.starts_with("/sys/devices/") { + self.last_event = self.now(); + if self.first_event < 0 { + self.first_event = self.last_event; + } + } + if !old_path.is_empty() { + self.events.observe(&old_path, true); + } + } + self.failed(); + } + + fn collect(&self) -> Inventory { + let mut inventory = Inventory { request: self.shared.request.load(Ordering::SeqCst), ..Default::default() }; + match scan_cancellable(Some(&self.shared.interrupt)) { + Ok(result) => { + inventory.devices = result.devices; + inventory.skipped = result.skipped; + } + Err(message) => inventory.error = message, + } + inventory + } + + fn run(mut self, publish: &dyn Fn(Inventory)) { + let mut periodic_at = PERIODIC_MS; + let mut handled_request = 0; + let mut initial = true; + let mut awaiting_gui = false; + // Enable reception BEFORE the initial snapshot, and retain it across scans. + self.open_monitor(); + while !self.interrupted() { + self.drain(); + let now = self.now(); + if self.monitor.is_none() && now >= self.retry_at { + self.open_monitor(); + } + if self.events.dirty { + self.pending = true; + if self.first_event < 0 { + self.first_event = now; + } + // Quiet-time deadline is updated only when drain() receives events. + } + if awaiting_gui && self.shared.acknowledged.load(Ordering::SeqCst) { + awaiting_gui = false; + } + let request = self.shared.request.load(Ordering::SeqCst); + let manual = request != handled_request; + let due = initial + || manual + || now >= periodic_at + || (self.pending && (self.first_event < 0 || now - self.first_event >= BURST_MS || now - self.last_event >= QUIET_MS)); + if !awaiting_gui && due { + let mut inventory = Inventory::default(); + // At most two scans per publication. Continuous churn cannot keep restarting a + // scan forever; uncertain dependency branches are withheld. + for _ in 0..2 { + self.events.begin_scan(); + inventory = self.collect(); + self.drain(); + if !self.events.dirty || self.interrupted() || !inventory.error.is_empty() { + break; + } + if self.monitor.is_none() { + self.open_monitor(); + } + } + if self.interrupted() { + break; + } + self.events.exclude_unsettled(&mut inventory); + inventory.removed_paths = self.events.removed.clone(); + inventory.identity_lost = self.events.lost; + inventory.monitor_note = self.monitor_note.clone(); + // MonitorState can also detect bounded-hint loss independently of socket + // failure. It must end event associations as well as UI generations. + if inventory.identity_lost && !initial && !self.history_gap_pending { + self.history.lose(); + } + self.history.reconcile(&mut inventory.devices); + inventory.events = self.history.snapshot(self.monitor.is_some()); + if self.events.dirty && inventory.monitor_note.is_empty() { + inventory.monitor_note = "Changing device branches are being reconciled".into(); + } + handled_request = inventory.request; + self.pending = self.events.dirty; + self.events.removed.clear(); + self.events.lost = false; + self.history_gap_pending = false; + initial = false; + self.first_event = if self.pending { self.now() } else { -1 }; + self.last_event = self.now(); + periodic_at = self.now() + if self.monitor.is_some() && inventory.error.is_empty() { PERIODIC_MS } else { RETRY_MS }; + self.shared.acknowledged.store(false, Ordering::SeqCst); + awaiting_gui = true; + publish(inventory); + } + // Short interruptible polling also observes manual refresh and UI acknowledgement. + match &self.monitor { + Some(monitor) => { + let mut poll = libc::pollfd { fd: monitor.as_raw_fd(), events: libc::POLLIN, revents: 0 }; + // SAFETY: `poll` is a valid single-element array for the call's duration. + let ready = unsafe { libc::poll(&mut poll, 1, 50) }; + if ready > 0 && poll.revents & libc::POLLIN != 0 { + self.last_event = self.now(); + } + if ready < 0 && std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + self.failed(); + } + } + None => std::thread::sleep(Duration::from_millis(50)), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + #[test] + fn session_publishes_one_snapshot_at_a_time_and_stops_promptly() { + let (tx, rx) = mpsc::channel(); + let mut session = Session::start(move |inventory| { + let _ = tx.send(inventory); + }); + let first = rx.recv_timeout(Duration::from_secs(30)).expect("initial inventory"); + assert!(first.error.is_empty() && !first.devices.is_empty()); + assert_eq!(first.request, 0); + assert!(first.devices.iter().filter(|d| d.subsystem == "pci").all(|d| d.event_instance != 0)); + assert!(rx.recv_timeout(Duration::from_millis(400)).is_err(), "no new snapshot before acknowledgement"); + + let request = session.request_refresh(); + session.acknowledge(); + let second = rx.recv_timeout(Duration::from_secs(30)).expect("manual refresh"); + assert!(second.request >= request); + assert!(first.events.started == second.events.started, "history persists across scans"); + + let stopped = Instant::now(); + session.request_stop(); + session.join(); + assert!(stopped.elapsed() < Duration::from_secs(10)); + } +} diff --git a/crates/qldm-core/src/monitor_edid.rs b/crates/qldm-core/src/monitor_edid.rs new file mode 100644 index 0000000..5327139 --- /dev/null +++ b/crates/qldm-core/src/monitor_edid.rs @@ -0,0 +1,505 @@ +//! Cached DRM connector reports (EDID, mode names), ported from +//! `src/devices/monitorproperties.cpp`. Hardware-reported data only, separate from the +//! desktop-session settings in [`crate::display`]; overrides, adapters or KVMs may supply the +//! EDID. The kernel descriptor is framed and checksummed before anything parses it, and two +//! reads that disagree reject the whole result. +use crate::dirfd::{available, failure, read_limited, Dir}; +use crate::model::{Attribute, ReadState}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[derive(Clone, Debug, Default)] +pub struct MonitorProperties { + pub applicable: bool, + pub descriptor: Attribute, + pub values: HashMap, + pub sources: HashMap, +} + +struct Report { + state: Attribute, + bytes: Vec, +} + +fn read_report(dir: &Dir, name: &str, limit: usize, cancel: Option<&AtomicBool>) -> Report { + let mut file = match dir.open_file(name) { + Ok(f) => f, + Err(e) => return Report { state: failure(crate::dirfd::io_errno(&e)), bytes: Vec::new() }, + }; + match read_limited(&mut file, limit, &|| cancel.is_some_and(|c| c.load(Ordering::Relaxed))) { + Err(error) => Report { state: failure(error), bytes: Vec::new() }, + Ok(bytes) if bytes.len() > limit => Report { state: failure(libc::EOVERFLOW), bytes: Vec::new() }, + Ok(bytes) => Report { state: Attribute { state: ReadState::Available, ..Default::default() }, bytes }, + } +} + +fn same_report(a: &Report, b: &Report) -> bool { + a.state.state == b.state.state && a.state.error == b.state.error && a.bytes == b.bytes +} + +/// Optional libdisplay-info, opened at run time (no headers or link-time dependency). +#[cfg(feature = "display-info")] +mod info { + use std::ffi::{c_char, c_void, CStr}; + + type ParseFn = unsafe extern "C" fn(*const c_void, usize) -> *mut c_void; + type DestroyFn = unsafe extern "C" fn(*mut c_void); + type PointerFn = unsafe extern "C" fn(*mut c_void) -> *const c_void; + type StringFn = unsafe extern "C" fn(*mut c_void) -> *const c_char; + type OwnedStringFn = unsafe extern "C" fn(*mut c_void) -> *mut c_char; + + pub struct DisplayInfo { + _library: libloading::Library, + parse: ParseFn, + destroy: DestroyFn, + get_edid: PointerFn, + failure_message: StringFn, + make: OwnedStringFn, + model: OwnedStringFn, + serial: OwnedStringFn, + } + + pub struct Decoded { + pub has_edid: bool, + pub warnings: Option, + pub make: Option>, + pub model: Option>, + pub serial: Option>, + } + + impl DisplayInfo { + pub fn open() -> Option { + // SAFETY: only the documented libdisplay-info entry points are resolved, each with + // its exact C signature (di_info_* in libdisplay-info/info.h). + unsafe { + let library = ["libdisplay-info.so.3", "libdisplay-info.so.2", "libdisplay-info.so.1", "libdisplay-info.so.0"] + .into_iter() + .find_map(|name| libloading::Library::new(name).ok())?; + macro_rules! sym { + ($name:literal, $ty:ty) => { + *library.get::<$ty>(concat!($name, "\0").as_bytes()).ok()? + }; + } + Some(Self { + parse: sym!("di_info_parse_edid", ParseFn), + destroy: sym!("di_info_destroy", DestroyFn), + get_edid: sym!("di_info_get_edid", PointerFn), + failure_message: sym!("di_info_get_failure_msg", StringFn), + make: sym!("di_info_get_make", OwnedStringFn), + model: sym!("di_info_get_model", OwnedStringFn), + serial: sym!("di_info_get_serial", OwnedStringFn), + _library: library, + }) + } + } + + /// Parses `edid`; `Err(errno)` if the library rejects it. + pub fn decode(&self, edid: &[u8]) -> Result { + // SAFETY: `edid` is a valid readable slice; every returned object is released on + // all paths, and owned strings are freed with `free` as the library documents. + unsafe { + *libc::__errno_location() = 0; + let info = (self.parse)(edid.as_ptr().cast(), edid.len()); + if info.is_null() { + let error = *libc::__errno_location(); + return Err(if error == libc::ENOMEM { libc::ENOMEM } else { libc::EPROTO }); + } + let owned = |p: *mut c_char| -> Option> { + if p.is_null() { + return None; + } + let bytes = CStr::from_ptr(p).to_bytes().to_vec(); + libc::free(p.cast()); + Some(bytes) + }; + let warnings = (self.failure_message)(info); + let decoded = Decoded { + has_edid: !(self.get_edid)(info).is_null(), + warnings: (!warnings.is_null()).then(|| CStr::from_ptr(warnings).to_string_lossy().into_owned()), + make: owned((self.make)(info)), + model: owned((self.model)(info)), + serial: owned((self.serial)(info)), + }; + (self.destroy)(info); + Ok(decoded) + } + } + } +} + +/// A bounded text value from library-owned bytes: empty is unreported, too long an error. +#[cfg(feature = "display-info")] +fn owned_string(bytes: Option>) -> Attribute { + match bytes { + None => Attribute::default(), + Some(b) if b.len() > 4096 => failure(libc::EOVERFLOW), + Some(b) if b.is_empty() => Attribute::default(), + Some(b) => available(String::from_utf8_lossy(&b).into_owned()), + } +} + +/// Base-block fields decoded straight from the EDID 1.x layout, independent of any library. +pub struct BaseBlock { + pub manufacturer_code: String, + pub product: u16, + pub width_cm: u8, + pub height_cm: u8, + pub digital: bool, + /// Declared bits per colour component (digital, EDID 1.4 only). + pub depth: Option, + pub interface: Option<&'static str>, +} + +pub fn decode_base_block(bytes: &[u8]) -> BaseBlock { + let word = u16::from_be_bytes([bytes[8], bytes[9]]); + let letter = |shift: u32| char::from(b'A' + (((word >> shift) & 0x1f) as u8).saturating_sub(1)); + let input = bytes[20]; + let digital = input & 0x80 != 0; + let (mut depth, mut interface) = (None, None); + if digital && bytes[19] >= 4 { + depth = match (input >> 4) & 7 { + 1 => Some(6), + 2 => Some(8), + 3 => Some(10), + 4 => Some(12), + 5 => Some(14), + 6 => Some(16), + _ => None, + }; + interface = match input & 0x0f { + 1 => Some("DVI"), + 2 => Some("HDMI-A"), + 3 => Some("HDMI-B"), + 4 => Some("MDDI"), + 5 => Some("DisplayPort"), + _ => None, + }; + } + BaseBlock { + manufacturer_code: [letter(10), letter(5), letter(0)].iter().collect(), + product: u16::from_le_bytes([bytes[10], bytes[11]]), + width_cm: bytes[21], + height_cm: bytes[22], + digital, + depth, + interface, + } +} + +fn decode_descriptor(result: &mut MonitorProperties, bytes: &[u8], path: &str) { + if bytes.is_empty() { + result.descriptor = Attribute::default(); + return; + } + // Reject truncated/corrupt framing before passing a bounded report to the parser. + const HEADER: [u8; 8] = [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]; + if bytes.len() < 128 || bytes[..8] != HEADER || bytes.len() != 128 * (1 + bytes[126] as usize) { + result.descriptor = failure(libc::EPROTO); + return; + } + if bytes.chunks(128).any(|block| block.iter().map(|b| *b as u32).sum::() & 255 != 0) { + result.descriptor = failure(libc::EPROTO); + return; + } + let version = format!("{}.{}", bytes[18], bytes[19]); + result.values.insert("version".into(), available(version.clone())); + result.sources.insert("version".into(), format!("{path}/edid: bytes 18–19")); + if bytes[18] != 1 || bytes[19] > 4 { + result.descriptor = Attribute { state: ReadState::Unsupported, value: version, error: 0 }; + return; + } + #[cfg(not(feature = "display-info"))] + { + result.descriptor = Attribute { state: ReadState::Unsupported, ..Default::default() }; + } + #[cfg(feature = "display-info")] + { + let Some(library) = info::DisplayInfo::open() else { + result.descriptor = Attribute { state: ReadState::Unsupported, ..Default::default() }; + return; + }; + let decoded = match library.decode(bytes) { + Ok(d) => d, + Err(error) => { + result.descriptor = failure(error); + return; + } + }; + if !decoded.has_edid { + result.descriptor = Attribute { state: ReadState::Unsupported, value: version, error: 0 }; + return; + } + result.descriptor = Attribute { state: ReadState::Available, ..Default::default() }; + let mut put = |key: &str, value: Attribute, source: &str| { + result.values.insert(key.into(), value); + result.sources.insert(key.into(), format!("{path}/edid: {source} (libdisplay-info)")); + }; + if let Some(warnings) = &decoded.warnings { + let value = if warnings.len() > 16384 { failure(libc::EOVERFLOW) } else { available(warnings.trim()) }; + put("warnings", value, "parser conformance diagnostics; decoded values remain reported information"); + } + put("manufacturer", owned_string(decoded.make), "reported manufacturer, resolved by the library's PNP database"); + put("model", owned_string(decoded.model), "product name, or product code if no name was supplied"); + put("serial", owned_string(decoded.serial), "serial text, or numeric serial if available"); + let base = decode_base_block(bytes); + put("manufacturer_code", available(base.manufacturer_code), "PNP manufacturer code"); + put("product", available(format!("0x{:04x}", base.product)), "reported product code"); + put( + "size", + if base.width_cm > 0 && base.height_cm > 0 { available(format!("{} {}", base.width_cm, base.height_cm)) } else { Attribute::default() }, + "reported image dimensions in cm; unspecified/aspect-ratio encodings are not physical dimensions", + ); + put("digital", available(if base.digital { "1" } else { "0" }), "base-block video input type"); + if base.digital { + put( + "depth", + base.depth.map_or_else(Attribute::default, |d| available(d.to_string())), + "declared bits per color component, not active link or framebuffer depth", + ); + put("interface", base.interface.map_or_else(Attribute::default, available), "reported digital input interface"); + } + } +} + +/// Kernel mode names, duplicates combined, as one value per line. +fn mode_names(report: &Report, cancel: Option<&AtomicBool>) -> Attribute { + if report.state.state != ReadState::Available { + return report.state.clone(); + } + if report.bytes.is_empty() { + return Attribute::default(); + } + let lines: Vec<&[u8]> = report.bytes.split(|b| *b == b'\n').collect(); + if lines.len() > 2049 { + return failure(libc::EOVERFLOW); + } + let mut names: Vec = Vec::new(); + let mut seen: HashSet<&[u8]> = HashSet::new(); + let mut count = 0; + for line in lines { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return failure(libc::ECANCELED); + } + if line.is_empty() { + continue; + } + count += 1; + if count > 2048 || line.len() > 128 { + return failure(libc::EOVERFLOW); + } + if line.iter().any(|c| !(32..=126).contains(c)) { + return failure(libc::EILSEQ); + } + if seen.insert(line) { + names.push(String::from_utf8_lossy(line).into_owned()); + } + } + if names.is_empty() { + Attribute::default() + } else { + available(names.join("\n")) + } +} + +/// Explicit Properties snapshots only. `directory` is the selected connector directory. +pub fn collect_monitor_properties(directory: &Dir, path: &str, cancel: Option<&AtomicBool>) -> MonitorProperties { + let mut result = MonitorProperties::default(); + let status = read_report(directory, "status", 64, cancel); + let trimmed = |r: &Report| String::from_utf8_lossy(&r.bytes).trim().to_string(); + if status.state.state == ReadState::Available && trimmed(&status) == "disconnected" { + return result; + } + result.applicable = true; + result.sources.insert( + "descriptor".into(), + format!( + "{path}/edid: cached kernel descriptor; header, length and every block checksum checked. EDID 1.0–1.4 identity/input fields are decoded with optional libdisplay-info. Overrides, adapters or KVMs may supply this report." + ), + ); + result.sources.insert( + "modes".into(), + format!( + "{path}/modes: cached kernel mode names, with duplicate names combined. These may include driver additions or restrictions; names do not distinguish refresh rates. Not a guaranteed capability list or active configuration." + ), + ); + if status.state.state != ReadState::Available { + result.descriptor = status.state.clone(); + result.sources.insert("descriptor".into(), format!("{path}/status: connection status could not be read")); + result.values.insert("modes".into(), status.state); + return result; + } + if !matches!(trimmed(&status).as_str(), "connected" | "unknown") { + result.descriptor = failure(libc::EINVAL); + result.sources.insert("descriptor".into(), format!("{path}/status: malformed connection status")); + result.values.insert("modes".into(), result.descriptor.clone()); + return result; + } + let edid = read_report(directory, "edid", 32768, cancel); + let modes = read_report(directory, "modes", 65536, cancel); + // A monitor may be replaced without the connector directory changing identity. + let after_edid = read_report(directory, "edid", 32768, cancel); + let after_status = read_report(directory, "status", 64, cancel); + if after_status.state.state != ReadState::Available || (after_edid.state.state != ReadState::Available && !same_report(&edid, &after_edid)) { + result.descriptor = if after_status.state.state != ReadState::Available { after_status.state } else { after_edid.state }; + result.values.insert("modes".into(), result.descriptor.clone()); + return result; + } + if !same_report(&status, &after_status) || !same_report(&edid, &after_edid) { + result.descriptor = failure(libc::EAGAIN); + result.values.insert("modes".into(), result.descriptor.clone()); + return result; + } + if edid.state.state == ReadState::Available { + decode_descriptor(&mut result, &edid.bytes, path); + } else { + result.descriptor = edid.state.clone(); + } + result.values.insert("modes".into(), mode_names(&modes, cancel)); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// A valid 128-byte EDID 1.4 base block: Dell-coded, DisplayPort, 8 bpc, 52x29 cm, with a + /// product-name and a serial-text descriptor. + pub fn sample_edid() -> Vec { + let mut b = vec![0u8; 128]; + b[..8].copy_from_slice(&[0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]); + b[8..10].copy_from_slice(&[0x10, 0xac]); // "DEL" + b[10..12].copy_from_slice(&[0xb1, 0xa0]); // product 0xa0b1 + b[12..16].copy_from_slice(&[0x01, 0x02, 0x03, 0x04]); + b[16] = 10; + b[17] = 30; + b[18] = 1; + b[19] = 4; + b[20] = 0xa5; // digital, 8 bits, DisplayPort + b[21] = 52; + b[22] = 29; + for slot in 0..4 { + let at = 54 + slot * 18; + let (tag, text): (u8, &[u8]) = match slot { + 0 => (0xfc, b"Test Monitor\n"), + 1 => (0xff, b"SER12345\n"), + _ => (0x10, b""), + }; + b[at..at + 4].copy_from_slice(&[0, 0, 0, tag]); + if slot < 2 { + b[at + 5..at + 5 + text.len()].copy_from_slice(text); + for fill in &mut b[at + 5 + text.len()..at + 18] { + *fill = b' '; + } + } + } + let sum: u32 = b[..127].iter().map(|x| *x as u32).sum(); + b[127] = ((256 - (sum & 255)) & 255) as u8; + b + } + + fn connector(status: &str, edid: Option<&[u8]>, modes: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("status"), status).unwrap(); + if let Some(edid) = edid { + fs::write(tmp.path().join("edid"), edid).unwrap(); + } + fs::write(tmp.path().join("modes"), modes).unwrap(); + tmp + } + + fn collect(tmp: &tempfile::TempDir) -> MonitorProperties { + collect_monitor_properties(&Dir::open(tmp.path()).unwrap(), "/sys/devices/x/card0-DP-1", None) + } + + #[test] + fn base_block_fields_are_decoded_without_a_library() { + let base = decode_base_block(&sample_edid()); + assert_eq!(base.manufacturer_code, "DEL"); + assert_eq!((base.product, base.width_cm, base.height_cm), (0xa0b1, 52, 29)); + assert!(base.digital); + assert_eq!((base.depth, base.interface), (Some(8), Some("DisplayPort"))); + let mut old = sample_edid(); + old[19] = 3; + let base = decode_base_block(&old); + assert_eq!((base.depth, base.interface), (None, None), "depth and interface exist from EDID 1.4"); + } + + #[test] + fn disconnected_connectors_are_not_applicable() { + let tmp = connector("disconnected\n", None, ""); + assert!(!collect(&tmp).applicable); + } + + #[test] + fn edid_is_framed_checksummed_and_decoded() { + let good = connector("connected\n", Some(&sample_edid()), "1920x1080\n1920x1080\n1280x720\n"); + let found = collect(&good); + assert!(found.applicable); + assert_eq!(found.values["version"].value, "1.4"); + assert_eq!(found.values["modes"].value, "1920x1080\n1280x720", "duplicate names combined"); + match found.descriptor.state { + ReadState::Available => { + assert_eq!(found.values["manufacturer_code"].value, "DEL"); + assert_eq!(found.values["product"].value, "0xa0b1"); + assert_eq!(found.values["size"].value, "52 29"); + assert_eq!((found.values["digital"].value.as_str(), found.values["depth"].value.as_str()), ("1", "8")); + assert_eq!(found.values["interface"].value, "DisplayPort"); + assert_eq!(found.values["model"].value, "Test Monitor"); + assert_eq!(found.values["serial"].value, "SER12345"); + assert!(found.values["manufacturer"].value.contains("Dell"), "{:?}", found.values["manufacturer"]); + } + other => assert_eq!(other, ReadState::Unsupported, "libdisplay-info absent: decoding is unsupported, not wrong"), + } + + let mut bad_sum = sample_edid(); + bad_sum[127] ^= 1; + assert_eq!(collect(&connector("connected\n", Some(&bad_sum), "")).descriptor.error, libc::EPROTO); + let mut bad_header = sample_edid(); + bad_header[0] = 1; + assert_eq!(collect(&connector("connected\n", Some(&bad_header), "")).descriptor.error, libc::EPROTO); + let mut claims_extension = sample_edid(); + claims_extension[126] = 1; + assert_eq!(collect(&connector("connected\n", Some(&claims_extension), "")).descriptor.error, libc::EPROTO, "length must match the block count"); + let mut v2 = sample_edid(); + v2[18] = 2; + let sum: u32 = v2[..127].iter().map(|x| *x as u32).sum(); + v2[127] = ((256 - (sum & 255)) & 255) as u8; + let unsupported = collect(&connector("connected\n", Some(&v2), "")); + assert_eq!((unsupported.descriptor.state, unsupported.descriptor.value.as_str()), (ReadState::Unsupported, "2.4")); + assert_eq!(collect(&connector("connected\n", Some(&[]), "")).descriptor.state, ReadState::Unavailable, "empty report"); + } + + #[test] + fn status_and_mode_validation() { + assert_eq!(collect(&connector("weird\n", None, "")).descriptor.error, libc::EINVAL); + let unknown = collect(&connector("unknown\n", Some(&sample_edid()), "640x480\n")); + assert!(unknown.applicable && unknown.values["modes"].value == "640x480"); + assert_eq!(collect(&connector("connected\n", None, "bad\u{7}mode\n")).values["modes"].error, libc::EILSEQ); + let many = (0..2100).map(|i| format!("{i}x1\n")).collect::(); + assert_eq!(collect(&connector("connected\n", None, &many)).values["modes"].error, libc::EOVERFLOW); + assert_eq!(collect(&connector("connected\n", None, &"x".repeat(129))).values["modes"].error, libc::EOVERFLOW); + let no_edid = collect(&connector("connected\n", None, "1x1\n")); + assert_eq!(no_edid.descriptor.state, ReadState::Unavailable, "a connector without an edid file"); + let cancelled = AtomicBool::new(true); + let tmp = connector("connected\n", None, "1x1\n"); + let stopped = collect_monitor_properties(&Dir::open(tmp.path()).unwrap(), "p", Some(&cancelled)); + assert_eq!(stopped.descriptor.error, libc::ECANCELED); + } + + #[test] + fn real_connectors_collect_without_error() { + let Ok(entries) = fs::read_dir("/sys/class/drm") else { return }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.contains('-') || !name.starts_with("card") { + continue; + } + let path = fs::canonicalize(entry.path()).unwrap(); + let found = collect_monitor_properties(&Dir::open(&path).unwrap(), &path.to_string_lossy(), None); + if found.applicable && found.descriptor.state == ReadState::Available { + assert!(found.values["manufacturer_code"].value.len() == 3, "{name}"); + } + } + } +} diff --git a/crates/qldm-core/src/monitorstate.rs b/crates/qldm-core/src/monitorstate.rs new file mode 100644 index 0000000..8289067 --- /dev/null +++ b/crates/qldm-core/src/monitorstate.rs @@ -0,0 +1,152 @@ +//! Coalesced udev hints that decide which branches of a scan are trustworthy, ported from +//! `src/devices/monitorstate.{h,cpp}`. Hints are bounded; overflow degrades to full +//! reconciliation rather than guessing. +use crate::model::Inventory; +use std::collections::HashSet; + +fn related(a: &str, b: &str) -> bool { + a == b || a.starts_with(&format!("{b}/")) || b.starts_with(&format!("{a}/")) +} + +#[derive(Debug, Default)] +pub struct MonitorState { + pub changed: HashSet, + pub removed: HashSet, + pub lost: bool, + pub dirty: bool, +} + +impl MonitorState { + pub const LIMIT: usize = 4096; + + pub fn observe(&mut self, path: &str, removal: bool) { + if !path.starts_with("/sys/devices/") { + return; + } + self.dirty = true; + if self.changed.len() >= Self::LIMIT || self.removed.len() >= Self::LIMIT { + self.lose(); + return; + } + self.changed.insert(path.to_string()); + if removal { + self.removed.insert(path.to_string()); + } + } + + pub fn lose(&mut self) { + self.dirty = true; + self.lost = true; + self.changed.clear(); + self.removed.clear(); + } + + pub fn begin_scan(&mut self) { + self.changed.clear(); + self.dirty = false; + // Removal/identity-loss evidence survives all scans until the UI publishes. + } + + /// Withholds records whose branch changed during collection. Real ancestry and + /// presentation dependencies count: an ancestor's metadata can supply a child's name, + /// grouping or driver relationship, and vice versa. + pub fn exclude_unsettled(&self, inventory: &mut Inventory) { + if !self.dirty { + return; + } + if self.lost { + inventory.skipped += inventory.devices.len(); + inventory.devices.clear(); + return; + } + let mut excluded = self.changed.clone(); + loop { + let mut extended = false; + for d in &inventory.devices { + if excluded.contains(&d.path) { + if !d.represented_by_path.is_empty() && excluded.insert(d.represented_by_path.clone()) { + extended = true; + } + continue; + } + let affected = + (!d.represented_by_path.is_empty() && excluded.contains(&d.represented_by_path)) || self.changed.iter().any(|path| related(&d.path, path)); + if affected { + excluded.insert(d.path.clone()); + extended = true; + } + } + if !extended { + break; + } + } + let before = inventory.devices.len(); + inventory.devices.retain(|d| !excluded.contains(&d.path)); + inventory.skipped += before - inventory.devices.len(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Device; + + fn at(path: &str) -> Device { + Device { path: path.into(), ..Default::default() } + } + + #[test] + fn coalescing_scans_and_exclusion_follow_the_cpp_rules() { + let mut events = MonitorState::default(); + events.observe("/sys/devices/usb/device", true); + events.observe("/sys/devices/usb/device", false); + assert_eq!((events.changed.len(), events.removed.len()), (1, 1), "remove/add keeps replacement evidence"); + events.begin_scan(); + assert!(!events.dirty && events.changed.is_empty() && events.removed.len() == 1, "removals survive until publication"); + + events.observe("/sys/devices/usb/device", false); + let ancestor = at("/sys/devices/usb"); + let target = at("/sys/devices/usb/device"); + let child = Device { parent_path: target.path.clone(), ..at("/sys/devices/usb/device/interface") }; + let peer = Device { parent_path: ancestor.path.clone(), ..at("/sys/devices/usb/other") }; + let independent = at("/sys/devices/pci/card"); + let mut inventory = Inventory { devices: vec![ancestor, target, child, peer.clone(), independent.clone()], ..Default::default() }; + events.exclude_unsettled(&mut inventory); + let kept: Vec<_> = inventory.devices.iter().map(|d| d.path.as_str()).collect(); + assert_eq!(kept, [peer.path.as_str(), independent.path.as_str()], "ancestors and descendants withheld, siblings kept"); + assert_eq!(inventory.skipped, 3); + + events.lose(); + inventory.devices = vec![independent.clone()]; + events.exclude_unsettled(&mut inventory); + assert!(inventory.devices.is_empty() && events.lost, "loss during collection withholds the snapshot"); + + events.begin_scan(); + inventory.devices = vec![independent]; + events.exclude_unsettled(&mut inventory); + assert!(inventory.devices.len() == 1 && events.lost, "recovery scan publishes while forcing new generations"); + } + + #[test] + fn hints_are_bounded_and_canonical() { + let mut events = MonitorState::default(); + for i in 0..=MonitorState::LIMIT { + events.observe(&format!("/sys/devices/fixture/{i}"), true); + } + assert!(events.lost && events.changed.len() <= MonitorState::LIMIT && events.removed.len() <= MonitorState::LIMIT); + let mut other = MonitorState::default(); + other.observe("/sys/class/usb", true); + assert!(!other.dirty); + } + + #[test] + fn presentation_dependencies_extend_the_exclusion() { + let mut events = MonitorState::default(); + events.observe("/sys/devices/pci/wifi", false); + let wifi = at("/sys/devices/pci/wifi"); + let net = Device { represented_by_path: wifi.path.clone(), ..at("/sys/devices/pci/net/wlan0") }; + let mut inventory = Inventory { devices: vec![wifi, net], ..Default::default() }; + events.exclude_unsettled(&mut inventory); + assert!(inventory.devices.is_empty(), "a represented row is withheld with its representative"); + } +} diff --git a/crates/qldm-core/src/naming.rs b/crates/qldm-core/src/naming.rs new file mode 100644 index 0000000..ea3038b --- /dev/null +++ b/crates/qldm-core/src/naming.rs @@ -0,0 +1,249 @@ +//! Device naming priority chain, ported from `nameDevice()` in `src/devices/device.cpp`. +//! Direct identity/model metadata wins over static captions, which win over kernel names. +use crate::functionnames::{known_function_name, usb_root_hub}; +use crate::model::{Device, ReadState}; + +/// Uses the device's own sysfs attribute as its name, only when the read succeeded. +fn attribute_name(d: &mut Device, key: &str) -> bool { + match d.attributes.get(key) { + Some(a) if a.state == ReadState::Available && !a.value.is_empty() => { + d.name = a.value.clone(); + d.name_source = format!("sysfs: {}/{key}", d.path); + true + } + _ => false, + } +} + +fn available(d: &Device, key: &str) -> Option { + d.attributes.get(key).filter(|a| a.state == ReadState::Available && !a.value.is_empty()).map(|a| a.value.clone()) +} + +pub fn name_device(d: &mut Device) { + d.name_translated = false; + d.name_candidate = false; + let subsystem = d.subsystem.clone(); + + if subsystem == "cpu" { + if let Some(model) = available(d, "cpu_model") { + d.name = model; + d.name_source = format!("/proc/cpuinfo: processor {}", d.sysname.get(3..).unwrap_or("")); + return; + } + } + // Function names take priority over generic USB receiver/camera model names. + if (subsystem == "input" || subsystem == "video4linux") && attribute_name(d, "name") { + return; + } + if subsystem == "input" { + let raw = d.properties.get("NAME").cloned().unwrap_or_default(); + let name = if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') { raw[1..raw.len() - 1].to_string() } else { raw }; + if !name.is_empty() { + d.name = name; + d.name_source = "udev: NAME".into(); + return; + } + } + if subsystem == "video4linux" { + if let Some(name) = d.properties.get("ID_V4L_PRODUCT").filter(|n| !n.is_empty()) { + d.name = name.clone(); + d.name_source = "udev: ID_V4L_PRODUCT".into(); + return; + } + } + if subsystem == "nvme" && attribute_name(d, "model") { + return; + } + if subsystem == "hdaudio" { + if let Some(chip) = available(d, "chip_name") { + d.name = match available(d, "vendor_name") { + Some(vendor) => format!("{vendor} {chip}"), + None => chip, + }; + d.name_source = format!("sysfs: {}/chip_name and available vendor_name", d.path); + return; + } + } + // Replace generic database root-hub text only for verified Linux root hubs. + if subsystem == "usb" { + if let Some(hub) = usb_root_hub(d) { + d.name = hub.label.into(); + d.name_source = hub.source; + d.name_translated = true; + return; + } + } + for key in ["ID_MODEL_FROM_DATABASE", "ID_MODEL", "NAME"] { + if key == "ID_MODEL" && subsystem == "usb" && d.devtype == "usb_device" && attribute_name(d, "product") { + return; + } + if let Some(value) = d.properties.get(key).filter(|v| !v.is_empty()) { + d.name = value.clone(); + d.name_source = d.property_sources.get(key).cloned().unwrap_or_else(|| format!("udev: {key}")); + return; + } + } + if subsystem == "sound" && attribute_name(d, "id") { + return; + } + if subsystem == "power_supply" && attribute_name(d, "model_name") { + return; + } + if let Some(function) = known_function_name(d) { + d.name = function.label.into(); + d.name_source = function.source; + d.name_translated = true; + return; + } + d.name = if d.sysname.is_empty() { d.path.clone() } else { d.sysname.clone() }; + d.name_source = "kernel name".into(); + // Describe unresolved bound functions using real binding evidence, without + // claiming that a module/driver name is the hardware's marketing model. + if !d.driver.is_empty() && subsystem != "cpu" { + d.name = format!("{} — {}", d.driver, d.name); + d.name_source = "direct kernel driver and kernel name".into(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Attribute; + + fn attr(state: ReadState, value: &str) -> Attribute { + Attribute { state, value: value.into(), error: 0 } + } + fn dev(subsystem: &str) -> Device { + Device { subsystem: subsystem.into(), ..Default::default() } + } + + #[test] + fn unknown_bound_function_gets_readable_fallback() { + let mut d = Device { driver: "example".into(), sysname: "device0".into(), ..dev("unknown_bus") }; + name_device(&mut d); + assert_eq!(d.name, "example — device0"); + assert!(!d.name_translated); + } + + #[test] + fn cpu_name_identifies_source_and_failed_reads_do_not_resolve() { + let mut cpu = Device { sysname: "cpu0".into(), ..dev("cpu") }; + cpu.attributes.insert("cpu_model".into(), attr(ReadState::Available, "Intel(R) Core(TM) Ultra X7 358H")); + name_device(&mut cpu); + assert_eq!(cpu.name, "Intel(R) Core(TM) Ultra X7 358H"); + assert!(cpu.name_source.contains("processor 0")); + cpu.attributes.insert("cpu_model".into(), attr(ReadState::PermissionDenied, "Intel")); + name_device(&mut cpu); + assert_eq!(cpu.name, "cpu0"); + } + + #[test] + fn input_function_name_beats_generic_model() { + let mut d = Device { sysname: "input14".into(), ..dev("input") }; + d.properties.insert("NAME".into(), "\"SYNA3517:00 06CB:CFC6 Touchpad\"".into()); + d.properties.insert("ID_MODEL".into(), "Generic receiver".into()); + name_device(&mut d); + assert_eq!(d.name, "SYNA3517:00 06CB:CFC6 Touchpad"); + } + + #[test] + fn acpi_identities_use_exact_ids_only() { + let mut d = Device { sysname: "PNP0C09:00".into(), ..dev("platform") }; + d.properties.insert("MODALIAS".into(), "acpi:PNP0C09:".into()); + name_device(&mut d); + assert_eq!((d.name.as_str(), d.name_translated), ("ACPI embedded controller", true)); + d.properties.insert("MODALIAS".into(), "acpi:INT340E:PNP0C02:".into()); + name_device(&mut d); + assert_eq!(d.name, "Motherboard resources"); + assert!(d.name_source.ends_with("PNP0C02")); + d.properties.insert("MODALIAS".into(), "acpi:UNKNOWNPNP0C02:".into()); + name_device(&mut d); + assert_eq!((d.name.as_str(), d.name_translated), ("PNP0C09:00", false)); + d.properties.insert("MODALIAS".into(), "acpi:PNP0C09:".into()); + d.properties.insert("ID_MODEL_FROM_DATABASE".into(), "Actual model".into()); + name_device(&mut d); + assert_eq!((d.name.as_str(), d.name_translated), ("Actual model", false)); + } + + #[test] + fn pnp_modalias_parsing() { + let mut d = dev("pnp"); + d.properties.insert("MODALIAS".into(), "pnp:dPNP0303".into()); + name_device(&mut d); + assert_eq!(d.name, "PS/2 keyboard interface"); + } + + #[test] + fn codec_ignores_failed_vendor_read() { + let mut d = dev("hdaudio"); + d.attributes.insert("chip_name".into(), attr(ReadState::Available, "ALC-test")); + d.attributes.insert("vendor_name".into(), attr(ReadState::PermissionDenied, "Stale vendor")); + name_device(&mut d); + assert_eq!(d.name, "ALC-test"); + d.attributes.insert("vendor_name".into(), attr(ReadState::Available, "Realtek")); + name_device(&mut d); + assert_eq!(d.name, "Realtek ALC-test"); + } + + #[test] + fn usb_product_beats_generic_model_and_root_hubs_are_verified() { + let mut d = Device { devtype: "usb_device".into(), ..dev("usb") }; + d.properties.insert("ID_MODEL".into(), "Generic_Model".into()); + d.attributes.insert("product".into(), attr(ReadState::Available, "Readable USB product")); + name_device(&mut d); + assert_eq!(d.name, "Readable USB product"); + + let mut hub = Device { devtype: "usb_device".into(), sysname: "usb2".into(), ..dev("usb") }; + hub.properties.insert("DEVNUM".into(), "001".into()); + hub.properties.insert("PRODUCT".into(), "1d6b/3/702".into()); + hub.properties.insert("ID_MODEL_FROM_DATABASE".into(), "3.0 root hub".into()); + name_device(&mut hub); + assert_eq!((hub.name.as_str(), hub.name_translated), ("USB 3.x root hub", true)); + hub.sysname = "2-1".into(); + name_device(&mut hub); + assert_eq!((hub.name.as_str(), hub.name_translated), ("3.0 root hub", false)); + } + + #[test] + fn services_faux_and_platform_aliases_need_exact_matches() { + let mut service = Device { driver: "pciehp".into(), ..dev("pci_express") }; + name_device(&mut service); + assert_eq!(service.name, "PCI Express hot-plug service"); + service.subsystem = "unknown_bus".into(); + name_device(&mut service); + assert!(!service.name_translated, "driver names outside their subsystem imply nothing"); + + let mut faux = Device { driver: "faux_driver".into(), sysname: "reg-dummy".into(), ..dev("faux") }; + name_device(&mut faux); + assert_eq!(faux.name, "Dummy voltage regulator"); + faux.sysname = "reg-dummy-other".into(); + name_device(&mut faux); + assert!(!faux.name_translated); + + let mut firmware = Device { sysname: "INTC1025:00".into(), ..dev("platform") }; + firmware.properties.insert("MODALIAS".into(), "acpi:INTC1025:INT_PTL_SINIT:".into()); + name_device(&mut firmware); + assert_eq!(firmware.name, firmware.sysname, "vendor ACPI objects get no hardcoded labels"); + firmware.properties.insert("MODALIAS".into(), "platform:coretemp".into()); + name_device(&mut firmware); + assert_eq!(firmware.name, "Intel CPU temperature monitor"); + } + + #[test] + fn root_hub_caption_needs_the_linux_vendor_id() { + let mut hub = Device { devtype: "usb_device".into(), sysname: "usb2".into(), ..dev("usb") }; + hub.properties.insert("DEVNUM".into(), "001".into()); + hub.properties.insert("PRODUCT".into(), "1234/3/702".into()); + name_device(&mut hub); + assert!(!hub.name_translated); + } + + #[test] + fn hwdb_source_is_reported() { + let mut d = dev("pci"); + d.properties.insert("ID_MODEL_FROM_DATABASE".into(), "Wi-Fi 7 BE211 320MHz".into()); + d.property_sources.insert("ID_MODEL_FROM_DATABASE".into(), "hwdb: ID_MODEL_FROM_DATABASE (pci:x)".into()); + name_device(&mut d); + assert_eq!(d.name_source, "hwdb: ID_MODEL_FROM_DATABASE (pci:x)"); + } +} diff --git a/crates/qldm-core/src/network.rs b/crates/qldm-core/src/network.rs new file mode 100644 index 0000000..245593e --- /dev/null +++ b/crates/qldm-core/src/network.rs @@ -0,0 +1,250 @@ +//! Interface addresses and permanent MAC, ported from `src/devices/networkproperties.cpp`. +//! +//! Addresses are read with `getifaddrs` in the application's network namespace; the permanent +//! MAC comes from the read-only, unprivileged `ETHTOOL_GPERMADDR` ioctl. The interface name is +//! re-checked against its ifindex before and after, so a renamed or replaced interface is +//! reported as withdrawn instead of mixing two interfaces' data. +use crate::dirfd::io_errno; +use crate::ethernet::{valid_interface_name, EthernetProperties}; +use crate::model::{Attribute, ReadState}; +use std::ffi::{CStr, CString}; +use std::sync::atomic::{AtomicBool, Ordering}; + +const SIOCETHTOOL: libc::c_ulong = 0x8946; +const ETHTOOL_GPERMADDR: u32 = 32; +/// Linux `MAX_ADDR_LEN`. +const ADDRESS_LIMIT: usize = 32; + +#[derive(Clone, Debug, Default)] +pub struct NetworkAddress { + pub family: &'static str, + pub address: String, + pub netmask: Attribute, +} + +/// Owned worker-thread results for one interface. +#[derive(Clone, Debug, Default)] +pub struct NetworkProperties { + pub name: String, + pub path: String, + pub current_address: Attribute, + pub assignment_type: Attribute, + pub link_type: Attribute, + pub mtu: Attribute, + pub addresses: Vec, + pub address_state: Attribute, + pub permanent_address: Attribute, + pub ethernet: EthernetProperties, +} + +fn failure(error: i32) -> Attribute { + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENODEV | libc::ENXIO => ReadState::Removed, + libc::EOPNOTSUPP | libc::ENODATA => ReadState::Unavailable, + _ => ReadState::Error, + }; + Attribute { state, value: String::new(), error } +} + +fn if_index(name: &str) -> u32 { + CString::new(name).map_or(0, |c| { + // SAFETY: `c` is a valid NUL-terminated string. + unsafe { libc::if_nametoindex(c.as_ptr()) } + }) +} + +/// Text of an IPv4/IPv6 socket address, or an `EAFNOSUPPORT` failure for other families. +/// +/// # Safety +/// `address` must point to a valid `sockaddr` of the size its family implies. +unsafe fn ip_text(address: *const libc::sockaddr) -> Attribute { + if address.is_null() { + return Attribute::default(); + } + // SAFETY: guaranteed by the caller; the family decides which union view is read. + unsafe { + match (*address).sa_family as i32 { + libc::AF_INET => { + let raw = (*address.cast::()).sin_addr.s_addr; + crate::dirfd::available(std::net::Ipv4Addr::from(u32::from_be(raw)).to_string()) + } + libc::AF_INET6 => { + let bytes = (*address.cast::()).sin6_addr.s6_addr; + crate::dirfd::available(std::net::Ipv6Addr::from(bytes).to_string()) + } + _ => failure(libc::EAFNOSUPPORT), + } + } +} + +/// ETHTOOL_GPERMADDR: drivers may report all zeroes when no permanent address is known, which +/// is unavailable evidence rather than a MAC. +fn permanent_address(name: &str) -> Attribute { + // SAFETY: plain socket(2); the descriptor is closed on every path below. + let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; + if fd < 0 { + return failure(io_errno(&std::io::Error::last_os_error())); + } + // `struct ethtool_perm_addr { u32 cmd; u32 size; u8 data[]; }` with room for MAX_ADDR_LEN. + let mut query = [0u8; 8 + ADDRESS_LIMIT]; + query[0..4].copy_from_slice(ÐTOOL_GPERMADDR.to_ne_bytes()); + query[4..8].copy_from_slice(&(ADDRESS_LIMIT as u32).to_ne_bytes()); + // SAFETY: ifreq is plain data; `ifru_data` points at `query`, which outlives the ioctl. + let (status, error) = unsafe { + let mut request: libc::ifreq = std::mem::zeroed(); + for (slot, byte) in request.ifr_name.iter_mut().zip(name.bytes()) { + *slot = byte as libc::c_char; + } + request.ifr_ifru.ifru_data = query.as_mut_ptr().cast(); + let status = libc::ioctl(fd, SIOCETHTOOL as _, &mut request); + let error = io_errno(&std::io::Error::last_os_error()); + libc::close(fd); + (status, error) + }; + if status < 0 { + return failure(error); + } + // This collector requests MAC addresses only for Ethernet-style interfaces. + let size = u32::from_ne_bytes(query[4..8].try_into().expect("4 bytes")) as usize; + if size != 6 { + return Attribute::default(); + } + let data = &query[8..8 + size]; + if data.iter().all(|b| *b == 0) { + return Attribute::default(); + } + crate::dirfd::available(data.iter().map(|b| format!("{b:02x}")).collect::>().join(":")) +} + +/// Collects addresses (and, for Ethernet-style interfaces, the permanent MAC) for `name`. +/// `index` must still be that name's ifindex. +pub fn collect_network_properties(name: &str, index: u32, ethernet: bool, cancel: Option<&AtomicBool>) -> NetworkProperties { + let mut result = NetworkProperties::default(); + if !valid_interface_name(name, index) { + result.address_state = failure(libc::EINVAL); + result.permanent_address = result.address_state.clone(); + return result; + } + if if_index(name) != index { + result.address_state = failure(libc::ENODEV); + result.permanent_address = result.address_state.clone(); + return result; + } + let mut raw: *mut libc::ifaddrs = std::ptr::null_mut(); + // SAFETY: getifaddrs fills `raw`; it is released with freeifaddrs below. + if unsafe { libc::getifaddrs(&mut raw) } != 0 { + result.address_state = failure(io_errno(&std::io::Error::last_os_error())); + } else { + result.address_state = crate::dirfd::available(""); + let mut entry = raw; + while !entry.is_null() { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + result.addresses.clear(); + result.address_state = failure(libc::ECANCELED); + break; + } + // SAFETY: `entry` is a live node of the list owned by getifaddrs until freed. + let current = unsafe { &*entry }; + entry = current.ifa_next; + if current.ifa_name.is_null() || current.ifa_addr.is_null() { + continue; + } + // SAFETY: ifa_name is a NUL-terminated string. + let label = unsafe { CStr::from_ptr(current.ifa_name) }.to_string_lossy(); + // getifaddrs may use the IPv4 address label (e.g. eth0:1) instead of the base + // interface name. Include these secondary addresses. + if label != name && !(label.starts_with(name) && label.as_bytes().get(name.len()) == Some(&b':')) { + continue; + } + // SAFETY: ifa_addr is non-null and a valid sockaddr per getifaddrs. + let family = unsafe { (*current.ifa_addr).sa_family } as i32; + if family != libc::AF_INET && family != libc::AF_INET6 { + continue; + } + // SAFETY: ifa_addr is a valid IPv4/IPv6 sockaddr (family checked above). + let address = unsafe { ip_text(current.ifa_addr) }; + if address.state != ReadState::Available { + result.addresses.clear(); + result.address_state = address; + break; + } + let mut text = address.value; + if family == libc::AF_INET6 { + // SAFETY: an AF_INET6 address is a sockaddr_in6. + let scope = unsafe { (*current.ifa_addr.cast::()).sin6_scope_id }; + if scope != 0 { + text.push_str(&format!("%{scope}")); + } + } + // SAFETY: the netmask, when present, has the same family and layout. + let netmask = if !current.ifa_netmask.is_null() && unsafe { (*current.ifa_netmask).sa_family } as i32 == family { + unsafe { ip_text(current.ifa_netmask) } + } else { + Attribute::default() + }; + result.addresses.push(NetworkAddress { family: if family == libc::AF_INET { "IPv4" } else { "IPv6" }, address: text, netmask }); + } + // SAFETY: `raw` came from a successful getifaddrs and is freed exactly once. + unsafe { libc::freeifaddrs(raw) }; + } + if !cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + result.permanent_address = if ethernet { permanent_address(name) } else { Attribute { state: ReadState::NotApplicable, ..Default::default() } }; + } + if if_index(name) != index { + result.addresses.clear(); + result.address_state = failure(libc::ENODEV); + result.permanent_address = result.address_state.clone(); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn loopback_index() -> u32 { + if_index("lo") + } + + #[test] + fn invalid_and_withdrawn_interfaces_are_explicit() { + assert_eq!(collect_network_properties("", 1, false, None).address_state.error, libc::EINVAL); + assert_eq!(collect_network_properties("lo", 0, false, None).permanent_address.error, libc::EINVAL); + let moved = collect_network_properties("lo", loopback_index() + 1000, false, None); + assert_eq!((moved.address_state.state, moved.permanent_address.state), (ReadState::Removed, ReadState::Removed)); + } + + #[test] + fn loopback_addresses_are_listed_and_mac_does_not_apply() { + let found = collect_network_properties("lo", loopback_index(), false, None); + assert_eq!(found.address_state.state, ReadState::Available); + assert!(found.addresses.iter().any(|a| a.family == "IPv4" && a.address == "127.0.0.1")); + let v4 = found.addresses.iter().find(|a| a.address == "127.0.0.1").unwrap(); + assert_eq!(v4.netmask.value, "255.0.0.0"); + assert!(found.addresses.iter().filter(|a| a.family == "IPv6").all(|a| a.address.contains(':'))); + assert_eq!(found.permanent_address.state, ReadState::NotApplicable); + let cancelled = AtomicBool::new(true); + let stopped = collect_network_properties("lo", loopback_index(), false, Some(&cancelled)); + assert_eq!(stopped.address_state.error, libc::ECANCELED); + assert!(stopped.addresses.is_empty()); + } + + #[test] + fn real_ethernet_interfaces_report_a_permanent_address_or_why_not() { + for entry in std::fs::read_dir("/sys/class/net").unwrap().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if std::fs::read_to_string(entry.path().join("type")).map_or(true, |t| t.trim() != "1") { + continue; + } + let found = collect_network_properties(&name, if_index(&name), true, None); + match found.permanent_address.state { + ReadState::Available => { + let parts: Vec<_> = found.permanent_address.value.split(':').collect(); + assert!(parts.len() == 6 && parts.iter().all(|p| p.len() == 2), "{name}: {:?}", found.permanent_address); + } + other => assert_ne!(other, ReadState::NotApplicable, "{name}"), + } + } + } +} diff --git a/crates/qldm-core/src/presentation.rs b/crates/qldm-core/src/presentation.rs new file mode 100644 index 0000000..89aec75 --- /dev/null +++ b/crates/qldm-core/src/presentation.rs @@ -0,0 +1,261 @@ +//! Default type-view grouping, ported from `src/devices/devicepresentation.cpp`. +//! +//! Every record stays in the inventory with its real parent and driver binding; grouping only +//! sets `hidden` and `represented_by_path`. Missing parents or unrelated classes never +//! justify suppression. +use crate::model::{is_audio_jack_switch, Device, ReadState}; +use std::collections::{HashMap, HashSet}; + +fn pci_class(d: &Device, base: u32, subclass: Option) -> bool { + if d.subsystem != "pci" { + return false; + } + let Some(code) = d.properties.get("PCI_CLASS").and_then(|v| u32::from_str_radix(v, 16).ok()) else { + return false; + }; + code >> 16 == base && subclass.is_none_or(|s| (code >> 8) & 0xff == s) +} + +/// Follows recorded udev parents to the nearest requested subsystem. PCI callers stop at the +/// first function, so an outer controller cannot represent its child. +fn ancestor(d: &Device, devices: &[Device], lookup: &HashMap<&str, usize>, subsystem: &str, devtype: Option<&str>) -> Option { + let mut path = d.parent_path.as_str(); + let mut visited = HashSet::new(); + while !path.is_empty() && visited.insert(path) { + let index = *lookup.get(path)?; + let parent = &devices[index]; + if parent.subsystem == subsystem && devtype.is_none_or(|t| parent.devtype == t) { + return Some(index); + } + path = parent.parent_path.as_str(); + } + None +} + +/// Names a USB network interface from its owning USB device's database identity, falling back +/// to readable descriptors. Never guesses what underscores in ID_MODEL meant or borrows a +/// hub's or neighbour's name. +fn usb_network_name(network: &mut Device, usb: &Device) { + let source_of = |key: &str| usb.property_sources.get(key).cloned().unwrap_or_else(|| format!("udev: {key}")); + let property = |key: &str| usb.properties.get(key).map_or("", |v| v.trim()).to_string(); + let available = |key: &str| usb.attributes.get(key).filter(|a| a.state == ReadState::Available).map(|a| a.value.trim().to_string()); + let mut model = property("ID_MODEL_FROM_DATABASE"); + let mut model_source = source_of("ID_MODEL_FROM_DATABASE"); + if model.is_empty() { + match available("product").filter(|p| !p.is_empty()) { + Some(product) => { + model = product; + model_source = format!("sysfs: {}/product", usb.path); + } + None => return, + } + } + let mut vendor = property("ID_VENDOR_FROM_DATABASE"); + let mut vendor_source = source_of("ID_VENDOR_FROM_DATABASE"); + if vendor.is_empty() { + if let Some(manufacturer) = available("manufacturer") { + vendor = manufacturer; + } + vendor_source = format!("sysfs: {}/manufacturer", usb.path); + } + let vendor_len = vendor.chars().count(); + let already_named = + !vendor.is_empty() && model.to_lowercase().starts_with(&vendor.to_lowercase()) && model.chars().nth(vendor_len).is_none_or(|c| !c.is_alphanumeric()); + network.name = if vendor.is_empty() || already_named { model } else { format!("{vendor} {model}") }; + network.name_source = format!("USB device {}: {model_source}", usb.path); + if !vendor.is_empty() && !already_named { + network.name_source += &format!("; {vendor_source}"); + } + network.name_translated = false; + network.name_candidate = false; +} + +pub fn refine_presentation(devices: &mut [Device]) { + let paths: Vec = devices.iter().map(|d| d.path.clone()).collect(); + let lookup: HashMap<&str, usize> = paths.iter().enumerate().map(|(i, p)| (p.as_str(), i)).collect(); + let mut functions: HashMap> = HashMap::new(); + for i in 0..devices.len() { + if devices[i].subsystem == "net" { + // Stop at the nearest USB device even if it has no readable name; never continue + // outward to a hub or host controller for a label. + if let Some(usb) = ancestor(&devices[i], devices, &lookup, "usb", Some("usb_device")) { + if !devices[usb].sysname.starts_with("usb") { + let owner = devices[usb].clone(); + usb_network_name(&mut devices[i], &owner); + } + } + } + if is_audio_jack_switch(&devices[i]) { + if let Some(card) = ancestor(&devices[i], devices, &lookup, "sound", None) { + devices[i].represented_by_path = paths[card].clone(); + } + } + let d = &devices[i]; + if d.hidden || !matches!(d.subsystem.as_str(), "net" | "nvme" | "sound") { + continue; + } + let Some(parent) = ancestor(d, devices, &lookup, "pci", None) else { continue }; + if devices[parent].hidden { + continue; + } + let grouped_function = (d.subsystem == "net" && pci_class(&devices[parent], 2, None)) + || (d.subsystem == "sound" && d.sysname.starts_with("card") && pci_class(&devices[parent], 4, None)); + if grouped_function { + functions.entry(parent).or_default().push(i); + } else if d.subsystem == "nvme" && pci_class(&devices[parent], 1, Some(8)) { + // The PCI function represents the controller; namespace disks stay separate. + devices[i].hidden = true; + devices[i].represented_by_path = paths[parent].clone(); + } + } + for (parent, children) in functions { + if let [child] = children[..] { + devices[child].hidden = true; + devices[child].represented_by_path = paths[parent].clone(); + } else { + // Multiple cards/ports need their independently useful function rows. + // Suppress the extra bus row, never collapse distinct cards or interfaces. + devices[parent].hidden = true; + } + } + // A single ALSA card may itself be grouped under its PCI audio function. + for i in 0..devices.len() { + if !is_audio_jack_switch(&devices[i]) { + continue; + } + if let Some(&card) = lookup.get(devices[i].represented_by_path.as_str()) { + if !devices[card].represented_by_path.is_empty() { + devices[i].represented_by_path = devices[card].represented_by_path.clone(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{classify, Attribute}; + + fn pci(path: &str, class: &str) -> Device { + let mut d = Device { path: path.into(), subsystem: "pci".into(), ..Default::default() }; + d.properties.insert("PCI_CLASS".into(), class.into()); + d + } + fn child(path: String, parent: &str, subsystem: &str, sysname: &str) -> Device { + Device { path, parent_path: parent.into(), subsystem: subsystem.into(), sysname: sysname.into(), ..Default::default() } + } + fn refined(mut devices: Vec) -> Vec { + devices.iter_mut().for_each(classify); + refine_presentation(&mut devices); + devices + } + + #[test] + fn one_wifi_function_and_one_interface_yield_one_row() { + let wifi = pci("/sys/devices/pci0000:00/0000:00:14.3", "28000"); + let net = child(format!("{}/net/wlo1", wifi.path), &wifi.path, "net", "wlo1"); + let out = refined(vec![wifi.clone(), net]); + assert!(!out[0].hidden && out[1].hidden); + assert_eq!(out[1].represented_by_path, wifi.path); + assert_eq!((out[1].parent_path.as_str(), out[1].driver.as_str()), (wifi.path.as_str(), ""), "ancestry kept"); + } + + #[test] + fn independent_network_functions_survive() { + let wifi = pci("/sys/devices/pci0000:00/0000:00:14.3", "28000"); + let a = child(format!("{}/net/wlo1", wifi.path), &wifi.path, "net", "wlo1"); + let b = child(format!("{}/net/other0", wifi.path), &wifi.path, "net", "other0"); + let out = refined(vec![wifi, a, b]); + assert!(out[0].hidden && !out[1].hidden && !out[2].hidden); + } + + #[test] + fn nvme_controller_pairs_but_disk_and_bridges_do_not() { + let ctl = pci("/sys/devices/pci0000:00/0000:00:06.0/0000:01:00.0", "10802"); + let nvme = child(format!("{}/nvme/nvme0", ctl.path), &ctl.path, "nvme", "nvme0"); + let mut disk = child(format!("{}/nvme0n1", nvme.path), &ctl.path, "block", "nvme0n1"); + disk.devtype = "disk".into(); + let out = refined(vec![ctl.clone(), nvme.clone(), disk]); + assert!(!out[0].hidden && out[1].hidden && !out[2].hidden, "controller row stays, disk not hidden"); + + let mut second = ctl.clone(); + second.path = "/sys/devices/pci0000:00/0000:02:00.0".into(); + let two = refined(vec![ctl.clone(), nvme.clone(), second]); + assert!(!two[0].hidden && !two[2].hidden, "a second controller is never merged"); + + let bridge = pci(&ctl.path, "60400"); + assert!(!refined(vec![bridge, nvme])[1].hidden, "a PCI bridge is not an NVMe controller duplicate"); + } + + fn audio_fixture() -> (Device, Device, Device) { + let audio = pci("/sys/devices/pci0000:00/0000:00:1f.3", "40300"); + let dsp = child(format!("{}/skl_hda_dsp_generic", audio.path), &audio.path, "platform", ""); + let card = child(format!("{}/sound/card0", dsp.path), &dsp.path, "sound", "card0"); + (audio, dsp, card) + } + + #[test] + fn alsa_card_groups_under_pci_audio_through_dsp_records() { + let (audio, dsp, card) = audio_fixture(); + let out = refined(vec![audio.clone(), dsp.clone(), card.clone()]); + assert!(!out[0].hidden && out[2].hidden); + assert_eq!(out[2].represented_by_path, audio.path); + + let mut card1 = card.clone(); + card1.path = format!("{}/sound/card1", dsp.path); + card1.sysname = "card1".into(); + let multi = refined(vec![audio.clone(), dsp.clone(), card.clone(), card1]); + assert!(multi[0].hidden && !multi[2].hidden && !multi[3].hidden, "distinct cards stay visible"); + + let usb = pci(&audio.path, "0c0330"); + assert!(!refined(vec![usb, dsp.clone(), card.clone()])[2].hidden, "a USB controller is not an audio function"); + + let mut second = audio.clone(); + second.path = "/sys/devices/pci0000:00/0000:01:00.1".into(); + let two = refined(vec![audio, dsp, card, second]); + assert!(!two[0].hidden && !two[3].hidden, "a separate audio controller is not merged"); + } + + #[test] + fn alsa_switch_is_associated_with_the_audio_controller() { + let (audio, dsp, card) = audio_fixture(); + let mut jack = child(format!("{}/input23", card.path), &card.path, "input", "input23"); + jack.properties.insert("NAME".into(), "\"sof-hda-dsp HDMI/DP,pcm=3\"".into()); + jack.properties.insert("PHYS".into(), "\"ALSA\"".into()); + jack.properties.insert("ID_INPUT_SWITCH".into(), "1".into()); + let out = refined(vec![audio.clone(), dsp, card, jack]); + assert_eq!((out[3].category, out[3].hidden), ("audio", true)); + assert_eq!(out[3].represented_by_path, audio.path); + } + + #[test] + fn usb_network_interface_takes_the_owning_usb_devices_name() { + let mut usb = Device { + path: "/sys/devices/pci0000:00/usb1/1-2".into(), + subsystem: "usb".into(), + devtype: "usb_device".into(), + sysname: "1-2".into(), + ..Default::default() + }; + usb.properties.insert("ID_MODEL_FROM_DATABASE".into(), "AX88179 Gigabit Ethernet".into()); + usb.properties.insert("ID_VENDOR_FROM_DATABASE".into(), "ASIX Electronics Corp.".into()); + let net = child(format!("{}/1-2:1.0/net/enx1", usb.path), &format!("{}/1-2:1.0", usb.path), "net", "enx1"); + let iface = child(format!("{}/1-2:1.0", usb.path), &usb.path, "usb", "1-2:1.0"); + let out = refined(vec![usb.clone(), iface, net]); + assert_eq!(out[2].name, "ASIX Electronics Corp. AX88179 Gigabit Ethernet"); + assert!(out[2].name_source.starts_with("USB device /sys/devices/pci0000:00/usb1/1-2")); + + // Unreadable descriptors leave the name untouched, never borrowing a hub's. + let mut bare = usb; + bare.properties.clear(); + bare.attributes.insert("product".into(), Attribute { state: ReadState::PermissionDenied, value: "x".into(), error: 13 }); + let net2 = Device { name: "kernel".into(), ..child(format!("{}/net/enx2", bare.path), &bare.path, "net", "enx2") }; + assert_eq!(refined(vec![bare, net2])[1].name, "kernel"); + } + + #[test] + fn unrelated_or_missing_parents_never_suppress() { + let orphan = child("/sys/devices/x/net/eth9".into(), "/sys/devices/missing", "net", "eth9"); + assert!(!refined(vec![orphan])[0].hidden); + } +} diff --git a/crates/qldm-core/src/properties.rs b/crates/qldm-core/src/properties.rs new file mode 100644 index 0000000..0baf1b0 --- /dev/null +++ b/crates/qldm-core/src/properties.rs @@ -0,0 +1,1149 @@ +//! On-demand device properties, ported from `src/devices/deviceproperties.cpp`. +//! +//! One read-only, bounded pass over a single device directory: selected udev metadata, the +//! documented per-subsystem sysfs attributes, direct driver/module facts, PCIe link data, USB +//! interfaces, battery values, resources and (for UEFI records) the file bytes. Nothing here +//! writes, escalates privileges or queries hardware beyond sysfs. Storage, network, display and +//! camera groups are filled by their own modules. +use crate::camera::{collect_camera_properties, default_helper_path, CameraProperties}; +use crate::dirfd::{available, failure, file_name, io_errno, read_limited, Dir, TEXT_LIMIT}; +use crate::display::{collect_display_properties, DisplayProperties}; +use crate::ethernet::collect_ethernet_properties; +use crate::model::{Attribute, Device, ReadState}; +use crate::modulenames::{module_details, ModuleLookup}; +use crate::monitor_edid::{collect_monitor_properties, MonitorProperties}; +use crate::network::{collect_network_properties, NetworkProperties}; +use crate::resources::{collect_device_resources, DeviceResources}; +use crate::storage::{collect_storage_properties, StorageProperties}; +use crate::sysfs::bounded_directory_names; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt}; +use std::sync::atomic::{AtomicBool, Ordering}; + +const EFI_LIMIT: usize = 65536; + +/// USB-specific values stay separate from generic Details metadata. +#[derive(Clone, Debug, Default)] +pub struct UsbInterfaceProperties { + pub name: String, + pub path: String, + pub values: HashMap, +} + +#[derive(Clone, Debug, Default)] +pub struct UsbProperties { + pub values: HashMap, + pub interfaces: Vec, + pub interface_state: Attribute, +} + +#[derive(Clone, Debug, Default)] +pub struct BatteryProperties { + pub applicable: bool, + pub values: HashMap, + pub state: Attribute, +} + +/// Owned raw results. Display labels, translations and hex formatting belong to the UI side. +#[derive(Clone, Debug, Default)] +pub struct DeviceProperties { + pub device: Device, + pub values: HashMap, + pub sources: HashMap, + pub resources: DeviceResources, + pub storage: StorageProperties, + pub network: Vec, + pub network_state: Attribute, + pub display: DisplayProperties, + pub monitor: MonitorProperties, + pub camera: CameraProperties, + pub usb: UsbProperties, + pub battery: BatteryProperties, + /// Evidence from this PCI device's link attributes only. + pub pcie_applicable: bool, + /// Numeric PCI Express device/port type; no GPU-id inference. + pub pcie_type: Attribute, + pub pcie_type_source: String, + pub pcie_type_requires_root: bool, + pub efi_bytes: Vec, + pub efi_truncated: bool, + pub state: ReadState, + pub error: i32, + pub request: u64, +} + +fn inode_identity(info: &std::fs::Metadata) -> String { + format!("{}:{}", info.dev(), info.ino()) +} + +fn put(result: &mut DeviceProperties, id: &str, value: Attribute, source: impl Into) { + result.values.insert(id.to_string(), value); + result.sources.insert(id.to_string(), source.into()); +} + +fn bounded_text(value: Option<&OsStr>) -> Attribute { + match value { + None => Attribute::default(), + Some(v) if v.len() > TEXT_LIMIT => failure(libc::EOVERFLOW), + Some(v) => available(v.to_string_lossy().into_owned()), + } +} + +fn unsupported_if_unimplemented(value: &mut Attribute) { + if value.error == libc::EOPNOTSUPP || value.error == libc::ENOSYS { + value.state = ReadState::Unsupported; + } +} + +fn same(a: &Attribute, b: &Attribute) -> bool { + a == b +} + +fn sysfs(result: &mut DeviceProperties, dir: &Dir, key: &str) { + let source = format!("{}/{key}", result.device.path); + put(result, &format!("sysfs/{key}"), dir.read_text(key), source); +} + +// --------------------------------------------------------------------------------------- +// PCI Express +// --------------------------------------------------------------------------------------- + +const PCI_STATUS: i64 = 0x06; +const PCI_STATUS_CAP_LIST: u32 = 0x10; +const PCI_HEADER_TYPE: i64 = 0x0e; +const PCI_CAPABILITY_LIST: i64 = 0x34; +const PCI_CB_CAPABILITY_LIST: i64 = 0x14; +const PCI_CAP_ID_EXP: u32 = 0x10; +const PCI_EXP_FLAGS: u32 = 2; +const PCI_EXP_FLAGS_VERS: u32 = 0x000f; +const PCI_EXP_FLAGS_TYPE: u32 = 0x00f0; + +/// Finds the PCI Express device/port type by walking the standard capability list through +/// `read(offset, size)`. At most 48 aligned entries in bytes 0x40..0xff; no extended-space scan, +/// payload dump, BAR access or write. +fn pcie_type_from(read: &dyn Fn(i64, usize) -> Result, source: &mut String) -> Attribute { + let value = |offset, size| read(offset, size).map_err(failure); + let status = match value(PCI_STATUS, 2) { + Ok(v) => v, + Err(a) => return a, + }; + if status == 0xffff { + return failure(libc::ENODEV); + } + if status & PCI_STATUS_CAP_LIST == 0 { + return failure(libc::ENODATA); + } + let header = match value(PCI_HEADER_TYPE, 1) { + Ok(v) => v & 0x7f, + Err(a) => return a, + }; + if header > 2 { + return failure(libc::EPROTO); + } + let mut pointer = match value(if header == 2 { PCI_CB_CAPABILITY_LIST } else { PCI_CAPABILITY_LIST }, 1) { + Ok(v) => v, + Err(a) => return a, + }; + let mut visited = [false; 256]; + for _ in 0..48 { + if pointer == 0 { + break; + } + if !(0x40..=0xfc).contains(&pointer) || pointer & 3 != 0 || visited[pointer as usize] { + return failure(libc::EPROTO); + } + visited[pointer as usize] = true; + let capability = match value(pointer as i64, 2) { + Ok(v) => v, + Err(a) => return a, + }; + if capability & 0xff == PCI_CAP_ID_EXP { + let flags = match value((pointer + PCI_EXP_FLAGS) as i64, 2) { + Ok(v) => v, + Err(a) => return a, + }; + if flags == 0xffff || flags & PCI_EXP_FLAGS_VERS == 0 { + return failure(libc::EPROTO); + } + source.push_str(&format!("; PCI Express flags at offset 0x{:02x}", pointer + PCI_EXP_FLAGS)); + return available(((flags & PCI_EXP_FLAGS_TYPE) >> 4).to_string()); + } + pointer = capability >> 8; + } + failure(if pointer != 0 { libc::EPROTO } else { libc::ENODATA }) +} + +fn pcie_device_type(dir: &Dir, source: &mut String, cancel: Option<&AtomicBool>) -> Attribute { + // Ordinary sysfs link reads remain usable without administrative privileges. Never + // escalate or attempt a configuration-space read for a normal user. + // SAFETY: geteuid has no preconditions. + if unsafe { libc::geteuid() } != 0 { + return failure(libc::EACCES); + } + let config = match dir.open_file("config") { + Ok(f) => f, + Err(e) => return failure(io_errno(&e)), + }; + let read = |offset: i64, size: usize| -> Result { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return Err(libc::ECANCELED); + } + let mut bytes = [0u8; 2]; + match config.read_at(&mut bytes[..size], offset as u64) { + Err(e) => Err(io_errno(&e)), + Ok(n) if n != size => Err(libc::EMSGSIZE), + Ok(_) => Ok(bytes[0] as u32 | if size == 2 { (bytes[1] as u32) << 8 } else { 0 }), + } + }; + pcie_type_from(&read, source) +} + +fn pcie_properties(result: &mut DeviceProperties, dir: &Dir, cancel: Option<&AtomicBool>) { + if result.device.subsystem != "pci" { + return; + } + let mut values = Vec::new(); + let mut applicable = false; + for key in ["current_link_speed", "current_link_width", "max_link_speed", "max_link_width"] { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return; + } + let mut value = dir.read_text(key); + // Absent files alone do not establish PCIe applicability. Preserve actual read + // failures so a present but unreadable PCIe device still has a tab. + if value.state == ReadState::Available || value.error != libc::ENOENT { + applicable = true; + } + if value.error == libc::EOPNOTSUPP || value.error == libc::ENOSYS { + value.state = ReadState::Unsupported; + } + values.push((key, value)); + } + if !applicable { + return; + } + result.pcie_applicable = true; + for (key, value) in values { + let source = format!("{}/{key}", result.device.path); + put(result, &format!("sysfs/{key}"), value, source); + } + result.pcie_type_source = format!("{}/config", result.device.path); + // SAFETY: geteuid has no preconditions. + result.pcie_type_requires_root = unsafe { libc::geteuid() } != 0; + let mut source = std::mem::take(&mut result.pcie_type_source); + result.pcie_type = pcie_device_type(dir, &mut source, cancel); + result.pcie_type_source = source; +} + +// --------------------------------------------------------------------------------------- +// Battery +// --------------------------------------------------------------------------------------- + +fn battery_properties(result: &mut DeviceProperties, dir: &Dir, cancel: Option<&AtomicBool>) { + if result.device.subsystem != "power_supply" { + return; + } + let mut kind = result.values.get("sysfs/type").cloned().unwrap_or_default(); + unsupported_if_unimplemented(&mut kind); + let previous = result.device.attributes.get("type").cloned().unwrap_or_default(); + // Inventory evidence controls only whether a failed snapshot gets a tab. It must not + // replace an unreadable current type or supply battery values. + if kind.state == ReadState::Available && kind.value != "Battery" { + return; + } + if kind.state != ReadState::Available && (previous.state != ReadState::Available || previous.value != "Battery") { + return; + } + let battery = &mut result.battery; + battery.applicable = true; + battery.values.insert("type".into(), kind.clone()); + if kind.state != ReadState::Available { + battery.state = kind; + return; + } + battery.state = available(""); + for key in ["manufacturer", "model_name", "serial_number", "present"] { + battery.values.insert(key.into(), result.values.get(&format!("sysfs/{key}")).cloned().unwrap_or_default()); + } + let present = battery.values["present"].clone(); + if present.state == ReadState::Available && present.value != "0" && present.value != "1" { + battery.state = failure(libc::EINVAL); + return; + } + let absent = present.state == ReadState::Available && present.value == "0"; + for key in [ + "status", + "technology", + "cycle_count", + "capacity", + "voltage_now", + "energy_full_design", + "energy_full", + "energy_now", + "charge_full_design", + "charge_full", + "charge_now", + "current_now", + "power_now", + ] { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + battery.state = failure(libc::ECANCELED); + battery.values.clear(); + return; + } + let mut value = if absent { Attribute { state: ReadState::NotApplicable, ..Default::default() } } else { dir.read_text(key) }; + unsupported_if_unimplemented(&mut value); + battery.values.insert(key.into(), value); + } + // A power_supply node can remain while a removable battery disappears. Reject changed + // presence/type evidence rather than presenting mixed values. + let after_type = dir.read_text("type"); + let after_present = dir.read_text("present"); + if !same(&kind, &after_type) || !same(&present, &after_present) { + battery.values.clear(); + battery.state = if after_type.state != ReadState::Available { + after_type + } else if !same(&present, &after_present) && after_present.state != ReadState::Available { + after_present + } else { + failure(libc::EAGAIN) + }; + unsupported_if_unimplemented(&mut battery.state); + } +} + +// --------------------------------------------------------------------------------------- +// USB +// --------------------------------------------------------------------------------------- + +fn usb_properties(result: &mut DeviceProperties, dir: &Dir, cancel: Option<&AtomicBool>) { + let device = result.device.clone(); + if device.subsystem != "usb" || device.devtype != "usb_device" { + return; + } + let usb = &mut result.usb; + for key in ["bConfigurationValue", "bNumConfigurations", "configuration", "bNumInterfaces", "bmAttributes", "bMaxPower"] { + usb.values.insert(key.into(), dir.read_text(key)); + } + let configuration = usb.values["bConfigurationValue"].clone(); + let unconfigured = configuration.state == ReadState::Available && (configuration.value.is_empty() || configuration.value == "0"); + let not_applicable = Attribute { state: ReadState::NotApplicable, ..Default::default() }; + if unconfigured { + for key in ["configuration", "bNumInterfaces", "bmAttributes", "bMaxPower"] { + usb.values.insert(key.into(), not_applicable.clone()); + } + usb.interface_state = not_applicable.clone(); + } + // Only direct USB interface children, never descendant devices behind hubs. + let names = if unconfigured { Ok(Vec::new()) } else { bounded_directory_names(&device.path, 256, &[&format!("{}:*", device.sysname)], cancel) }; + if !unconfigured { + usb.interface_state = match &names { + Err(error) => failure(*error), + Ok(_) => available(""), + }; + } + let listed = names.clone().unwrap_or_default(); + for name in &listed { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + usb.interfaces.clear(); + usb.interface_state = failure(libc::ECANCELED); + break; + } + let mut interface = UsbInterfaceProperties { name: name.clone(), path: format!("{}/{name}", device.path), values: HashMap::new() }; + match dir.open_dir(name, false).and_then(|child| child.metadata().map(|m| (child, m))) { + Err(e) => { + interface.values.insert("state".into(), failure(io_errno(&e))); + } + Ok((child, before)) => { + let driver = child.link_target("driver"); + for key in ["bInterfaceNumber", "bAlternateSetting", "bInterfaceClass", "bInterfaceSubClass", "bInterfaceProtocol", "interface"] { + interface.values.insert(key.into(), child.read_text(key)); + } + let mut binding = driver.clone(); + if binding.state == ReadState::Available { + binding.value = file_name(&binding.value); + } else if binding.state == ReadState::Unavailable && binding.error == libc::ENOENT { + binding = available(""); + } + interface.values.insert("driver".into(), binding); + let after_driver = child.link_target("driver"); + let after_setting = child.read_text("bAlternateSetting"); + let setting = interface.values["bAlternateSetting"].clone(); + match dir.lstat(name) { + Err(e) => { + interface.values.clear(); + interface.values.insert("state".into(), failure(io_errno(&e))); + } + Ok(after) => { + if inode_identity(&before) != inode_identity(&after) || !same(&driver, &after_driver) || !same(&setting, &after_setting) { + interface.values.clear(); + interface.values.insert("state".into(), failure(libc::EAGAIN)); + } + } + } + } + } + usb.interfaces.push(interface); + } + // A listing failure must not become a successful empty/partial result. + let count = usb.values["bNumInterfaces"].clone(); + let expected = count.value.parse::().ok(); + if !unconfigured && names.is_ok() && listed.is_empty() && count.state != ReadState::Available { + usb.interface_state = count.clone(); + } + if !unconfigured && names.is_ok() && count.state == ReadState::Available { + let consistent = expected.is_some_and(|e| e <= 255 && e as usize == listed.len()); + if !consistent { + usb.interfaces.clear(); + usb.interface_state = failure(if expected.is_some_and(|e| e <= 255) { libc::EAGAIN } else { libc::EINVAL }); + } + } + // Configuration changes invalidate both its declarations and interface list. + let after = dir.read_text("bConfigurationValue"); + if !same(&configuration, &after) { + usb.interfaces.clear(); + let changed = failure(libc::EAGAIN); + for value in usb.values.values_mut() { + *value = changed.clone(); + } + usb.interface_state = changed; + } +} + +// --------------------------------------------------------------------------------------- +// Network +// --------------------------------------------------------------------------------------- + +/// Reads one interface directory. Address, MAC and (for non-wireless Ethernet) link data are +/// taken for the same ifindex; if the index changes or the device vanishes while reading, every +/// value is replaced by the reason rather than mixing two interfaces. +fn network_interface(dir: &Dir, path: &str, name: &str, ethernet_details: bool, cancel: Option<&AtomicBool>) -> NetworkProperties { + let index = dir.read_text("ifindex"); + let link_type = dir.read_text("type"); + let number = index.value.parse::().ok().filter(|n| *n != 0); + let usable_index = index.state == ReadState::Available && number.is_some(); + let is_ether = link_type.state == ReadState::Available && link_type.value == "1"; + let mut result = match number.filter(|_| usable_index) { + Some(number) => collect_network_properties(name, number, is_ether, cancel), + None => { + let reason = if index.state == ReadState::Available { failure(libc::EINVAL) } else { index.clone() }; + NetworkProperties { address_state: reason.clone(), permanent_address: reason, ..Default::default() } + } + }; + result.name = name.to_string(); + result.path = path.to_string(); + result.link_type = link_type.clone(); + if link_type.state != ReadState::Available { + result.permanent_address = link_type; + } + result.current_address = dir.read_text("address"); + result.assignment_type = dir.read_text("addr_assign_type"); + result.mtu = dir.read_text("mtu"); + if ethernet_details && is_ether { + // ARPHRD_ETHER describes MAC framing and includes Wi-Fi. Check both kernel wireless + // markers before issuing Ethernet-only ethtool queries. + let mut wireless = false; + let mut scope_error = 0; + for marker in ["phy80211", "wireless"] { + match dir.lstat(marker) { + Ok(_) => wireless = true, + Err(e) if io_errno(&e) != libc::ENOENT => scope_error = io_errno(&e), + Err(_) => {} + } + } + if !wireless && scope_error != 0 { + result.ethernet.scope = failure(scope_error); + result.ethernet.source = format!("{path}/phy80211; {path}/wireless"); + } else if !wireless && usable_index && !cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + result.ethernet = collect_ethernet_properties(name, number.expect("usable"), cancel); + } + } + let after = dir.read_text("ifindex"); + let index_changed = index.state == ReadState::Available && (after.state != ReadState::Available || after.value != index.value); + let removed = |a: &Attribute| a.state == ReadState::Removed; + if index_changed || removed(&result.address_state) || removed(&result.permanent_address) || removed(&result.ethernet.scope) { + result.addresses.clear(); + let reason = if index_changed { + if after.state != ReadState::Available { + after + } else { + failure(libc::EAGAIN) + } + } else { + failure(libc::ENODEV) + }; + result.address_state = reason.clone(); + result.permanent_address = reason.clone(); + result.current_address = reason.clone(); + result.assignment_type = reason.clone(); + result.mtu = reason.clone(); + if result.ethernet.applicable || result.ethernet.scope.state != ReadState::NotApplicable { + result.ethernet.invalidate(&reason); + } + } + result +} + +fn network_properties(result: &mut DeviceProperties, dir: &Dir, ethernet_details: bool, cancel: Option<&AtomicBool>) { + let device = result.device.clone(); + if device.subsystem == "net" { + result.network.push(network_interface(dir, &device.path, &device.sysname, ethernet_details, cancel)); + result.network_state = available(""); + return; + } + if device.category != "network" { + return; + } + // A single net interface is represented by its PCI function in the device tree. Read only + // that function's direct net directory, never ancestor ports. + let directory = match dir.open_dir("net", false) { + Ok(d) => d, + Err(e) => { + result.network_state = failure(io_errno(&e)); + return; + } + }; + let path = format!("{}/net", device.path); + let names = match bounded_directory_names(&path, 256, &[], cancel) { + Ok(names) => names, + Err(error) => { + result.network_state = failure(error); + return; + } + }; + result.network_state = available(""); + for name in names { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + break; + } + let interface_path = format!("{path}/{name}"); + match directory.open_dir(&name, false) { + Err(e) => { + let error = failure(io_errno(&e)); + result.network.push(NetworkProperties { + name, + path: interface_path, + address_state: error.clone(), + permanent_address: error.clone(), + current_address: error.clone(), + assignment_type: error.clone(), + link_type: error.clone(), + mtu: error, + ..Default::default() + }); + } + Ok(interface) => result.network.push(network_interface(&interface, &interface_path, &name, ethernet_details, cancel)), + } + } +} + +// --------------------------------------------------------------------------------------- +// Driver / module +// --------------------------------------------------------------------------------------- + +const MODULE_FIELDS: [&str; 6] = ["filename", "version", "description", "author", "license", "firmware"]; + +fn module_metadata(result: &mut DeviceProperties, driver: &Dir, cancel: Option<&AtomicBool>) { + let mut owner = driver.link_target("module"); + // ENOENT is absent evidence, not evidence of a built-in driver. + if owner.state == ReadState::Available { + owner.value = file_name(&owner.value); + } + let driver_path = result.values.get("driver/path").map(|a| a.value.clone()).unwrap_or_default(); + put(result, "module/name", owner.clone(), format!("{driver_path}/module")); + for key in MODULE_FIELDS { + put(result, &format!("module/{key}"), Attribute::default(), "installed module metadata"); + } + put(result, "module/type", Attribute::default(), "kernel module initstate"); + put(result, "module/runtime_version", Attribute::default(), "kernel module version"); + if owner.state != ReadState::Available || owner.value.is_empty() { + return; + } + match driver.open_dir("module", true) { + Ok(module) => { + put(result, "module/runtime_version", module.read_text("version"), format!("/sys/module/{}/version", owner.value)); + let state = module.read_text("initstate"); + let source = format!("/sys/module/{}/initstate", owner.value); + put(result, "module/type", if state.state == ReadState::Available { Attribute::default() } else { state.clone() }, source.clone()); + if state.state == ReadState::Available && matches!(state.value.as_str(), "live" | "coming" | "going") { + put(result, "module/type", available("modular"), source); + } + } + Err(e) => { + let error = failure(io_errno(&e)); + put(result, "module/runtime_version", error.clone(), "kernel module version"); + put(result, "module/type", error, "kernel module initstate"); + } + } + let all_fields = |result: &mut DeviceProperties, error: Attribute, skip_filename: bool| { + for key in MODULE_FIELDS { + if skip_filename && key == "filename" { + continue; + } + result.values.insert(format!("module/{key}"), error.clone()); + } + }; + match module_details(&owner.value) { + ModuleLookup::Unsupported => { + for key in MODULE_FIELDS { + result.values.entry(format!("module/{key}")).or_default().state = ReadState::Unsupported; + } + } + ModuleLookup::Failed(error) => all_fields(result, failure(error), false), + ModuleLookup::Missing => {} + ModuleLookup::Found(details) => { + put(result, "module/filename", details.filename.clone(), "libkmod: running-kernel installed module index"); + match details.info { + Err(error) => all_fields(result, failure(error), true), + Ok(entries) => { + for (examined, (key, value)) in entries.into_iter().enumerate() { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + break; + } + if examined >= 4096 { + all_fields(result, failure(libc::EOVERFLOW), true); + break; + } + if key.state != ReadState::Available || key.value == "filename" || !MODULE_FIELDS.contains(&key.value.as_str()) { + continue; + } + let id = format!("module/{}", key.value); + let previous = result.values.get(&id).cloned().unwrap_or_default(); + if previous.state == ReadState::Error { + continue; + } + let mut value = value; + if value.state == ReadState::Available && previous.state == ReadState::Available { + value.value = format!("{}\n{}", previous.value, value.value); + if value.value.len() > TEXT_LIMIT { + value = failure(libc::EOVERFLOW); + } + } + let source = if details.filename.state == ReadState::Available { + format!("{}: .modinfo/{}", details.filename.value, key.value) + } else { + format!("libkmod: installed metadata/{}", key.value) + }; + put(result, &id, value, source); + } + if details.too_many { + all_fields(result, failure(libc::EOVERFLOW), true); + } + } + } + } + } +} + +// --------------------------------------------------------------------------------------- +// Collection +// --------------------------------------------------------------------------------------- + +fn removed_or(error: i32) -> ReadState { + if error == libc::ENOENT || error == libc::ENODEV { + ReadState::Removed + } else { + failure(error).state + } +} + +const UDEV_KEYS: [&str; 25] = [ + "ID_VENDOR_FROM_DATABASE", + "ID_VENDOR", + "ID_MODEL_FROM_DATABASE", + "ID_MODEL", + "ID_BUS", + "ID_PATH", + "ID_PATH_TAG", + "ID_SERIAL", + "ID_SERIAL_SHORT", + "ID_REVISION", + "ID_VENDOR_ID", + "ID_MODEL_ID", + "PCI_ID", + "PCI_SUBSYS_ID", + "PCI_CLASS", + "PCI_SLOT_NAME", + "PRODUCT", + "PHYS", + "MODALIAS", + "DEVNAME", + "DEVTYPE", + "DRIVER", + "ID_NET_DRIVER", + "ID_USB_DRIVER", + "ID_FIRMWARE_FROM_DATABASE", +]; + +fn subsystem_attributes(d: &Device) -> &'static [&'static str] { + match d.subsystem.as_str() { + "pci" => &["vendor", "device", "subsystem_vendor", "subsystem_device", "class", "revision", "modalias"], + "usb" => &["idVendor", "idProduct", "bcdDevice", "manufacturer", "product", "serial", "authorized", "busnum", "devnum", "version", "speed", "modalias"], + "input" => &["name", "phys", "uniq", "modalias"], + "nvme" => &["model", "serial", "firmware_rev", "state"], + "power_supply" => &["type", "manufacturer", "model_name", "serial_number", "present"], + "net" => &["address", "addr_assign_type", "type", "mtu", "ifindex", "operstate", "flags"], + "drm" if d.sysname.contains('-') => &["status", "enabled"], + "hdaudio" => &["vendor_name", "chip_name", "vendor_id", "subsystem_id", "revision_id"], + "acpi" => &["hid", "uid", "path", "modalias"], + "pnp" | "platform" | "hid" => &["modalias"], + "video4linux" => &["name", "index"], + "sound" if d.sysname.starts_with("card") => &["id"], + _ => &[], + } +} + +/// Collects the properties of `device`. `cached_storage_service` is false for deep search, +/// which skips desktop-session and service-backed groups. Results are always an owned +/// snapshot; the state says whether the instance was verified still present. +pub fn collect_device_properties(device: &Device, cached_storage_service: bool, cancel: Option<&AtomicBool>) -> DeviceProperties { + let mut result = DeviceProperties { device: device.clone(), ..Default::default() }; + let d = device; + let cancelled = || cancel.is_some_and(|c| c.load(Ordering::Relaxed)); + let efi = d.subsystem == "efivarfs"; + if efi { + match std::fs::symlink_metadata(&d.path) { + Err(e) => { + result.error = io_errno(&e); + result.state = removed_or(result.error); + return result; + } + Ok(entry) => { + if inode_identity(&entry) != d.incarnation { + result.state = ReadState::Removed; + return result; + } + if !entry.file_type().is_file() || entry.mode() & 0o004 == 0 { + result.state = ReadState::PermissionDenied; + result.error = libc::EACCES; + return result; + } + } + } + } + // The device directory (or, for UEFI records, the file) is opened once and every further + // read is anchored to it. + let mut efi_file = None; + let mut dir = None; + let before = if efi { + match std::fs::OpenOptions::new().read(true).custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW).open(&d.path) { + Ok(file) => { + let info = file.metadata(); + efi_file = Some(file); + info + } + Err(e) => Err(e), + } + } else { + match Dir::open(&d.path) { + Ok(opened) => { + let info = opened.metadata(); + dir = Some(opened); + info + } + Err(e) => Err(e), + } + }; + let before = match before { + Ok(info) => info, + Err(e) => { + result.error = io_errno(&e); + result.state = removed_or(result.error); + return result; + } + }; + let identity = inode_identity(&before); + let same_identity = if efi { identity == d.incarnation } else { d.incarnation.starts_with(&format!("{identity}:")) }; + if !same_identity { + result.state = ReadState::Removed; + return result; + } + result.state = ReadState::Available; + if let Some(mut file) = efi_file { + if !before.file_type().is_file() || before.mode() & 0o004 == 0 { + result.state = ReadState::PermissionDenied; + result.error = libc::EACCES; + return result; + } + match read_limited(&mut file, EFI_LIMIT, &cancelled) { + Err(error) => put(&mut result, "efi/hex", failure(error), "efivarfs: complete file bytes"), + Ok(mut bytes) => { + result.efi_truncated = bytes.len() > EFI_LIMIT; + bytes.truncate(EFI_LIMIT); + result.efi_bytes = bytes; + put(&mut result, "efi/hex", available(""), "efivarfs: complete file bytes, no decoding"); + } + } + } else if let Some(dir) = dir { + let Ok(raw) = udev::Device::from_syspath(std::path::Path::new(&d.path)) else { + result.state = ReadState::Error; + result.error = libc::EIO; + return result; + }; + let stamp = bounded_text(raw.property_value("USEC_INITIALIZED")).value; + if format!("{identity}:{stamp}") != d.incarnation { + result.state = ReadState::Removed; + return result; + } + // Selected udev metadata only; no unbounded property dump. + for key in UDEV_KEYS { + let value = bounded_text(raw.property_value(key)); + if value.state != ReadState::Unavailable { + put(&mut result, &format!("udev/{key}"), value, format!("udev: {key} on {}", d.path)); + } + } + let driver_link = dir.link_target("driver"); + let mut driver_path = driver_link.clone(); + if driver_path.state == ReadState::Available { + let target = std::path::Path::new(&driver_path.value); + let joined = if target.is_absolute() { target.to_path_buf() } else { std::path::Path::new(&d.path).join(target) }; + driver_path.value = clean_path(&joined.to_string_lossy()); + } + put(&mut result, "driver/path", driver_path.clone(), format!("{}/driver", d.path)); + let mut binding = driver_link.clone(); + if binding.state == ReadState::Available { + binding.value = file_name(&binding.value); + } else if binding.state == ReadState::Unavailable && binding.error == libc::ENOENT { + binding = available(""); + } + put(&mut result, "driver/name", binding, format!("{}/driver", d.path)); + if driver_link.state == ReadState::Available { + // Follow this one documented kernel link, never a guessed parent driver. + match dir.open_dir("driver", true) { + Ok(driver) => { + let subsystem = dir.link_target("subsystem"); + let bus = if subsystem.state == ReadState::Available { available(file_name(&subsystem.value)) } else { subsystem }; + put(&mut result, "driver/bus", bus, format!("{}/subsystem", d.path)); + module_metadata(&mut result, &driver, cancel); + } + Err(e) => put(&mut result, "module/name", failure(io_errno(&e)), format!("{}/module", driver_path.value)), + } + } + for key in subsystem_attributes(d) { + if cancelled() { + break; + } + sysfs(&mut result, &dir, key); + } + if cached_storage_service && !cancelled() { + pcie_properties(&mut result, &dir, cancel); + } + if !cancelled() { + result.resources = collect_device_resources(dir.file(), d, cancel); + } + if !cancelled() { + result.storage = collect_storage_properties(d, cached_storage_service, cancel); + } + if cached_storage_service && d.subsystem == "video4linux" && d.sysname.starts_with("video") && !cancelled() { + let node = bounded_text(raw.devnode().map(|p| p.as_os_str())).value; + result.camera = collect_camera_properties(&dir, &d.path, &node, &default_helper_path(), cancel); + } + // Desktop-session queries belong to explicit properties requests, not deep search. + if cached_storage_service && d.subsystem == "drm" && d.sysname.contains('-') && !cancelled() { + result.monitor = collect_monitor_properties(&dir, &d.path, cancel); + if !cancelled() { + result.display = collect_display_properties(d, &dir.read_text("status"), cancel); + } + } + if !cancelled() { + network_properties(&mut result, &dir, cached_storage_service, cancel); + } + if cached_storage_service && !cancelled() { + usb_properties(&mut result, &dir, cancel); + } + if cached_storage_service && !cancelled() { + battery_properties(&mut result, &dir, cancel); + } + // Binding can change during a read even if the device instance remains. + let after_link = dir.link_target("driver"); + if !same(&after_link, &driver_link) { + result.state = ReadState::Error; + result.error = libc::EAGAIN; + } + let _ = dir.file().as_raw_fd(); + } + match std::fs::symlink_metadata(&d.path) { + Err(e) => { + result.error = io_errno(&e); + result.state = removed_or(result.error); + } + Ok(after) => { + if inode_identity(&after) != identity { + result.state = ReadState::Removed; + } else if efi && after.mode() & 0o004 == 0 { + result.state = ReadState::PermissionDenied; + result.error = libc::EACCES; + result.efi_bytes.clear(); + } + } + } + if cancelled() { + result.state = ReadState::Error; + result.error = libc::ECANCELED; + } + result +} + +/// `QDir::cleanPath` for absolute paths: collapses `.`, `..` and repeated separators. +fn clean_path(path: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + other => parts.push(other), + } + } + format!("/{}", parts.join("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::{symlink, PermissionsExt}; + use std::path::Path; + + fn write(dir: &Path, name: &str, text: &str) { + fs::write(dir.join(name), text).unwrap(); + } + + fn fixture_properties(device: Device) -> DeviceProperties { + DeviceProperties { device, ..Default::default() } + } + + #[test] + fn clean_path_collapses_relative_driver_links() { + assert_eq!(clean_path("/sys/devices/pci0000:00/0000:00:14.3/../../../bus/pci/drivers/iwlwifi"), "/sys/bus/pci/drivers/iwlwifi"); + assert_eq!(clean_path("/a//b/./c"), "/a/b/c"); + } + + fn pcie_config(bytes: Vec) -> impl Fn(i64, usize) -> Result { + move |offset, size| { + let o = offset as usize; + if o + size > bytes.len() { + return Err(libc::EMSGSIZE); + } + Ok(bytes[o] as u32 | if size == 2 { (bytes[o + 1] as u32) << 8 } else { 0 }) + } + } + + #[test] + fn pcie_type_follows_the_capability_list() { + let mut config = vec![0u8; 256]; + config[0x06] = 0x10; // status: capability list present + config[0x0e] = 0x00; // header type 0 + config[0x34] = 0x40; // first capability + config[0x40] = 0x01; // power management, next 0x50 + config[0x41] = 0x50; + config[0x50] = 0x10; // PCI Express, end of list + config[0x52] = 0x02 | (4 << 4); // version 2, type 4 (root port) + let mut source = "cfg".to_string(); + let found = pcie_type_from(&pcie_config(config.clone()), &mut source); + assert_eq!((found.state, found.value.as_str()), (ReadState::Available, "4")); + assert!(source.ends_with("offset 0x52")); + + let mut loop_config = config.clone(); + loop_config[0x51] = 0x40; // 0x50 -> 0x40 -> 0x50 ... + loop_config[0x50] = 0x01; + loop_config[0x41] = 0x50; + assert_eq!(pcie_type_from(&pcie_config(loop_config), &mut source).error, libc::EPROTO, "cycle rejected"); + + let mut no_caps = config.clone(); + no_caps[0x06] = 0; + assert_eq!(pcie_type_from(&pcie_config(no_caps), &mut source).error, libc::ENODATA); + let mut gone = config.clone(); + gone[0x06] = 0xff; + gone[0x07] = 0xff; + assert_eq!(pcie_type_from(&pcie_config(gone), &mut source).state, ReadState::Removed); + let mut misaligned = config; + misaligned[0x34] = 0x42; + assert_eq!(pcie_type_from(&pcie_config(misaligned), &mut source).error, libc::EPROTO); + } + + #[test] + fn battery_values_and_absence() { + let tmp = tempfile::tempdir().unwrap(); + for (k, v) in [("type", "Battery"), ("present", "1"), ("manufacturer", "ACME"), ("capacity", "87\n"), ("status", "Charging")] { + write(tmp.path(), k, v); + } + let dir = Dir::open(tmp.path()).unwrap(); + let mut result = fixture_properties(Device { subsystem: "power_supply".into(), ..Default::default() }); + for key in ["type", "present", "manufacturer"] { + sysfs(&mut result, &dir, key); + } + battery_properties(&mut result, &dir, None); + let battery = &result.battery; + assert!(battery.applicable && battery.state.state == ReadState::Available); + assert_eq!(battery.values["capacity"].value, "87"); + assert_eq!(battery.values["voltage_now"].state, ReadState::Unavailable, "missing file is unavailable, not zero"); + + write(tmp.path(), "present", "0"); + let mut absent = fixture_properties(Device { subsystem: "power_supply".into(), ..Default::default() }); + for key in ["type", "present"] { + sysfs(&mut absent, &dir, key); + } + battery_properties(&mut absent, &dir, None); + assert_eq!(absent.battery.values["capacity"].state, ReadState::NotApplicable); + + write(tmp.path(), "present", "maybe"); + let mut invalid = fixture_properties(Device { subsystem: "power_supply".into(), ..Default::default() }); + for key in ["type", "present"] { + sysfs(&mut invalid, &dir, key); + } + battery_properties(&mut invalid, &dir, None); + assert_eq!(invalid.battery.state.error, libc::EINVAL); + + write(tmp.path(), "type", "Mains"); + let mut mains = fixture_properties(Device { subsystem: "power_supply".into(), ..Default::default() }); + sysfs(&mut mains, &dir, "type"); + battery_properties(&mut mains, &dir, None); + assert!(!mains.battery.applicable, "only batteries get battery values"); + } + + #[test] + fn usb_interfaces_are_direct_children_and_validated() { + let tmp = tempfile::tempdir().unwrap(); + let device_dir = tmp.path().join("1-2"); + fs::create_dir(&device_dir).unwrap(); + for (k, v) in [("bConfigurationValue", "1"), ("bNumInterfaces", "1"), ("bNumConfigurations", "1")] { + write(&device_dir, k, v); + } + let iface = device_dir.join("1-2:1.0"); + fs::create_dir(&iface).unwrap(); + for (k, v) in [("bInterfaceNumber", "00"), ("bAlternateSetting", " 0"), ("bInterfaceClass", "03")] { + write(&iface, k, v); + } + symlink("../../../drivers/usbhid", iface.join("driver")).unwrap(); + fs::create_dir(device_dir.join("1-2.3")).unwrap(); // a child device behind a hub + let device = Device { + path: device_dir.to_string_lossy().into(), + sysname: "1-2".into(), + subsystem: "usb".into(), + devtype: "usb_device".into(), + ..Default::default() + }; + let dir = Dir::open(&device_dir).unwrap(); + let mut result = fixture_properties(device.clone()); + usb_properties(&mut result, &dir, None); + assert_eq!(result.usb.interface_state.state, ReadState::Available); + assert_eq!(result.usb.interfaces.len(), 1, "descendant devices are not interfaces"); + let values = &result.usb.interfaces[0].values; + assert_eq!((values["driver"].value.as_str(), values["bInterfaceClass"].value.as_str()), ("usbhid", "03")); + + write(&device_dir, "bNumInterfaces", "2"); + let mut mismatch = fixture_properties(device.clone()); + usb_properties(&mut mismatch, &dir, None); + assert!(mismatch.usb.interfaces.is_empty()); + assert_eq!(mismatch.usb.interface_state.error, libc::EAGAIN, "count disagrees with the listing"); + + write(&device_dir, "bConfigurationValue", "0"); + let mut unconfigured = fixture_properties(device); + usb_properties(&mut unconfigured, &dir, None); + assert_eq!(unconfigured.usb.interface_state.state, ReadState::NotApplicable); + assert_eq!(unconfigured.usb.values["bMaxPower"].state, ReadState::NotApplicable); + } + + #[test] + fn efi_variable_bytes_are_read_with_identity_and_permission_checks() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("Var-8be4df61-93ca-11d2-aa0d-00e098032b8c"); + fs::write(&path, [7u8, 0, 0, 0, 1]).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + let info = fs::symlink_metadata(&path).unwrap(); + let device = Device { + path: path.to_string_lossy().into(), + subsystem: "efivarfs".into(), + incarnation: format!("{}:{}", info.dev(), info.ino()), + ..Default::default() + }; + let found = collect_device_properties(&device, true, None); + assert_eq!(found.state, ReadState::Available); + assert_eq!(found.efi_bytes, [7, 0, 0, 0, 1]); + assert!(!found.efi_truncated); + + let mut other = device.clone(); + other.incarnation = "1:2".into(); + assert_eq!(collect_device_properties(&other, true, None).state, ReadState::Removed); + + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let private = collect_device_properties(&device, true, None); + assert_eq!((private.state, private.error), (ReadState::PermissionDenied, libc::EACCES)); + fs::remove_file(&path).unwrap(); + assert_eq!(collect_device_properties(&device, true, None).state, ReadState::Removed); + } + + #[test] + fn real_pci_and_net_devices_collect_without_error() { + let devices = crate::scan().expect("scan"); + let pci = devices.iter().find(|d| d.subsystem == "pci").expect("a pci device"); + let found = collect_device_properties(pci, true, None); + assert_eq!(found.state, ReadState::Available, "{:?}", found.error); + assert_eq!(found.values["sysfs/vendor"].state, ReadState::Available); + assert!(found.values.contains_key("driver/path") && found.values.contains_key("driver/name")); + assert!(found.values.keys().any(|k| k.starts_with("udev/"))); + if let Some(bound) = devices.iter().find(|d| d.subsystem == "pci" && !d.driver.is_empty()) { + let found = collect_device_properties(bound, true, None); + assert_eq!(found.values["driver/name"].value, bound.driver); + assert!(found.values.contains_key("module/name") && found.values.contains_key("driver/bus")); + } + let cancelled = AtomicBool::new(true); + let stopped = collect_device_properties(pci, true, Some(&cancelled)); + assert_eq!((stopped.state, stopped.error), (ReadState::Error, libc::ECANCELED)); + } + + #[test] + fn net_interfaces_read_their_own_directory_and_reject_changed_indexes() { + let tmp = tempfile::tempdir().unwrap(); + let lo = std::ffi::CString::new("lo").unwrap(); + // SAFETY: valid NUL-terminated name. + let index = unsafe { libc::if_nametoindex(lo.as_ptr()) }; + for (k, v) in [ + ("ifindex", index.to_string()), + ("type", "772".into()), + ("address", "00:00:00:00:00:00".into()), + ("addr_assign_type", "0".into()), + ("mtu", "65536".into()), + ] { + write(tmp.path(), k, &v); + } + let dir = Dir::open(tmp.path()).unwrap(); + let found = network_interface(&dir, "/sys/devices/virtual/net/lo", "lo", true, None); + assert_eq!((found.name.as_str(), found.mtu.value.as_str()), ("lo", "65536")); + assert_eq!(found.link_type.value, "772"); + assert_eq!(found.permanent_address.state, ReadState::NotApplicable, "not an Ethernet-style link"); + assert!(found.addresses.iter().any(|a| a.address == "127.0.0.1")); + assert!(!found.ethernet.applicable, "ethtool queries are Ethernet-only"); + + write(tmp.path(), "ifindex", "0"); + let invalid = network_interface(&dir, "p", "lo", true, None); + assert_eq!(invalid.address_state.error, libc::EINVAL); + fs::remove_file(tmp.path().join("ifindex")).unwrap(); + let missing = network_interface(&dir, "p", "lo", true, None); + assert_eq!(missing.address_state.state, ReadState::Unavailable); + write(tmp.path(), "ifindex", &(index + 500).to_string()); + let moved = network_interface(&dir, "p", "lo", true, None); + assert_eq!(moved.address_state.state, ReadState::Removed, "name no longer maps to that index"); + assert_eq!(moved.mtu.state, ReadState::Removed); + } + + #[test] + fn real_net_and_network_pci_devices_collect_interfaces() { + let devices = crate::scan().expect("scan"); + let lo = devices.iter().find(|d| d.subsystem == "net" && d.sysname == "lo").expect("loopback"); + let found = collect_device_properties(lo, true, None); + assert_eq!(found.network_state.state, ReadState::Available); + assert_eq!(found.network.len(), 1); + assert!(found.network[0].addresses.iter().any(|a| a.address == "127.0.0.1")); + // A PCI network function lists its direct interface(s) when it has any. + if let Some(pci) = devices.iter().find(|d| d.subsystem == "pci" && d.category == "network") { + let found = collect_device_properties(pci, false, None); + assert!(matches!(found.network_state.state, ReadState::Available | ReadState::Unavailable), "{:?}", found.network_state); + assert!(found.network.iter().all(|n| !n.name.is_empty())); + } + } +} diff --git a/crates/qldm-core/src/propertytext.rs b/crates/qldm-core/src/propertytext.rs new file mode 100644 index 0000000..b6d3968 --- /dev/null +++ b/crates/qldm-core/src/propertytext.rs @@ -0,0 +1,721 @@ +//! Display text for property reads, ported from `src/devices/propertytext.cpp`. +use crate::i18n::{arg, arg2, format_data_size, format_fixed, format_integer, format_local_iso, tr}; +use crate::label::device_display_name; +use crate::model::{category_label, Attribute, Device, ReadState}; +use crate::properties::DeviceProperties; +use crate::storage::{Format, StorageField}; + +/// Text for one attribute read: its value, or a plain-language reason it has none. +/// errno stays visible so copied text keeps the exact failure. +pub fn property_read_value(a: &Attribute) -> String { + match a.state { + ReadState::Available if a.value.is_empty() => tr("Empty value"), + ReadState::Available => a.value.clone(), + ReadState::Unavailable => tr("Unavailable"), + ReadState::PermissionDenied => tr("Permission denied"), + ReadState::Removed => tr("Device removed"), + ReadState::Unsupported => tr("Unsupported"), + ReadState::NotApplicable => tr("Not applicable"), + ReadState::Error => { + let text = match a.error { + libc::ETIMEDOUT => "Query timed out (errno %1)", + libc::EOVERFLOW => "Data exceeds supported limits (errno %1)", + libc::EAGAIN => "Temporarily unavailable; retry the query (errno %1)", + libc::EINVAL | libc::EPROTO => "Invalid data or query (errno %1)", + libc::ECANCELED => "Query cancelled (errno %1)", + _ => "Read error (errno %1)", + }; + arg(tr(text), &a.error.to_string()) + } + } +} + +/// One row of the generic property table. `tab` selects the dialog page: 0 General, 1 Driver, +/// 2 Details, 3 Storage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PropertyEntry { + pub id: String, + pub label: String, + pub value: String, + pub source: String, + pub advanced: bool, + pub tab: i32, + pub state: ReadState, + pub error: i32, +} + +impl PropertyEntry { + fn source(&mut self, source: impl Into) -> &mut Self { + self.source = source.into(); + self + } + fn advanced(&mut self) -> &mut Self { + self.advanced = true; + self + } + fn tab(&mut self, tab: i32) -> &mut Self { + self.tab = tab; + self + } + fn state(&mut self, state: ReadState, error: i32) -> &mut Self { + self.state = state; + self.error = error; + self + } +} + +#[derive(Default)] +struct Entries(Vec); + +impl Entries { + fn add(&mut self, id: &str, label: String, value: String) -> &mut PropertyEntry { + self.0.push(PropertyEntry { id: id.into(), label, value, source: String::new(), advanced: false, tab: 2, state: ReadState::Available, error: 0 }); + self.0.last_mut().expect("just pushed") + } +} + +/// Status wording for a row whose value could not be read, lower-cased for inline use. +pub fn details_property_status(entry: &PropertyEntry) -> String { + if entry.state == ReadState::Available { + return if entry.value.is_empty() { tr("Empty value").to_lowercase() } else { String::new() }; + } + property_read_value(&Attribute { state: entry.state, value: String::new(), error: entry.error }).to_lowercase() +} + +/// Display text for one storage field, including friendly explanations for known states. +pub fn storage_field_value(field: &StorageField) -> String { + let value = &field.value; + if field.format == Format::HealthQueryStatus && value.state == ReadState::Unavailable && value.error == libc::ETIMEDOUT { + return tr("Not queried: shared health-query time limit reached"); + } + if value.state == ReadState::Unsupported { + match field.id.as_str() { + "service" => return tr("UDisks2 support is not enabled in this build"), + "health" => return tr("Drive health is not exposed through a supported UDisks2 interface"), + "org.freedesktop.UDisks2.Drive.Ata/SmartGetAttributes" | "org.freedesktop.UDisks2.NVMe.Controller/SmartGetAttributes" => { + return tr("Detailed health query is not supported by UDisks2"); + } + _ => {} + } + } + if value.state != ReadState::Available { + return property_read_value(value); + } + let number = || value.value.parse::().ok(); + match field.format { + Format::HealthQueryStatus | Format::Text => { + if field.id == "service" && value.value == "cached" { + return tr("Available; cached properties only"); + } + if field.id == "mountinfo" && value.value == "application-namespace" { + return tr("Application mount namespace only; other namespaces may differ. No filesystem is mounted or unlocked."); + } + if field.id == "mounts" && value.value == "no-observed-mount" { + return tr("No mount observed in the application namespace"); + } + property_read_value(value) + } + Format::Bytes => match number() { + None => tr("Invalid capacity or sector-size metadata"), + Some(bytes) if bytes > i64::MAX as u64 => arg(tr("%1 bytes"), &format_integer(bytes)), + Some(bytes) => arg2(tr("%1 bytes (%2)"), &format_integer(bytes), &format_data_size(bytes)), + }, + Format::Boolean => match value.value.as_str() { + "1" => tr("Yes"), + "0" => tr("No"), + _ => tr("Invalid boolean metadata"), + }, + Format::Removable => match value.value.as_str() { + "1" => tr("Removable (UDisks2 hint)"), + "0" => tr("Fixed (UDisks2 hint)"), + _ => tr("Invalid removable metadata"), + }, + Format::Epoch => match number().filter(|s| *s != 0 && *s <= i64::MAX as u64).and_then(|s| format_local_iso(s as i64)) { + Some(text) => text, + None => tr("Unavailable"), + }, + Format::Kelvin => match value.value.parse::().ok().filter(|k| *k > 0.0) { + Some(kelvin) => { + let precision = if field.id.starts_with("org.freedesktop.UDisks2.NVMe.Controller/") { 0 } else { 1 }; + arg(tr("%1 °C"), &format_fixed(kelvin - 273.15, precision)) + } + None => tr("Unavailable"), + }, + Format::Percent | Format::Hours | Format::Seconds | Format::Minutes | Format::Count => { + let Some(n) = number() else { return tr("Invalid health metadata") }; + let text = format_integer(n); + match field.format { + Format::Percent => arg(tr("%1%"), &text), + Format::Seconds => arg2(tr("%1 seconds (%2 hours)"), &text, &format_fixed(n as f64 / 3600.0, 1)), + Format::Hours => arg(tr("%1 hours"), &text), + Format::Minutes => arg(tr("%1 minutes"), &text), + _ => text, + } + } + Format::AtaHealth => match value.value.as_str() { + "1" => tr("Failure predicted by drive"), + "0" => tr("No failure predicted by drive (not a guarantee of health)"), + _ => tr("Unavailable"), + }, + Format::NvmeHealth => { + if value.value.is_empty() { + return tr("No critical warnings reported (not a guarantee of health)"); + } + value + .value + .split('\n') + .filter(|w| !w.is_empty()) + .map(|warning| match warning { + "spare" => tr("Available spare is below its threshold"), + "temperature" => tr("Temperature is outside a permitted threshold"), + "degraded" => tr("NVM subsystem reliability is degraded"), + "readonly" => tr("Media is in read-only mode"), + "volatile_mem" => tr("Volatile-memory backup has failed"), + "pmr_readonly" => tr("Persistent memory region is read-only or unreliable"), + other => arg(tr("Unrecognized warning: %1"), other), + }) + .collect::>() + .join("\n") + } + } +} + +fn hex_dump(bytes: &[u8]) -> String { + bytes + .chunks(16) + .enumerate() + .map(|(row, chunk)| { + let hex = chunk.iter().map(|b| format!("{b:02x}")).collect::>().join(" "); + let printable: String = chunk.iter().map(|&b| if (32..=126).contains(&b) { b as char } else { '.' }).collect(); + format!("{:08x} {hex:<47} {printable}", row * 16) + }) + .collect::>() + .join("\n") +} + +fn attr_or(snapshot: &DeviceProperties, id: &str, fallback: &Attribute) -> Attribute { + snapshot.values.get(id).unwrap_or(fallback).clone() +} + +/// Builds the General, Driver and Details rows for a snapshot, keeping provenance beside +/// each value. `removed` marks a snapshot of a device that is no longer present. Storage rows +/// are added by the storage module. +pub fn property_entries(snapshot: &DeviceProperties, removed: bool) -> Vec { + let mut entries = Entries::default(); + let d = &snapshot.device; + let efi = d.subsystem == "efivarfs"; + let not_applicable = Attribute { state: ReadState::NotApplicable, ..Default::default() }; + let raw = |id: &str| snapshot.values.get(id).cloned().unwrap_or_default(); + let source_of = |id: &str| snapshot.sources.get(id).cloned().unwrap_or_default(); + let add_raw = |entries: &mut Entries, id: &str, label: String, tab: i32, fallback: &Attribute| { + let a = attr_or(snapshot, id, fallback); + entries.add(id, label, property_read_value(&a)).source(source_of(id)).tab(tab).state(a.state, a.error); + }; + + if snapshot.storage.applicable { + entries + .add( + "storage/scope", + tr("Storage snapshot"), + tr("Each entity is a separate storage layer; capacities are not added. Health is cached UDisks2 evidence, separate from operational status. Reload never requests SMART updates or self-tests. Deep search includes native metadata only."), + ) + .tab(3); + for note in &snapshot.storage.notes { + entries.add(&format!("storage/note/{}", note.id), tr(note.label), storage_field_value(note)).source(note.source.clone()).tab(3); + } + for entity in &snapshot.storage.entities { + let prefix = format!("storage/entity/{}", entity.id); + entries.add(&prefix, tr("Storage entity"), entity.id.clone()).tab(3); + for field in &entity.fields { + entries.add(&format!("{prefix}/{}", field.id), tr(field.label), storage_field_value(field)).source(field.source.clone()).tab(3); + } + } + for (index, edge) in snapshot.storage.links.iter().enumerate() { + entries.add(&format!("storage/link/{index}"), tr(edge.label), format!("{}\n→ {}", edge.from, edge.to)).source(edge.source.clone()).tab(3); + } + } + entries.add("name", tr("Device name"), device_display_name(d, false)).source(d.name_source.clone()).tab(0); + entries.add("category", tr("Category"), category_label(d.category)).source(tr("Application grouping")).tab(0); + + let mut manufacturer = Attribute::default(); + let mut manufacturer_source = String::new(); + if efi { + manufacturer = not_applicable.clone(); + } else { + for id in ["sysfs/manufacturer", "sysfs/vendor_name", "udev/ID_VENDOR_FROM_DATABASE", "udev/ID_VENDOR"] { + let candidate = raw(id); + let usable = (candidate.state == ReadState::Available && !candidate.value.is_empty()) + || matches!(candidate.state, ReadState::PermissionDenied | ReadState::Error); + if usable { + manufacturer = candidate; + manufacturer_source = source_of(id); + break; + } + } + if manufacturer.state == ReadState::Unavailable { + for key in ["ID_VENDOR_FROM_DATABASE", "ID_VENDOR"] { + let Some(value) = d.properties.get(key).filter(|v| !v.is_empty()) else { continue }; + manufacturer = Attribute { state: ReadState::Available, value: value.clone(), error: 0 }; + manufacturer_source = d.property_sources.get(key).cloned().unwrap_or_else(|| format!("udev: {key} ({})", d.path)); + break; + } + } + } + entries + .add("manufacturer", tr("Manufacturer"), property_read_value(&manufacturer)) + .source(manufacturer_source) + .tab(0) + .state(manufacturer.state, manufacturer.error); + + let mut bus = raw("udev/ID_BUS"); + if bus.state != ReadState::Available { + bus = raw("driver/bus"); + } + if efi { + bus = not_applicable.clone(); + } + let bus_value = if efi { + property_read_value(&bus) + } else if bus.state == ReadState::Available { + format!("{} / {}", bus.value, d.subsystem) + } else { + d.subsystem.clone() + }; + entries + .add("bus", tr("Bus / subsystem"), bus_value) + .source(tr("Device udev metadata and direct driver bus")) + .tab(0) + .state(if efi { ReadState::NotApplicable } else { ReadState::Available }, 0); + entries.add("location", tr("Location"), if efi { d.parent_path.clone() } else { d.path.clone() }).source(tr("Kernel path")).tab(0); + + let status = if removed { + tr("Removed or replaced; showing a snapshot.") + } else if snapshot.state != ReadState::Available { + tr("Present in the last inventory; properties have not been verified.") + } else { + let mut status = tr("Present at the last property read."); + if !efi { + let binding = raw("driver/name"); + status.push('\n'); + status += &if binding.state == ReadState::Available { + if binding.value.is_empty() { + tr("No directly bound driver; this alone is not a fault.") + } else { + arg(tr("Driver bound: %1"), &binding.value) + } + } else { + arg(tr("Driver binding: %1"), &property_read_value(&binding)) + }; + let authorization = raw("sysfs/authorized"); + match (authorization.state, authorization.value.as_str()) { + (ReadState::Available, "0") => status += &format!("\n{}", tr("USB device/interface is unauthorized.")), + (ReadState::Available, "1") => status += &format!("\n{}", tr("USB device/interface is authorized.")), + (ReadState::PermissionDenied | ReadState::Error, _) => { + status += &format!("\n{}", arg(tr("USB authorization: %1"), &property_read_value(&authorization))); + } + _ => {} + } + } + status + }; + entries.add("status", tr("Device status"), status).source(tr("Observed presence, binding and USB authorization only")).tab(0); + + let mut binding = snapshot.values.get("driver/name").cloned().unwrap_or_else(|| { + if d.driver.is_empty() { + Attribute::default() + } else { + Attribute { state: ReadState::Available, value: d.driver.clone(), error: 0 } + } + }); + if efi { + binding = not_applicable.clone(); + } + let driver_name = + if !efi && binding.state == ReadState::Available && binding.value.is_empty() { tr("No directly bound driver") } else { property_read_value(&binding) }; + let driver_source = if efi { String::new() } else { format!("{}/driver", d.path) }; + entries.add("driver/name", tr("Bound kernel driver"), driver_name.clone()).source(driver_source.clone()).tab(0).state(binding.state, binding.error); + + let mut identifiers: Vec = Vec::new(); + for key in ["PCI_ID", "PCI_SUBSYS_ID", "PRODUCT", "ID_VENDOR_ID", "ID_MODEL_ID", "ID_SERIAL_SHORT"] { + let a = snapshot.values.get(&format!("udev/{key}")).cloned().unwrap_or_else(|| { + d.properties.get(key).map_or_else(Attribute::default, |v| Attribute { state: ReadState::Available, value: v.clone(), error: 0 }) + }); + if a.state == ReadState::Available && !a.value.is_empty() { + identifiers.push(format!("{key}: {}", a.value)); + } + } + for key in ["vendor", "device", "idVendor", "idProduct", "serial", "hid", "uid"] { + let a = raw(&format!("sysfs/{key}")); + if a.state != ReadState::Unavailable && !(a.state == ReadState::Available && a.value.is_empty()) { + identifiers.push(format!("{key}: {}", property_read_value(&a))); + } + } + let identifiers_value = if efi { + property_read_value(¬_applicable) + } else if identifiers.is_empty() { + tr("Unavailable") + } else { + identifiers.join("\n") + }; + entries.add("identifiers", tr("Hardware identifiers"), identifiers_value).source(tr("Selected udev and direct sysfs identifiers")).tab(0).state( + if efi { + ReadState::NotApplicable + } else if identifiers.is_empty() { + ReadState::Unavailable + } else { + ReadState::Available + }, + 0, + ); + + entries.add("driver/binding", tr("Bound kernel driver"), driver_name).source(driver_source).tab(1).state(binding.state, binding.error); + let module_fallback = + if efi || (binding.state == ReadState::Available && binding.value.is_empty()) { not_applicable.clone() } else { Attribute::default() }; + add_raw(&mut entries, "driver/bus", tr("Driver bus"), 1, &module_fallback); + add_raw(&mut entries, "module/name", tr("Owning kernel module"), 1, &module_fallback); + let kind = attr_or(snapshot, "module/type", &module_fallback); + let reportable = matches!(kind.state, ReadState::NotApplicable | ReadState::PermissionDenied | ReadState::Error); + entries + .add( + "module/type", + tr("Built-in or modular"), + if kind.state == ReadState::Available { + tr("Modular (kernel initstate evidence)") + } else if reportable { + property_read_value(&kind) + } else { + tr("Undetermined; a missing module link/version does not prove a built-in driver.") + }, + ) + .source(source_of("module/type")) + .tab(1) + .state(if reportable { kind.state } else { ReadState::Available }, kind.error); + add_raw(&mut entries, "module/runtime_version", tr("Running module version"), 1, &module_fallback); + let labels = [ + tr("Installed module filename"), + tr("Installed module version"), + tr("Installed module description"), + tr("Installed module author"), + tr("Installed module license"), + tr("Declared firmware names"), + ]; + for (field, label) in ["filename", "version", "description", "author", "license", "firmware"].into_iter().zip(labels) { + add_raw(&mut entries, &format!("module/{field}"), label, 1, &module_fallback); + } + let firmware_fallback = if d.subsystem == "nvme" { Attribute::default() } else { not_applicable.clone() }; + add_raw(&mut entries, "sysfs/firmware_rev", tr("Reported device firmware revision"), 1, &firmware_fallback); + entries + .add( + "module/note", + tr("Metadata scope"), + if efi { + property_read_value(¬_applicable) + } else { + tr("Installed metadata may differ from code already loaded before an update. Declared firmware names do not prove firmware is loaded or report its version.") + }, + ) + .tab(1) + .state(if efi { ReadState::NotApplicable } else { ReadState::Available }, 0); + + // Interpreted facts plus curated raw values, keeping provenance beside each. + let parent_known = !d.parent_path.is_empty(); + let parent = entries.add("parent", tr("Parent device path"), if parent_known { d.parent_path.clone() } else { tr("Unavailable") }); + parent.source(tr("Recorded ancestry")).state(if parent_known { ReadState::Available } else { ReadState::Unavailable }, 0); + if efi { + parent.advanced(); + } + entries.add("raw_name", tr("Original name"), d.name.clone()).source(d.name_source.clone()).advanced(); + entries.add("name_source", tr("Name source"), d.name_source.clone()).advanced(); + entries.add("sysname", tr("Kernel name"), d.sysname.clone()).advanced(); + entries.add("path", tr("Full kernel path"), d.path.clone()).advanced(); + if !efi { + let a = raw("driver/path"); + entries.add("driver/path", tr("Direct driver path"), property_read_value(&a)).source(source_of("driver/path")).advanced().state(a.state, a.error); + } + let mut keys: Vec<&String> = snapshot.values.keys().collect(); + keys.sort(); + for key in keys { + if !(key.starts_with("udev/") || key.starts_with("sysfs/")) || key == "sysfs/firmware_rev" { + continue; // firmware_rev is already exposed on the Driver tab. + } + let a = raw(key); + entries.add(key, key.clone(), property_read_value(&a)).source(source_of(key)).advanced().state(a.state, a.error); + } + let mut keys: Vec<&String> = d.properties.keys().collect(); + keys.sort(); + for key in keys { + if snapshot.values.contains_key(&format!("udev/{key}")) { + continue; + } + entries + .add(&format!("inventory/{key}"), arg(tr("Inventory: %1"), key), d.properties[key].clone()) + .source(d.property_sources.get(key).cloned().unwrap_or_else(|| "inventory udev metadata".into())) + .advanced(); + } + let mut keys: Vec<&String> = d.attributes.keys().collect(); + keys.sort(); + for key in keys { + let a = &d.attributes[key]; + entries + .add(&format!("inventory/sysfs/{key}"), arg(tr("Inventory attribute: %1"), key), property_read_value(a)) + .source(tr("Last inventory; see name source for derived naming metadata")) + .advanced() + .state(a.state, a.error); + } + if efi { + let hex = raw("efi/hex"); + let mut dump = property_read_value(&hex); + if hex.state == ReadState::Available { + dump = if snapshot.efi_bytes.is_empty() { tr("Empty file") } else { hex_dump(&snapshot.efi_bytes) }; + if snapshot.efi_truncated { + dump += &format!("\n{}", tr("Truncated: first 65536 bytes shown.")); + } + } + entries + .add("efi/hex", tr("UEFI variable — hex dump"), dump) + .source(tr("Complete efivarfs file bytes, including the attribute prefix; contents are not decoded.")) + .state(hex.state, hex.error); + } + entries.0 +} + +/// Storage identity and content belong to the storage tabs even in advanced mode; generic +/// device/bus/driver facts remain available for all device classes. +pub fn is_details_property(entry: &PropertyEntry, device: &Device) -> bool { + if entry.tab == 3 || entry.id.starts_with("storage/") { + return false; + } + if device.subsystem != "block" && device.subsystem != "nvme" { + return true; + } + let mut key = entry.id.as_str(); + for prefix in ["inventory/sysfs/", "inventory/", "udev/", "sysfs/"] { + if let Some(rest) = key.strip_prefix(prefix) { + key = rest; + break; + } + } + const HIDDEN_PREFIXES: [&str; 12] = ["ID_FS_", "ID_PART_", "ID_ATA", "ID_NVME", "ID_SCSI", "ID_DRIVE_", "ID_CDROM", "DM_", "MD_", "queue/", "dm/", "md/"]; + if HIDDEN_PREFIXES.iter().any(|p| key.starts_with(p)) { + return false; + } + const IDENTITY_KEYS: [&str; 24] = [ + "ID_MODEL", + "ID_MODEL_ENC", + "ID_VENDOR", + "ID_VENDOR_ENC", + "ID_SERIAL", + "ID_SERIAL_SHORT", + "ID_REVISION", + "ID_BUS", + "ID_WWN", + "ID_WWN_WITH_EXTENSION", + "DEVNAME", + "DISKSEQ", + "size", + "dev", + "partition", + "start", + "ro", + "removable", + "model", + "vendor", + "serial", + "rev", + "firmware_rev", + "wwid", + ]; + !IDENTITY_KEYS.contains(&key) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(state: ReadState, error: i32) -> Attribute { + Attribute { state, value: String::new(), error } + } + + #[test] + fn states_have_plain_language_text() { + assert_eq!(property_read_value(&read(ReadState::Available, 0)), "Empty value"); + assert_eq!(property_read_value(&Attribute { state: ReadState::Available, value: "x".into(), error: 0 }), "x"); + assert_eq!(property_read_value(&read(ReadState::PermissionDenied, 13)), "Permission denied"); + assert_eq!(property_read_value(&read(ReadState::Removed, 19)), "Device removed"); + } + + #[test] + fn errors_keep_errno_and_classify_common_causes() { + let text = |e| property_read_value(&read(ReadState::Error, e)); + assert_eq!(text(libc::ETIMEDOUT), "Query timed out (errno 110)"); + assert_eq!(text(libc::EOVERFLOW), "Data exceeds supported limits (errno 75)"); + assert_eq!(text(libc::EAGAIN), "Temporarily unavailable; retry the query (errno 11)"); + assert_eq!(text(libc::EPROTO), "Invalid data or query (errno 71)"); + assert_eq!(text(libc::ECANCELED), "Query cancelled (errno 125)"); + assert_eq!(text(libc::EIO), "Read error (errno 5)"); + } +} + +#[cfg(test)] +mod entry_tests { + use super::*; + use crate::model::Attribute; + + fn available(value: &str) -> Attribute { + Attribute { state: ReadState::Available, value: value.into(), error: 0 } + } + + fn snapshot() -> DeviceProperties { + let mut device = Device { + path: "/sys/devices/pci0000:00/0000:00:14.3".into(), + parent_path: "/sys/devices/pci0000:00".into(), + subsystem: "pci".into(), + sysname: "0000:00:14.3".into(), + name: "Wi-Fi 7".into(), + name_source: "hwdb: ID_MODEL_FROM_DATABASE (pci:x)".into(), + category: "network", + driver: "iwlwifi".into(), + ..Default::default() + }; + device.properties.insert("ID_VENDOR_FROM_DATABASE".into(), "Intel Corporation".into()); + device.properties.insert("PCI_ID".into(), "8086:7740".into()); + let mut snapshot = DeviceProperties { device, state: ReadState::Available, ..Default::default() }; + for (id, value) in [("driver/name", "iwlwifi"), ("driver/bus", "pci"), ("sysfs/vendor", "0x8086"), ("udev/PCI_ID", "8086:7740")] { + snapshot.values.insert(id.into(), available(value)); + } + snapshot + } + + fn find<'a>(entries: &'a [PropertyEntry], id: &str) -> &'a PropertyEntry { + entries.iter().find(|e| e.id == id).unwrap_or_else(|| panic!("missing entry {id}")) + } + + #[test] + fn general_and_driver_rows_report_evidence() { + let entries = property_entries(&snapshot(), false); + assert_eq!(find(&entries, "name").value, "Wi-Fi 7 [0000:00:14.3]"); + assert_eq!(find(&entries, "category").value, "Network adapters"); + assert_eq!(find(&entries, "manufacturer").value, "Intel Corporation", "falls back to inventory udev metadata"); + assert_eq!(find(&entries, "bus").value, "pci / pci"); + assert_eq!(find(&entries, "identifiers").value, "PCI_ID: 8086:7740\nvendor: 0x8086"); + assert_eq!(find(&entries, "status").value, "Present at the last property read.\nDriver bound: iwlwifi"); + assert_eq!(find(&entries, "driver/binding").tab, 1); + assert_eq!(find(&entries, "module/type").value, "Undetermined; a missing module link/version does not prove a built-in driver."); + assert!(find(&entries, "sysfs/vendor").advanced && find(&entries, "inventory/ID_VENDOR_FROM_DATABASE").advanced); + assert!(entries.iter().all(|e| e.id != "udev/PCI_ID" || e.advanced)); + } + + #[test] + fn removed_and_unverified_snapshots_say_so() { + assert_eq!(find(&property_entries(&snapshot(), true), "status").value, "Removed or replaced; showing a snapshot."); + let mut unverified = snapshot(); + unverified.state = ReadState::Error; + assert_eq!(find(&property_entries(&unverified, false), "status").value, "Present in the last inventory; properties have not been verified."); + } + + #[test] + fn unbound_driver_and_usb_authorization_are_worded_plainly() { + let mut s = snapshot(); + s.values.insert("driver/name".into(), available("")); + s.values.insert("sysfs/authorized".into(), available("0")); + let entries = property_entries(&s, false); + assert_eq!(find(&entries, "driver/name").value, "No directly bound driver"); + assert!(find(&entries, "status").value.ends_with("USB device/interface is unauthorized.")); + assert_eq!(find(&entries, "module/name").state, ReadState::NotApplicable, "no driver means no module question"); + } + + #[test] + fn efi_records_show_a_hex_dump_and_no_driver_facts() { + let mut s = DeviceProperties { + device: Device { + subsystem: "efivarfs".into(), + name: "SecureBoot".into(), + path: "/p".into(), + parent_path: "/d".into(), + category: "efi", + ..Default::default() + }, + state: ReadState::Available, + efi_bytes: (0u8..20).collect(), + efi_truncated: true, + ..Default::default() + }; + s.values.insert("efi/hex".into(), available("")); + let entries = property_entries(&s, false); + assert_eq!(find(&entries, "bus").state, ReadState::NotApplicable); + assert_eq!(find(&entries, "location").value, "/d"); + assert!(entries.iter().all(|e| e.id != "driver/path")); + let dump = &find(&entries, "efi/hex").value; + assert!(dump.starts_with("00000000 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f ................\n00000010 10 11 12 13")); + assert!(dump.ends_with("Truncated: first 65536 bytes shown.")); + } + + #[test] + fn block_devices_keep_storage_identity_out_of_details() { + let block = Device { subsystem: "block".into(), ..Default::default() }; + let entry = |id: &str| PropertyEntry { + id: id.into(), + label: String::new(), + value: String::new(), + source: String::new(), + advanced: true, + tab: 2, + state: ReadState::Available, + error: 0, + }; + assert!(!is_details_property(&entry("udev/ID_FS_TYPE"), &block)); + assert!(!is_details_property(&entry("sysfs/size"), &block)); + assert!(is_details_property(&entry("udev/PCI_ID"), &block)); + assert!(is_details_property(&entry("udev/ID_FS_TYPE"), &Device { subsystem: "pci".into(), ..Default::default() })); + assert!(!is_details_property(&PropertyEntry { tab: 3, ..entry("x") }, &Device::default())); + let status = |state, error| details_property_status(&PropertyEntry { state, error, ..entry("x") }); + assert_eq!(status(ReadState::PermissionDenied, 13), "permission denied"); + assert_eq!(status(ReadState::Available, 0), "empty value"); + } + + #[test] + fn storage_values_explain_themselves() { + use crate::storage::{Format, StorageField}; + let f = |id: &str, value: &str, format| StorageField { + id: id.into(), + label: "L", + value: Attribute { state: ReadState::Available, value: value.into(), error: 0 }, + source: String::new(), + format, + }; + assert_eq!(storage_field_value(&f("capacity", "1000204886016", Format::Bytes)), "1,000,204,886,016 bytes (931.51 GiB)"); + assert_eq!(storage_field_value(&f("x", "abc", Format::Bytes)), "Invalid capacity or sector-size metadata"); + assert_eq!(storage_field_value(&f("x", "1", Format::Boolean)), "Yes"); + assert_eq!(storage_field_value(&f("x", "2", Format::Boolean)), "Invalid boolean metadata"); + assert_eq!(storage_field_value(&f("x", "306", Format::Kelvin)), "32.9 °C"); + assert_eq!(storage_field_value(&f("org.freedesktop.UDisks2.NVMe.Controller/SmartTemperature", "306", Format::Kelvin)), "33 °C"); + assert_eq!(storage_field_value(&f("x", "0", Format::Kelvin)), "Unavailable"); + assert_eq!(storage_field_value(&f("x", "7200", Format::Seconds)), "7,200 seconds (2.0 hours)"); + assert_eq!(storage_field_value(&f("x", "87", Format::Percent)), "87%"); + assert_eq!(storage_field_value(&f("x", "0", Format::AtaHealth)), "No failure predicted by drive (not a guarantee of health)"); + assert_eq!(storage_field_value(&f("x", "", Format::NvmeHealth)), "No critical warnings reported (not a guarantee of health)"); + assert_eq!(storage_field_value(&f("mounts", "no-observed-mount", Format::Text)), "No mount observed in the application namespace"); + let unsupported = StorageField { value: Attribute { state: ReadState::Unsupported, ..Default::default() }, ..f("service", "", Format::Text) }; + assert_eq!(storage_field_value(&unsupported), "UDisks2 support is not enabled in this build"); + let skipped = StorageField { + value: Attribute { state: ReadState::Unavailable, error: libc::ETIMEDOUT, ..Default::default() }, + ..f("h", "", Format::HealthQueryStatus) + }; + assert_eq!(storage_field_value(&skipped), "Not queried: shared health-query time limit reached"); + } + + #[test] + fn storage_snapshots_add_a_scope_note_and_tab_three_rows() { + use crate::storage::{StorageEntity, StorageProperties}; + let mut s = snapshot(); + s.storage = StorageProperties { + applicable: true, + entities: vec![StorageEntity { id: "/sys/devices/x/sda".into(), ..Default::default() }], + ..Default::default() + }; + let entries = property_entries(&s, false); + assert_eq!(find(&entries, "storage/scope").tab, 3); + assert_eq!(find(&entries, "storage/entity//sys/devices/x/sda").label, "Storage entity"); + assert!(!is_details_property(find(&entries, "storage/scope"), &s.device)); + } +} diff --git a/crates/qldm-core/src/report.rs b/crates/qldm-core/src/report.rs new file mode 100644 index 0000000..656a50e --- /dev/null +++ b/crates/qldm-core/src/report.rs @@ -0,0 +1,1323 @@ +//! Text content of the specialised Properties tabs (Network, Camera, Display, USB, PCIe, +//! Battery), ported from the `rebuild*` methods of `src/ui/propertiesdialog.cpp` without any +//! toolkit types. Each builder returns grouped caption/value/source fields (the source is the +//! tooltip) plus the exact text the tab's Copy button places on the clipboard. +use crate::display::DisplayProperties; +use crate::i18n::{arg, arg2, format_g, format_integer, tr}; +use crate::model::{Attribute, Device, ReadState}; +use crate::properties::DeviceProperties; +use crate::propertytext::property_read_value; +use serde_json::Value; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReportField { + pub caption: String, + pub value: String, + pub source: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReportGroup { + pub title: String, + pub fields: Vec, +} + +/// A table of equal-width text rows (the camera's mode list). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReportTable { + pub title: String, + pub headers: Vec, + /// Display cells per row. + pub rows: Vec>, + /// Tooltip per row. + pub sources: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Report { + pub groups: Vec, + pub tables: Vec, + /// What the Copy button puts on the clipboard. + pub copy_text: String, +} + +#[derive(Default)] +struct Builder { + report: Report, + copy: Vec, +} + +impl Builder { + fn group(&mut self, title: String) { + if !self.copy.is_empty() { + self.copy.push(String::new()); + } + self.copy.push(title.clone()); + self.report.groups.push(ReportGroup { title, fields: Vec::new() }); + } + + fn field(&mut self, caption: impl Into, value: impl Into, source: impl Into) { + let (caption, value) = (caption.into(), value.into()); + self.copy.push(format!("{caption}: {value}")); + self.report.groups.last_mut().expect("a group was opened").fields.push(ReportField { caption, value, source: source.into() }); + } + + fn finish(mut self) -> Report { + self.report.copy_text = self.copy.join("\n"); + self.report + } +} + +fn read(value: &Attribute) -> String { + property_read_value(value) +} + +fn attribute(state: ReadState, error: i32) -> Attribute { + Attribute { state, value: String::new(), error } +} + +// --------------------------------------------------------------------------------------- +// Network +// --------------------------------------------------------------------------------------- + +pub fn network_applicable(device: &Device) -> bool { + device.subsystem == "net" || device.category == "network" +} + +fn ethernet_value(value: &Attribute) -> String { + match value.state { + ReadState::Unsupported => return tr("Query unsupported by the kernel/device"), + ReadState::Error => match value.error { + libc::EPROTO => return tr("Malformed ethtool reply"), + libc::ETIMEDOUT => return tr("Query timed out"), + libc::EOVERFLOW => return tr("Reply exceeds collection limits"), + libc::EAGAIN => return tr("Link changed during collection; reload properties"), + _ => {} + }, + _ => {} + } + read(value) +} + +fn speed_text(mbps: u64) -> String { + if mbps >= 1000 { + arg(tr("%1 Gbps"), &format_g(mbps as f64 / 1000.0, 6)) + } else { + arg(tr("%1 Mbps"), &format_integer(mbps)) + } +} + +pub fn network_report(snapshot: &DeviceProperties) -> Option { + let device = &snapshot.device; + if !network_applicable(device) { + return None; + } + let mut b = Builder::default(); + if snapshot.network.is_empty() { + b.group(tr("Network interfaces")); + let value = if snapshot.network_state.state == ReadState::Available { tr("No network interfaces observed") } else { read(&snapshot.network_state) }; + b.field(tr("Interfaces"), value, device.path.clone()); + } + for interface in &snapshot.network { + let ethernet = &interface.ethernet; + if ethernet.applicable { + b.group(arg(tr("%1 — Ethernet link"), &interface.name)); + let source = format!("{}\n{}", interface.path, ethernet.source); + let disconnected = ethernet.carrier.state == ReadState::Available && ethernet.carrier.value == "0"; + let link = if ethernet.carrier.state == ReadState::Available { + if disconnected { + tr("Disconnected") + } else { + tr("Connected") + } + } else { + ethernet_value(ðernet.carrier) + }; + b.field(tr("Link status"), link, source.clone()); + let operational = |value: &Attribute| { + if value.state == ReadState::NotApplicable && disconnected { + tr("Not applicable — link disconnected") + } else { + ethernet_value(value) + } + }; + let mut speed = operational(ðernet.speed); + if ethernet.speed.state == ReadState::Available { + speed = match ethernet.speed.value.parse::() { + Err(_) => tr("Malformed ethtool reply"), + Ok(0) | Ok(0xffff_ffff) => tr("Unavailable (driver reports unknown speed)"), + Ok(mbps) => speed_text(mbps), + }; + } + b.field( + tr("Link speed"), + speed, + format!("{source}\n{}", tr("Driver-reported operational speed, not measured throughput; it may be forced when autonegotiation is disabled.")), + ); + let mut duplex = operational(ðernet.duplex); + if ethernet.duplex.state == ReadState::Available { + duplex = match ethernet.duplex.value.as_str() { + "0" => tr("Half duplex"), + "1" => tr("Full duplex"), + "255" => tr("Unavailable (driver reports unknown duplex)"), + other => arg(tr("Unrecognized duplex value: %1"), other), + }; + } + b.field(tr("Duplex"), duplex, source.clone()); + let mut autoneg = ethernet_value(ðernet.autoneg); + if ethernet.autoneg.state == ReadState::Available { + autoneg = match ethernet.autoneg.value.as_str() { + "0" => tr("Disabled"), + "1" => tr("Enabled"), + other => arg(tr("Unrecognized autonegotiation value: %1"), other), + }; + } + b.field(tr("Autonegotiation"), autoneg, source.clone()); + b.field( + tr("Supported modes / capabilities"), + ethernet_value(ðernet.supported_modes).replace('\n', ", "), + format!("{source}\n{}", tr("Kernel-named supported-mask entries include link modes and media, pause or autonegotiation capabilities. Unnamed bits retain their numeric index.")), + ); + let mut partner = operational(ðernet.partner_modes); + if ethernet.partner_modes.state == ReadState::NotApplicable && !disconnected { + partner = tr("Not applicable — autonegotiation disabled"); + } else if ethernet.partner_modes.state == ReadState::Unavailable { + partner = tr("Unavailable — no partner advertisement reported"); + } + b.field(tr("Link-partner advertised modes"), partner.replace('\n', ", "), source.clone()); + } + b.group(arg(tr("%1 — Link"), &interface.name)); + let no_mac = interface.link_type.state == ReadState::Available && interface.link_type.value != "1"; + let current = if no_mac { attribute(ReadState::NotApplicable, 0) } else { interface.current_address.clone() }; + b.field(tr("Hardware MAC address"), read(&interface.permanent_address), tr("Driver permanent address: ETHTOOL_GPERMADDR")); + b.field(tr("Current MAC address"), read(¤t), format!("{}/address", interface.path)); + let override_value = if no_mac { + tr("Not applicable") + } else if current.state != ReadState::Available { + read(¤t) + } else if interface.permanent_address.state == ReadState::Available { + if current.value.eq_ignore_ascii_case(&interface.permanent_address.value) { + tr("None") + } else { + current.value.clone() + } + } else if interface.assignment_type.state == ReadState::Available && matches!(interface.assignment_type.value.as_str(), "1" | "2" | "3") { + current.value.clone() + } else { + tr("Unavailable; permanent MAC could not be determined") + }; + b.field(tr("Overridden MAC address"), override_value, format!("{0}/address; {0}/addr_assign_type; ETHTOOL_GPERMADDR", interface.path)); + b.field(tr("MTU"), read(&interface.mtu), format!("{}/mtu", interface.path)); + if !ethernet.applicable && ethernet.scope.state != ReadState::NotApplicable { + b.field(tr("Ethernet details"), ethernet_value(ðernet.scope), ethernet.source.clone()); + } + b.group(arg(tr("%1 — IP addresses"), &interface.name)); + let source = tr("getifaddrs: application network namespace"); + if interface.addresses.is_empty() { + let value = if interface.address_state.state == ReadState::Available { tr("None assigned") } else { read(&interface.address_state) }; + b.field(tr("IP addresses"), value, source.clone()); + } + for address in &interface.addresses { + b.field( + if address.family == "IPv4" { tr("IPv4 address") } else { tr("IPv6 address") }, + format!("{}/{}", address.address, read(&address.netmask)), + source.clone(), + ); + } + } + Some(b.finish()) +} + +// --------------------------------------------------------------------------------------- +// Camera +// --------------------------------------------------------------------------------------- + +fn camera_error_text(error: i32) -> String { + match error { + libc::EBUSY => return tr("Device busy"), + libc::ETIMEDOUT => return tr("Query timed out"), + libc::EAGAIN => return tr("Previous camera query has not exited; retry later"), + libc::EOVERFLOW => return tr("Enumeration or report limit reached"), + libc::EPROTO => return tr("Malformed driver response"), + _ => {} + } + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENODEV | libc::ENOENT | libc::ENXIO => ReadState::Removed, + libc::EINVAL | libc::ENOTTY | libc::EOPNOTSUPP => ReadState::Unsupported, + _ => ReadState::Error, + }; + read(&attribute(state, error)) +} + +/// `1/30` as text. +fn fraction_text(f: &Value) -> String { + format!("{}/{}", f[0].as_i64().unwrap_or(0), f[1].as_i64().unwrap_or(0)) +} + +/// Frames per second of an interval fraction. +fn fps(f: &Value) -> f64 { + f[1].as_i64().unwrap_or(0) as f64 / f[0].as_i64().unwrap_or(1) as f64 +} + +/// Four-character code of a V4L2 pixel format with unprintable bytes as `\xNN`. +pub fn fourcc(code: u32) -> String { + [0u32, 8, 16, 24] + .iter() + .map(|shift| { + let c = (code >> shift) & if *shift == 24 { 127 } else { 255 }; + if (32..=126).contains(&c) { + (c as u8 as char).to_string() + } else { + format!("\\x{c:02x}") + } + }) + .collect() +} + +pub fn camera_applicable(snapshot: &DeviceProperties) -> bool { + snapshot.camera.applicable +} + +pub fn camera_report(snapshot: &DeviceProperties) -> Option { + let data = &snapshot.camera; + if !data.applicable { + return None; + } + let mut b = Builder::default(); + let report = &data.report; + let empty = Vec::new(); + let modes = report.get("modes").and_then(Value::as_array).unwrap_or(&empty); + b.group(tr("Capture device")); + let query_source = format!("{}: VIDIOC_QUERYCAP via native ioctl passthrough", data.node); + b.field( + tr("Device node"), + if data.node.is_empty() { tr("Unavailable") } else { data.node.clone() }, + format!("{} / udev devnode; verified sysfs dev and /sys/dev/char binding", snapshot.device.path), + ); + let mut status = if data.state.state == ReadState::Available { + tr("Driver-reported snapshot") + } else if data.state.error != 0 { + camera_error_text(data.state.error) + } else { + read(&data.state) + }; + if data.state.state != ReadState::Available && !modes.is_empty() { + status = arg(tr("Incomplete enumeration: %1"), &status); + } + b.field(tr("Query status"), status, format!("{}: read-only capability enumeration; two-second worker wait budget", data.node)); + for (key, caption) in [("card", tr("Device name")), ("driver", tr("Driver")), ("bus", tr("Bus information"))] { + if let Some(value) = report.get(key) { + let text = value.as_str().unwrap_or(""); + b.field(caption, if text.is_empty() { tr("Unavailable") } else { text.to_string() }, query_source.clone()); + } + } + b.group(tr("Collection scope")); + b.field( + tr("Coverage"), + tr("This capture node and current media pipeline. No capture or configuration changes. Reported modes do not guarantee throughput or compatibility with other simultaneous streams."), + query_source, + ); + if !modes.is_empty() { + let title = tr("Format, size and frame rate combinations"); + let headers = vec![tr("Capture queue"), tr("Pixel format"), tr("Resolution / range"), tr("Frame rates / intervals")]; + let mut table = ReportTable { title: title.clone(), headers: headers.clone(), ..Default::default() }; + b.copy.push(String::new()); + b.copy.push(title); + for (index, row) in modes.iter().enumerate() { + let code = row["format"].as_u64().unwrap_or(0) as u32; + let code_text = fourcc(code); + let description = row["description"].as_str().unwrap_or(""); + let flags = row["flags"].as_u64().unwrap_or(0) as u32; + // The complete driver report goes to Copy, concise names to the table. + let mut format = format!("{code_text} (0x{code:08x})"); + if code & 0x8000_0000 != 0 { + format += &tr(" (big endian)"); + } + if !description.is_empty() { + format += &format!(" — {description}"); + } + if flags & 1 != 0 { + format += &tr(" (compressed)"); + } + if flags & 2 != 0 { + format += &tr(" (emulated, reported by driver)"); + } + let mut display_format = if code_text == "MJPG" { + "MJPEG".to_string() + } else if code_text == "YUYV" { + "YUYV 4:2:2".to_string() + } else if description == code_text || description.starts_with(&format!("{code_text} ")) { + description.to_string() + } else if description.is_empty() { + code_text.clone() + } else { + format!("{code_text} — {description}") + }; + if code & 0x8000_0000 != 0 { + display_format += &tr(" (big endian)"); + } + if flags & 1 != 0 && code_text != "MJPG" { + display_format += &tr(" (compressed)"); + } + if flags & 2 != 0 { + display_format += &tr(" (emulated, reported by driver)"); + } + let kind = row["size_kind"].as_str().unwrap_or(""); + let int = |key: &str| row[key].as_i64().unwrap_or(0).to_string(); + let size = if let Some(error) = row.get("size_error") { + camera_error_text(error.as_i64().unwrap_or(0) as i32) + } else if kind == "discrete" { + arg2(tr("%1 × %2"), &int("width"), &int("height")) + } else { + let mut size = argn4(tr("%1–%2 × %3–%4 pixels"), &int("width"), &int("max_width"), &int("height"), &int("max_height")); + size += + &if kind == "continuous" { tr(" (continuous)") } else { arg2(tr(" (steps: %1 × %2 pixels)"), &int("step_width"), &int("step_height")) }; + size + }; + let interval = &row["intervals"]; + let rates = if kind != "discrete" { + tr("Not enumerated: intervals depend on the exact size") + } else if let Some(error) = interval.get("error") { + camera_error_text(error.as_i64().unwrap_or(0) as i32) + } else if interval["kind"] == "discrete" { + interval["values"] + .as_array() + .map(|values| values.iter().map(|f| arg2(tr("%1 fps (%2 s)"), &format_g(fps(f), 8), &fraction_text(f))).collect::>().join("\n")) + .unwrap_or_default() + } else { + let (min, max) = (&interval["min"], &interval["max"]); + let mut rates = + argn4(tr("%1–%2 fps; intervals %3–%4 s"), &format_g(fps(max), 8), &format_g(fps(min), 8), &fraction_text(min), &fraction_text(max)); + rates += + &if interval["kind"] == "continuous" { tr(" (continuous)") } else { arg(tr(" (interval step: %1 s)"), &fraction_text(&interval["step"])) }; + rates + }; + let queue = if row["queue"] == "multi" { tr("Multiple planes") } else { tr("Single plane") }; + let values = [queue, display_format, size, rates]; + let mut copy_values = values.clone(); + copy_values[1] = format; + let source = format!( + "{}: VIDIOC_ENUM_FMT / VIDIOC_ENUM_FRAMESIZES / VIDIOC_ENUM_FRAMEINTERVALS\n{}", + data.node, + tr("Intervals queried only for this pixel format and exact discrete size. Ranges are not expanded.") + ); + b.copy.push(String::new()); + b.copy.push(arg(tr("Mode %1"), &(index + 1).to_string())); + for (header, value) in headers.iter().zip(©_values) { + b.copy.push(format!("{header}: {value}")); + } + table.rows.push(values.to_vec()); + table.sources.push(source); + } + b.report.tables.push(table); + } + Some(b.finish()) +} + +/// Four placeholders, as chained `QString::arg` calls. +fn argn4(text: String, a: &str, b: &str, c: &str, d: &str) -> String { + crate::i18n::argn(text, &[a, b, c, d]) +} + +// --------------------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------------------- + +pub fn display_applicable(device: &Device) -> bool { + device.subsystem == "drm" && device.sysname.contains('-') +} + +fn session_value(data: &DisplayProperties, key: &str) -> String { + let value = if data.status.state == ReadState::Available { + data.values.get(key).cloned().unwrap_or_default() + } else { + attribute(data.status.state, data.status.error) + }; + if value.state != ReadState::Available { + return read(&value); + } + match key { + "resolution" => value.value.replace('x', "×"), + "depth" => arg(tr("%1 bits per pixel"), &value.value), + "refresh" => arg(tr("%1 Hz"), &format!("{:.2}", value.value.parse::().unwrap_or(0.0))), + "rotation" => { + if value.value == "0" { + tr("0°") + } else { + arg(tr("%1° counterclockwise"), &value.value) + } + } + "reflection" => match value.value.as_str() { + "xy" => tr("X and Y axes"), + "x" => tr("X axis"), + "y" => tr("Y axis"), + "vertical" => tr("Vertical axis"), + _ => tr("None"), + }, + "scale" => { + if value.value == "custom" { + return tr("Custom transform"); + } + let axes: Vec<&str> = value.value.split(' ').collect(); + let (x, y) = (axes.first().and_then(|a| a.parse::().ok()).unwrap_or(0.0), axes.get(1).and_then(|a| a.parse::().ok()).unwrap_or(0.0)); + let mut text = + if axes.first() == axes.get(1) { arg(tr("%1×"), &format_g(x, 6)) } else { arg2(tr("X: %1×, Y: %2×"), &format_g(x, 6), &format_g(y, 6)) }; + match data.values.get("scale_kind").map(|k| k.value.as_str()) { + Some("derived") => text += &tr(" (derived)"), + Some("integer") => text += &tr(" (integer buffer scale)"), + _ => {} + } + text + } + _ => value.value, + } +} + +pub fn display_report(snapshot: &DeviceProperties) -> Option { + if !display_applicable(&snapshot.device) { + return None; + } + let data = &snapshot.display; + let monitor = &snapshot.monitor; + let mut b = Builder::default(); + if monitor.applicable { + let hardware = |key: &str| read(&monitor.values.get(key).cloned().unwrap_or_default()); + let source_of = |key: &str| monitor.sources.get(key).cloned().unwrap_or_default(); + b.group(tr("Monitor hardware (reported)")); + let descriptor = &monitor.descriptor; + let status = match (descriptor.state, descriptor.error) { + (ReadState::Available, _) => { + if monitor.values.contains_key("warnings") { + tr("Decoded with conformance warnings") + } else { + tr("Decoded by libdisplay-info") + } + } + (ReadState::Unsupported, _) if descriptor.value.is_empty() => tr("EDID decoding unavailable: build with libdisplay-info support"), + (ReadState::Unsupported, _) => arg(tr("Unsupported EDID version: %1"), &descriptor.value), + (ReadState::Error, libc::EPROTO) => tr("Malformed EDID (invalid header, length, checksum or parser rejection)"), + (ReadState::Error, libc::EAGAIN) => tr("Monitor report changed during collection; reload properties"), + _ => read(descriptor), + }; + b.field(tr("Descriptor"), status, source_of("descriptor")); + let hardware_field = |b: &mut Builder, caption: String, key: &str| b.field(caption, hardware(key), source_of(key)); + if monitor.values.contains_key("version") { + hardware_field(&mut b, tr("EDID version"), "version"); + } + if descriptor.state == ReadState::Available { + hardware_field(&mut b, tr("Manufacturer"), "manufacturer"); + hardware_field(&mut b, tr("Manufacturer code"), "manufacturer_code"); + hardware_field(&mut b, tr("Model"), "model"); + hardware_field(&mut b, tr("Product code"), "product"); + hardware_field(&mut b, tr("Serial number"), "serial"); + let size = monitor.values.get("size").cloned().unwrap_or_default(); + let dimensions: Vec<&str> = size.value.split(' ').collect(); + b.field( + tr("Image dimensions"), + if size.state == ReadState::Available { + arg2(tr("%1 × %2 cm"), dimensions.first().unwrap_or(&""), dimensions.get(1).unwrap_or(&"")) + } else { + read(&size) + }, + source_of("size"), + ); + if monitor.values.contains_key("warnings") { + hardware_field(&mut b, tr("Conformance warnings"), "warnings"); + } + b.group(tr("Monitor input (reported)")); + let digital = monitor.values.get("digital").cloned().unwrap_or_default(); + b.field( + tr("Signal type"), + if digital.state == ReadState::Available { + if digital.value == "1" { + tr("Digital") + } else { + tr("Analog") + } + } else { + read(&digital) + }, + source_of("digital"), + ); + if digital.state == ReadState::Available && digital.value == "1" { + hardware_field(&mut b, tr("Input interface"), "interface"); + let depth = monitor.values.get("depth").cloned().unwrap_or_default(); + b.field( + tr("Declared color depth"), + if depth.state == ReadState::Available { arg(tr("%1 bits per color component"), &depth.value) } else { read(&depth) }, + source_of("depth"), + ); + } + } + b.group(tr("Kernel-reported modes")); + hardware_field(&mut b, tr("Mode names"), "modes"); + b.field( + tr("Scope"), + tr("Cached mode names; refresh rates are not exposed here. The list may include driver additions or restrictions and does not guarantee monitor capabilities."), + source_of("modes"), + ); + } + if data.status.state == ReadState::Available && data.status.value == "disconnected" { + b.group(tr("Desktop session")); + b.field(tr("Configuration"), tr("Disconnected"), data.sources.get("configuration").cloned().unwrap_or_default()); + return Some(b.finish()); + } + let source = |key: &str| data.sources.get(key).cloned().unwrap_or_default(); + b.group(tr("Desktop session")); + b.field(tr("Backend"), if data.backend.is_empty() { tr("Unavailable") } else { data.backend.clone() }, ""); + b.field(tr("Output"), if data.output.is_empty() { tr("Unavailable") } else { data.output.clone() }, snapshot.device.path.clone()); + b.field( + tr("Configuration"), + if data.status.state == ReadState::Available { + if data.status.value == "inactive" { tr("No active mode") } else { tr("Reported by desktop server") } + } else { + read(&data.status) + }, + tr("Read-only session query. Unavailable can mean missing runtime libraries, an output not exposed by this session, or a protocol without usable output names."), + ); + b.group(tr("Active display mode")); + b.field(tr("Resolution"), session_value(data, "resolution"), source("resolution")); + b.field(tr("Desktop color depth"), session_value(data, "depth"), source("depth")); + b.field(tr("Refresh rate"), session_value(data, "refresh"), source("refresh")); + b.group(tr("Orientation and scale")); + b.field(tr("Rotation"), session_value(data, "rotation"), source("rotation")); + b.field(tr("Reflection"), session_value(data, "reflection"), source("reflection")); + b.field(tr("Output scale"), session_value(data, "scale"), source("scale")); + Some(b.finish()) +} + +// --------------------------------------------------------------------------------------- +// USB +// --------------------------------------------------------------------------------------- + +pub fn usb_applicable(device: &Device) -> bool { + device.subsystem == "usb" && device.devtype == "usb_device" +} + +fn usb_class_text(attribute: &Attribute) -> String { + if attribute.state != ReadState::Available { + return read(attribute); + } + let Some(code) = u32::from_str_radix(&attribute.value, 16).ok().filter(|c| *c <= 255) else { + return read(&self::attribute(ReadState::Error, libc::EINVAL)); + }; + let name = match code { + 0x01 => "Audio", + 0x02 => "Communications", + 0x03 => "Human interface device", + 0x07 => "Printer", + 0x08 => "Mass storage", + 0x09 => "Hub", + 0x0a => "CDC data", + 0x0b => "Smart card", + 0x0e => "Video", + 0xe0 => "Wireless controller", + 0xef => "Miscellaneous", + 0xfe => "Application-specific", + 0xff => "Vendor-specific", + _ => "", + }; + let hex = format!("0x{code:02x}"); + if name.is_empty() { + hex + } else { + arg2(tr("%1 (%2)"), &tr(name), &hex) + } +} + +pub fn usb_report(snapshot: &DeviceProperties) -> Option { + let device = &snapshot.device; + if !usb_applicable(device) { + return None; + } + let data = &snapshot.usb; + let mut b = Builder::default(); + let value = |key: &str| read(&data.values.get(key).cloned().unwrap_or_default()); + let source = |key: &str| format!("{}/{key}", device.path); + b.group(tr("Connection")); + let version = snapshot.values.get("sysfs/version").cloned().unwrap_or_default(); + b.field( + tr("USB version"), + read(&version), + format!("{}\n{}", source("version"), tr("USB specification version reported by the device descriptor; not the port's maximum capability.")), + ); + let speed = snapshot.values.get("sysfs/speed").cloned().unwrap_or_default(); + let mut speed_text = read(&speed); + if speed.state == ReadState::Available { + let mbps = speed.value.parse::().ok(); + if speed.value.eq_ignore_ascii_case("unknown") { + speed_text = read(&Attribute::default()); + } else if !mbps.is_some_and(|m| m.is_finite() && m > 0.0) { + speed_text = read(&attribute(ReadState::Error, libc::EINVAL)); + } else { + let mbps = mbps.expect("checked"); + speed_text = if mbps >= 1000.0 { arg(tr("%1 Gbps"), &format_g(mbps / 1000.0, 6)) } else { arg(tr("%1 Mbps"), &format_g(mbps, 6)) }; + } + } + b.field( + tr("Connection speed"), + speed_text, + format!("{}\n{}", source("speed"), tr("Kernel-reported link rate, not measured throughput. For root hubs this describes the USB bus.")), + ); + b.group(tr("Power declarations")); + let attributes = data.values.get("bmAttributes").cloned().unwrap_or_default(); + let (mut supply, mut wake) = (read(&attributes), read(&attributes)); + if attributes.state == ReadState::Available { + match u32::from_str_radix(&attributes.value, 16).ok().filter(|f| *f <= 255 && f & 0x80 != 0) { + None => { + supply = read(&attribute(ReadState::Error, libc::EINVAL)); + wake = supply.clone(); + } + Some(flags) => { + supply = if flags & 0x40 != 0 { tr("Self-powered") } else { tr("Bus-powered") }; + wake = if flags & 0x20 != 0 { tr("Supported") } else { tr("Not supported") }; + } + } + } + b.field(tr("Power source"), supply, source("bmAttributes")); + b.field(tr("Declared maximum current"), value("bMaxPower"), source("bMaxPower")); + b.field(tr("Remote wakeup capability"), wake, source("bmAttributes")); + b.group(tr("Configuration")); + let active = data.values.get("bConfigurationValue").cloned().unwrap_or_default(); + b.field( + tr("Active configuration"), + if active.state == ReadState::Available && (active.value.is_empty() || active.value == "0") { tr("Unconfigured") } else { read(&active) }, + source("bConfigurationValue"), + ); + b.field(tr("Available configurations"), value("bNumConfigurations"), source("bNumConfigurations")); + let description = data.values.get("configuration").cloned().unwrap_or_default(); + b.field( + tr("Description"), + if description.state == ReadState::Available && description.value.is_empty() { tr("Not provided") } else { read(&description) }, + source("configuration"), + ); + b.field(tr("Interface count"), value("bNumInterfaces"), source("bNumInterfaces")); + if data.interface_state.state != ReadState::Available || data.interfaces.is_empty() { + b.group(tr("Interfaces")); + b.field( + tr("Interfaces"), + if data.interface_state.state == ReadState::Available { tr("None observed") } else { read(&data.interface_state) }, + device.path.clone(), + ); + } + for interface in &data.interfaces { + b.group(arg(tr("Interface %1"), &interface.name)); + let get = |key: &str| interface.values.get(key).cloned().unwrap_or_default(); + if let Some(state) = interface.values.get("state") { + b.field(tr("Status"), read(state), interface.path.clone()); + continue; + } + let entry = |b: &mut Builder, key: &str, caption: String| b.field(caption, read(&get(key)), format!("{}/{key}", interface.path)); + entry(&mut b, "bInterfaceNumber", tr("Number (hex)")); + entry(&mut b, "bAlternateSetting", tr("Alternate setting")); + let label = get("interface"); + b.field( + tr("Description"), + if label.state == ReadState::Available && label.value.is_empty() { tr("Not provided") } else { read(&label) }, + format!("{}/interface", interface.path), + ); + b.field(tr("Function class"), usb_class_text(&get("bInterfaceClass")), format!("{}/bInterfaceClass", interface.path)); + entry(&mut b, "bInterfaceSubClass", tr("Subclass (hex)")); + entry(&mut b, "bInterfaceProtocol", tr("Protocol (hex)")); + let driver = get("driver"); + b.field( + tr("Bound driver"), + if driver.state == ReadState::Available && driver.value.is_empty() { tr("Unbound") } else { read(&driver) }, + format!("{}/driver", interface.path), + ); + } + Some(b.finish()) +} + +// --------------------------------------------------------------------------------------- +// PCIe +// --------------------------------------------------------------------------------------- + +pub fn pcie_applicable(snapshot: &DeviceProperties) -> bool { + snapshot.device.subsystem == "pci" && snapshot.pcie_applicable +} + +fn pcie_link_value(snapshot: &DeviceProperties, key: &str, speed: bool) -> String { + let value = snapshot.values.get(&format!("sysfs/{key}")).cloned().unwrap_or_default(); + if value.state == ReadState::Unsupported { + return tr("Query unsupported by the kernel/device"); + } + if value.state != ReadState::Available { + return read(&value); + } + let raw = value.value.clone(); + let malformed = || arg(tr("Malformed reported value: %1"), &if raw.is_empty() { tr("(empty)") } else { raw.clone() }); + if raw.is_empty() { + return malformed(); + } + if speed { + if raw.eq_ignore_ascii_case("Unknown speed") || raw.eq_ignore_ascii_case("unknown") { + return arg(tr("Unavailable (%1)"), &raw); + } + // Parse the kernel's C-locale rate, including its usual .0 suffix. Unfamiliar formats + // and future rates remain visible verbatim. + let rate_text = raw.strip_suffix(" PCIe").unwrap_or(&raw); + let Some(rate_text) = rate_text.strip_suffix(" GT/s") else { return raw }; + let Some(rate) = rate_text.parse::().ok().filter(|r| r.is_finite() && *r > 0.0) else { return malformed() }; + for (i, known) in [2.5, 5.0, 8.0, 16.0, 32.0, 64.0, 128.0].iter().enumerate() { + if rate == *known { + return arg2(tr("%1 (PCIe Gen %2)"), &raw, &(i + 1).to_string()); + } + } + return raw; + } + let Some(lanes) = raw.parse::().ok().filter(|_| !raw.starts_with('-')) else { return malformed() }; + // max_link_width exposes PCIE_LNK_WIDTH_UNKNOWN (0xff) verbatim: a kernel sentinel, not a + // 255-lane capability. Actual PCIe link width fields are six bits wide. + if key == "max_link_width" && lanes == 255 { + return arg(tr("Unavailable (kernel reports unknown width: %1)"), &raw); + } + if lanes > 63 { + return malformed(); + } + if lanes == 0 { + return arg(tr("Unavailable (reported width: %1)"), &raw); + } + arg2(tr("x%1 (%2 lanes)"), &lanes.to_string(), &format_integer(lanes)) +} + +pub fn pcie_report(snapshot: &DeviceProperties) -> Option { + if !pcie_applicable(snapshot) { + return None; + } + let mut b = Builder::default(); + let kind = &snapshot.pcie_type; + let number = kind.value.parse::().ok().filter(|_| kind.state == ReadState::Available); + let mut type_text = read(kind); + if kind.state == ReadState::Error && kind.error == libc::EPROTO { + type_text = tr("Malformed PCI capability data"); + } else if kind.state == ReadState::Error && kind.error == libc::EMSGSIZE { + type_text = tr("Incomplete PCI configuration data"); + } + if let Some(n) = number { + type_text = match n { + 0 => tr("PCIe endpoint"), + 1 => tr("Legacy PCIe endpoint"), + 4 => tr("Root port"), + 5 => tr("Upstream switch port"), + 6 => tr("Downstream switch port"), + 7 => tr("PCIe-to-PCI/PCI-X bridge"), + 8 => tr("PCI/PCI-X-to-PCIe bridge"), + 9 => tr("Root Complex Integrated Endpoint"), + 10 => tr("Root Complex Event Collector"), + _ => arg(tr("Unrecognized PCIe device type: %1"), &kind.value), + }; + } + b.group(tr("Connection")); + b.field(tr("Connection type"), type_text, snapshot.pcie_type_source.clone()); + if snapshot.pcie_type_requires_root { + b.field(tr("Access"), tr("Run the application as root or with sudo to read this device's PCIe connection type."), snapshot.pcie_type_source.clone()); + } + // Both root-complex types lack conventional PCIe link registers. This identifies connection + // topology, not GPU integration or performance. + if matches!(number, Some(9 | 10)) { + b.field(tr("PCIe link"), tr("Not applicable — integrated into the root complex; no external PCIe link."), snapshot.pcie_type_source.clone()); + } else { + let source = |key: &str, scope: &str| format!("{}\n{scope}", snapshot.sources.get(&format!("sysfs/{key}")).cloned().unwrap_or_default()); + let current_scope = tr("Current link reported for this device; an on-demand reading, not measured throughput or a fault verdict."); + b.group(tr("Current link")); + b.field(tr("Link speed"), pcie_link_value(snapshot, "current_link_speed", true), source("current_link_speed", ¤t_scope)); + b.field(tr("Link width"), pcie_link_value(snapshot, "current_link_width", false), source("current_link_width", ¤t_scope)); + let capability_scope = + tr("Maximum capability reported for this device, not the slot or entire upstream path. Speed and width maxima are separate declarations."); + b.group(tr("Device capability")); + b.field(tr("Maximum link speed"), pcie_link_value(snapshot, "max_link_speed", true), source("max_link_speed", &capability_scope)); + b.field(tr("Maximum link width"), pcie_link_value(snapshot, "max_link_width", false), source("max_link_width", &capability_scope)); + b.group(tr("Interpretation")); + b.field(tr("Upstream path"), tr("Not assessed; upstream links may impose additional limits."), ""); + b.field( + tr("Current vs. maximum"), + tr("A lower current link speed or width is not automatically a fault. Power management and topology can explain differences."), + "", + ); + } + Some(b.finish()) +} + +// --------------------------------------------------------------------------------------- +// Battery +// --------------------------------------------------------------------------------------- + +pub fn battery_applicable(snapshot: &DeviceProperties) -> bool { + snapshot.device.subsystem == "power_supply" && snapshot.battery.applicable +} + +pub fn battery_report(snapshot: &DeviceProperties) -> Option { + if !battery_applicable(snapshot) { + return None; + } + let data = &snapshot.battery; + let device = &snapshot.device; + let mut b = Builder::default(); + let source = |key: &str| format!("{}/{key}", device.path); + let read_value = |value: &Attribute| { + if value.state == ReadState::Unsupported || value.error == libc::EOPNOTSUPP || value.error == libc::ENOSYS { + tr("Query unsupported by the kernel/device") + } else { + read(value) + } + }; + let get = |key: &str| data.values.get(key).cloned().unwrap_or_default(); + let text_value = |key: &str| { + let value = get(key); + if value.state != ReadState::Available { + return read_value(&value); + } + if value.value.is_empty() { + return tr("Not reported"); + } + if value.value == "Unknown" { + return tr("Unknown (reported)"); + } + if key == "status" { + match value.value.as_str() { + "Charging" => return tr("Charging"), + "Discharging" => return tr("Discharging"), + "Not charging" => return tr("Not charging"), + "Full" => return tr("Full"), + _ => {} + } + } + value.value + }; + // Sysfs numeric battery properties are decimal integers, in micro-units. Signed current and + // power readings keep their sign, and neither is ever derived from the other. + let number_value = |key: &str, divisor: f64, unit: &str, signed: bool| { + let value = get(key); + if value.state != ReadState::Available { + return read_value(&value); + } + let number = value.value.parse::().ok().filter(|n| (signed || *n >= 0) && !(key == "capacity" && *n > 100)); + let Some(number) = number else { + return arg(tr("Malformed reported value: %1"), &if value.value.is_empty() { tr("(empty)") } else { value.value.clone() }); + }; + if number == 0 && (key == "cycle_count" || key.ends_with("_full") || key.ends_with("_full_design")) { + return tr("Unavailable (reported value: 0)"); + } + let formatted = if divisor == 1.0 { format_integer_signed(number) } else { format!("{:.3}", number as f64 / divisor) }; + if unit.is_empty() { + formatted + } else { + format!("{formatted} {unit}") + } + }; + b.group(tr("Current state")); + if data.state.state != ReadState::Available { + let mut state = read_value(&data.state); + if data.state.error == libc::EAGAIN { + state = tr("Battery type or presence changed during collection; reload properties."); + } else if data.state.error == libc::EINVAL && data.values.contains_key("present") { + state = arg(tr("Malformed battery presence value: %1"), &get("present").value); + } + b.field(tr("Collection status"), state, format!("{}\n{}", source("type"), source("present"))); + if data.values.contains_key("type") { + b.field(tr("Supply type"), text_value("type"), source("type")); + } + } else { + let present = get("present"); + let presence = if present.state == ReadState::Available { + if present.value == "1" { + tr("Present") + } else { + tr("Absent") + } + } else { + read_value(&present) + }; + b.field(tr("Battery presence"), presence, source("present")); + b.field(tr("Status"), text_value("status"), source("status")); + b.field(tr("Charge percentage"), number_value("capacity", 1.0, "%", false), source("capacity")); + b.field(tr("Voltage"), number_value("voltage_now", 1_000_000.0, "V", false), source("voltage_now")); + b.field( + tr("Current"), + number_value("current_now", 1_000_000.0, "A", true), + format!( + "{}\n{}", + source("current_now"), + tr("Driver-reported instantaneous current; the reported sign is preserved. Charging state is reported separately.") + ), + ); + b.field( + tr("Power"), + number_value("power_now", 1_000_000.0, "W", true), + format!("{}\n{}", source("power_now"), tr("Driver-reported instantaneous power; not calculated from voltage and current.")), + ); + let retention = |prefix: &str| { + let full = get(&format!("{prefix}_full")); + let design = get(&format!("{prefix}_full_design")); + if full.state == ReadState::NotApplicable || design.state == ReadState::NotApplicable { + return read_value(&attribute(ReadState::NotApplicable, 0)); + } + if full.state != ReadState::Available || design.state != ReadState::Available { + return tr("Unavailable — requires compatible full and design readings"); + } + let (Some(full), Some(design)) = (full.value.parse::().ok().filter(|v| *v > 0), design.value.parse::().ok().filter(|v| *v > 0)) else { + return tr("Unavailable — requires positive full and design readings"); + }; + arg(tr("%1 % (estimate)"), &format!("{:.1}", 100.0 * full as f64 / design as f64)) + }; + let has_capacity = |prefix: &str| { + ["_full_design", "_full", "_now"].iter().any(|suffix| { + let value = get(&format!("{prefix}{suffix}")); + value.state != ReadState::Unavailable || value.error != libc::ENOENT + }) + }; + let (energy, charge) = (has_capacity("energy"), has_capacity("charge")); + for (present, prefix, title, divisor, unit) in + [(energy, "energy", tr("Energy capacity"), 1_000_000.0, "Wh"), (charge, "charge", tr("Charge capacity"), 1000.0, "mAh")] + { + if !present { + continue; + } + b.group(title); + b.field(tr("Design capacity"), number_value(&format!("{prefix}_full_design"), divisor, unit, false), source(&format!("{prefix}_full_design"))); + b.field( + tr("Last full-charge capacity"), + number_value(&format!("{prefix}_full"), divisor, unit, false), + format!( + "{}\n{}", + source(&format!("{prefix}_full")), + tr("Firmware-reported learned full capacity; it can depend on temperature, age and calibration.") + ), + ); + b.field(tr("Remaining capacity"), number_value(&format!("{prefix}_now"), divisor, unit, false), source(&format!("{prefix}_now"))); + b.field( + tr("Estimated capacity retention"), + retention(prefix), + format!( + "{}\n{}\n{}", + source(&format!("{prefix}_full")), + source(&format!("{prefix}_full_design")), + tr("100 × full / design using matching units; not a complete battery health verdict. Values above 100% are not clamped.") + ), + ); + } + if !energy && !charge { + b.group(tr("Capacity")); + b.field(tr("Reported capacities"), tr("Unavailable — no energy or charge capacity attributes exposed"), device.path.clone()); + b.field(tr("Estimated capacity retention"), tr("Unavailable — requires compatible full and design readings"), device.path.clone()); + } + b.group(tr("Battery information")); + b.field( + tr("Cycle count"), + number_value("cycle_count", 1.0, "", false), + format!("{}\n{}", source("cycle_count"), tr("Reported full charge/discharge cycles. The kernel ABI defines zero as unavailable.")), + ); + b.field(tr("Chemistry"), text_value("technology"), source("technology")); + b.field(tr("Manufacturer"), text_value("manufacturer"), source("manufacturer")); + b.field(tr("Model"), text_value("model_name"), source("model_name")); + b.field(tr("Serial number"), text_value("serial_number"), source("serial_number")); + } + Some(b.finish()) +} + +fn format_integer_signed(n: i64) -> String { + if n < 0 { + format!("-{}", format_integer(n.unsigned_abs())) + } else { + format_integer(n as u64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::camera::CameraProperties; + use crate::display::DisplayProperties; + use crate::ethernet::EthernetProperties; + use crate::monitor_edid::MonitorProperties; + use crate::network::{NetworkAddress, NetworkProperties}; + use crate::properties::{BatteryProperties, UsbInterfaceProperties}; + use serde_json::json; + + fn available(value: &str) -> Attribute { + Attribute { state: ReadState::Available, value: value.into(), error: 0 } + } + fn field<'a>(report: &'a Report, caption: &str) -> &'a ReportField { + report.groups.iter().flat_map(|g| &g.fields).find(|f| f.caption == caption).unwrap_or_else(|| panic!("missing field {caption}")) + } + fn snapshot(subsystem: &str) -> DeviceProperties { + DeviceProperties { + device: Device { subsystem: subsystem.into(), path: "/sys/devices/x".into(), sysname: "x-1".into(), ..Default::default() }, + state: ReadState::Available, + ..Default::default() + } + } + + #[test] + fn ethernet_links_are_worded_from_raw_driver_values() { + let mut s = snapshot("net"); + let ethernet = EthernetProperties { + applicable: true, + scope: available(""), + carrier: available("1"), + speed: available("2500"), + duplex: available("1"), + autoneg: available("1"), + supported_modes: available("10baseT/Half\n1000baseT/Full"), + partner_modes: available("1000baseT/Full"), + source: "ethtool".into(), + }; + s.network.push(NetworkProperties { + name: "eno1".into(), + path: "/sys/devices/x/net/eno1".into(), + link_type: available("1"), + permanent_address: available("aa:bb:cc:dd:ee:01"), + current_address: available("aa:bb:cc:dd:ee:02"), + assignment_type: available("3"), + mtu: available("1500"), + addresses: vec![NetworkAddress { family: "IPv4", address: "192.168.1.5".into(), netmask: available("255.255.255.0") }], + address_state: available(""), + ethernet, + }); + let report = network_report(&s).unwrap(); + assert_eq!(field(&report, "Link status").value, "Connected"); + assert_eq!(field(&report, "Link speed").value, "2.5 Gbps"); + assert_eq!(field(&report, "Duplex").value, "Full duplex"); + assert_eq!(field(&report, "Autonegotiation").value, "Enabled"); + assert_eq!(field(&report, "Supported modes / capabilities").value, "10baseT/Half, 1000baseT/Full"); + assert_eq!(field(&report, "Overridden MAC address").value, "aa:bb:cc:dd:ee:02", "current differs from the permanent address"); + assert_eq!(field(&report, "IPv4 address").value, "192.168.1.5/255.255.255.0"); + assert!(report.copy_text.starts_with("eno1 — Ethernet link\nLink status: Connected")); + assert!(report.copy_text.contains("\n\neno1 — Link\n") && report.copy_text.contains("\n\neno1 — IP addresses\n")); + + // Unknown driver values stay explicit; a disconnected link makes speed inapplicable. + s.network[0].ethernet.speed = available("4294967295"); + s.network[0].ethernet.duplex = available("255"); + s.network[0].ethernet.autoneg = available("7"); + assert_eq!(field(&network_report(&s).unwrap(), "Link speed").value, "Unavailable (driver reports unknown speed)"); + assert_eq!(field(&network_report(&s).unwrap(), "Duplex").value, "Unavailable (driver reports unknown duplex)"); + assert_eq!(field(&network_report(&s).unwrap(), "Autonegotiation").value, "Unrecognized autonegotiation value: 7"); + s.network[0].ethernet.carrier = available("0"); + s.network[0].ethernet.speed = attribute(ReadState::NotApplicable, 0); + s.network[0].permanent_address = Attribute::default(); + s.network[0].assignment_type = available("0"); + let report = network_report(&s).unwrap(); + assert_eq!(field(&report, "Link status").value, "Disconnected"); + assert_eq!(field(&report, "Link speed").value, "Not applicable — link disconnected"); + assert_eq!(field(&report, "Overridden MAC address").value, "Unavailable; permanent MAC could not be determined"); + s.network.clear(); + s.network_state = available(""); + assert_eq!(field(&network_report(&s).unwrap(), "Interfaces").value, "No network interfaces observed"); + assert!(network_report(&snapshot("usb")).is_none()); + assert_eq!(speed_text(100), "100 Mbps"); + } + + #[test] + fn camera_modes_keep_format_size_and_rate_together() { + let mut s = snapshot("video4linux"); + s.camera = CameraProperties { + applicable: true, + state: available(""), + node: "/dev/video0".into(), + report: json!({ + "card": "USB2.0 HD UVC WebCam", "driver": "uvcvideo", "bus": "usb-0000:08:00.4-3", "error": 0, + "modes": [ + {"queue": "single", "format": 1196444237u32, "description": "Motion-JPEG", "flags": 1, "size_kind": "discrete", + "width": 1280, "height": 720, "intervals": {"kind": "discrete", "values": [[1, 30], [1, 15]]}}, + {"queue": "single", "format": 1448695129u32, "description": "YUYV 4:2:2", "flags": 0, "size_kind": "stepwise", + "width": 160, "height": 120, "max_width": 640, "max_height": 480, "step_width": 16, "step_height": 8}, + ], + }), + }; + let report = camera_report(&s).unwrap(); + assert_eq!(field(&report, "Device name").value, "USB2.0 HD UVC WebCam"); + assert_eq!(field(&report, "Query status").value, "Driver-reported snapshot"); + let table = &report.tables[0]; + assert_eq!(table.rows[0], ["Single plane", "MJPEG", "1280 × 720", "30 fps (1/30 s)\n15 fps (1/15 s)"]); + assert_eq!(table.rows[1][1], "YUYV 4:2:2"); + assert_eq!(table.rows[1][2], "160–640 × 120–480 pixels (steps: 16 × 8 pixels)"); + assert_eq!(table.rows[1][3], "Not enumerated: intervals depend on the exact size"); + assert!( + report.copy_text.contains("Mode 1\nCapture queue: Single plane\nPixel format: MJPG (0x47504a4d) — Motion-JPEG (compressed)"), + "{}", + report.copy_text + ); + assert_eq!(fourcc(1196444237), "MJPG"); + assert_eq!(fourcc(0x0000_0001), "\\x01\\x00\\x00\\x00"); + + s.camera.state = attribute(ReadState::Error, libc::ETIMEDOUT); + assert_eq!(field(&camera_report(&s).unwrap(), "Query status").value, "Incomplete enumeration: Query timed out"); + assert!(camera_report(&snapshot("usb")).is_none()); + } + + #[test] + fn display_reports_hardware_and_session_state_separately() { + let mut s = snapshot("drm"); + s.device.sysname = "card1-eDP-1".into(); + s.monitor = MonitorProperties { applicable: true, descriptor: available(""), ..Default::default() }; + for (k, v) in [ + ("version", "1.4"), + ("manufacturer", "Dell Inc."), + ("manufacturer_code", "DEL"), + ("model", "U2720Q"), + ("product", "0xa0b1"), + ("serial", "S1"), + ("size", "60 34"), + ("digital", "1"), + ("interface", "DisplayPort"), + ("depth", "10"), + ("modes", "3840x2160\n1920x1080"), + ] { + s.monitor.values.insert(k.into(), available(v)); + } + s.display = DisplayProperties { backend: "Wayland".into(), output: "eDP-1".into(), status: available("exposed"), ..Default::default() }; + for (k, v) in [("resolution", "2880x1800"), ("refresh", "120"), ("rotation", "90"), ("reflection", "none"), ("scale", "2 2"), ("scale_kind", "derived")] + { + s.display.values.insert(k.into(), available(v)); + } + let report = display_report(&s).unwrap(); + assert_eq!(field(&report, "Descriptor").value, "Decoded by libdisplay-info"); + assert_eq!(field(&report, "Image dimensions").value, "60 × 34 cm"); + assert_eq!(field(&report, "Declared color depth").value, "10 bits per color component"); + assert_eq!(field(&report, "Resolution").value, "2880×1800"); + assert_eq!(field(&report, "Refresh rate").value, "120.00 Hz"); + assert_eq!(field(&report, "Rotation").value, "90° counterclockwise"); + assert_eq!(field(&report, "Output scale").value, "2× (derived)"); + assert_eq!(field(&report, "Desktop color depth").value, "Unavailable", "Wayland exposes no desktop depth"); + s.display.values.insert("scale".into(), available("2 1.5")); + assert_eq!(field(&display_report(&s).unwrap(), "Output scale").value, "X: 2×, Y: 1.5× (derived)"); + s.display = DisplayProperties { status: available("disconnected"), ..Default::default() }; + s.display.sources.insert("configuration".into(), "/sys/devices/x/status".into()); + assert_eq!(field(&display_report(&s).unwrap(), "Configuration").value, "Disconnected"); + assert!(display_report(&snapshot("usb")).is_none()); + } + + #[test] + fn usb_power_class_and_speed_are_decoded_conservatively() { + let mut s = snapshot("usb"); + s.device.devtype = "usb_device".into(); + s.values.insert("sysfs/version".into(), available(" 2.00")); + s.values.insert("sysfs/speed".into(), available("480")); + for (k, v) in [ + ("bmAttributes", "e0"), + ("bMaxPower", "500mA"), + ("bConfigurationValue", "1"), + ("bNumConfigurations", "1"), + ("configuration", ""), + ("bNumInterfaces", "1"), + ] { + s.usb.values.insert(k.into(), available(v)); + } + s.usb.interface_state = available(""); + let mut interface = UsbInterfaceProperties { name: "1-2:1.0".into(), path: "/sys/devices/x/1-2:1.0".into(), ..Default::default() }; + for (k, v) in [ + ("bInterfaceNumber", "00"), + ("bAlternateSetting", "0"), + ("bInterfaceClass", "03"), + ("bInterfaceSubClass", "01"), + ("bInterfaceProtocol", "01"), + ("interface", ""), + ("driver", "usbhid"), + ] { + interface.values.insert(k.into(), available(v)); + } + s.usb.interfaces.push(interface); + let report = usb_report(&s).unwrap(); + assert_eq!(field(&report, "Connection speed").value, "480 Mbps"); + assert_eq!(field(&report, "Power source").value, "Self-powered"); + assert_eq!(field(&report, "Remote wakeup capability").value, "Supported"); + assert_eq!(field(&report, "Description").value, "Not provided"); + assert_eq!(field(&report, "Function class").value, "Human interface device (0x03)"); + assert_eq!(field(&report, "Bound driver").value, "usbhid"); + s.usb.values.insert("bmAttributes".into(), available("20")); + assert_eq!(field(&usb_report(&s).unwrap(), "Power source").value, "Invalid data or query (errno 22)", "bit 7 must be set"); + s.values.insert("sysfs/speed".into(), available("unknown")); + assert_eq!(field(&usb_report(&s).unwrap(), "Connection speed").value, "Unavailable"); + s.values.insert("sysfs/speed".into(), available("20000")); + assert_eq!(field(&usb_report(&s).unwrap(), "Connection speed").value, "20 Gbps"); + assert_eq!(usb_class_text(&available("ff")), "Vendor-specific (0xff)"); + assert_eq!(usb_class_text(&available("42")), "0x42", "unfamiliar classes keep their code only"); + s.usb.interfaces.clear(); + assert_eq!(field(&usb_report(&s).unwrap(), "Interfaces").value, "None observed"); + } + + #[test] + fn pcie_links_name_generations_and_sentinels() { + let mut s = snapshot("pci"); + s.pcie_applicable = true; + s.pcie_type = available("4"); + s.pcie_type_source = "/sys/devices/x/config".into(); + for (k, v) in [("current_link_speed", "16.0 GT/s PCIe"), ("current_link_width", "8"), ("max_link_speed", "32.0 GT/s PCIe"), ("max_link_width", "255")] { + s.values.insert(format!("sysfs/{k}"), available(v)); + } + let report = pcie_report(&s).unwrap(); + assert_eq!(field(&report, "Connection type").value, "Root port"); + assert_eq!(field(&report, "Link speed").value, "16.0 GT/s PCIe (PCIe Gen 4)"); + assert_eq!(field(&report, "Link width").value, "x8 (8 lanes)"); + assert_eq!(field(&report, "Maximum link speed").value, "32.0 GT/s PCIe (PCIe Gen 5)"); + assert_eq!(field(&report, "Maximum link width").value, "Unavailable (kernel reports unknown width: 255)"); + assert!(report.groups.iter().any(|g| g.title == "Interpretation")); + s.values.insert("sysfs/current_link_speed".into(), available("Unknown speed")); + assert_eq!(field(&pcie_report(&s).unwrap(), "Link speed").value, "Unavailable (Unknown speed)"); + s.values.insert("sysfs/current_link_speed".into(), available("3.5 GT/s")); + assert_eq!(field(&pcie_report(&s).unwrap(), "Link speed").value, "3.5 GT/s", "unfamiliar rates stay verbatim"); + s.values.insert("sysfs/current_link_width".into(), available("-1")); + assert!(field(&pcie_report(&s).unwrap(), "Link width").value.starts_with("Malformed reported value")); + s.pcie_type = available("9"); + let integrated = pcie_report(&s).unwrap(); + assert!(field(&integrated, "PCIe link").value.starts_with("Not applicable — integrated")); + assert!(integrated.groups.iter().all(|g| g.title != "Current link")); + s.pcie_type = attribute(ReadState::PermissionDenied, libc::EACCES); + s.pcie_type_requires_root = true; + assert!(field(&pcie_report(&s).unwrap(), "Access").value.contains("Run the application as root")); + s.pcie_applicable = false; + assert!(pcie_report(&s).is_none()); + } + + #[test] + fn battery_values_keep_units_signs_and_estimates_honest() { + let mut s = snapshot("power_supply"); + s.battery = BatteryProperties { applicable: true, state: available(""), ..Default::default() }; + for (k, v) in [ + ("type", "Battery"), + ("present", "1"), + ("status", "Discharging"), + ("capacity", "87"), + ("voltage_now", "12345000"), + ("current_now", "-1500000"), + ("power_now", "18500000"), + ("energy_full_design", "56000000"), + ("energy_full", "49000000"), + ("energy_now", "42630000"), + ("cycle_count", "212"), + ("technology", "Li-ion"), + ("manufacturer", "ASUSTeK"), + ("model_name", "A32-K55"), + ("serial_number", "Unknown"), + ] { + s.battery.values.insert(k.into(), available(v)); + } + // Real collection records absent attributes as ENOENT, which means "not exposed". + for key in ["charge_full_design", "charge_full", "charge_now"] { + s.battery.values.insert(key.into(), attribute(ReadState::Unavailable, libc::ENOENT)); + } + let report = battery_report(&s).unwrap(); + assert_eq!(field(&report, "Charge percentage").value, "87 %"); + assert_eq!(field(&report, "Voltage").value, "12.345 V"); + assert_eq!(field(&report, "Current").value, "-1.500 A", "sign preserved"); + assert_eq!(field(&report, "Power").value, "18.500 W"); + assert_eq!(field(&report, "Design capacity").value, "56.000 Wh"); + assert_eq!(field(&report, "Estimated capacity retention").value, "87.5 % (estimate)"); + assert_eq!(field(&report, "Cycle count").value, "212"); + assert_eq!(field(&report, "Serial number").value, "Unknown (reported)"); + assert!(report.groups.iter().all(|g| g.title != "Charge capacity"), "no charge attributes were exposed"); + s.battery.values.insert("energy_full".into(), available("0")); + let report = battery_report(&s).unwrap(); + assert_eq!(field(&report, "Last full-charge capacity").value, "Unavailable (reported value: 0)"); + assert_eq!(field(&report, "Estimated capacity retention").value, "Unavailable — requires positive full and design readings"); + s.battery.values.insert("capacity".into(), available("101")); + assert!(field(&battery_report(&s).unwrap(), "Charge percentage").value.starts_with("Malformed reported value")); + s.battery.values.clear(); + s.battery.state = attribute(ReadState::Error, libc::EAGAIN); + assert!(field(&battery_report(&s).unwrap(), "Collection status").value.starts_with("Battery type or presence changed")); + s.battery.applicable = false; + assert!(battery_report(&s).is_none()); + } +} diff --git a/crates/qldm-core/src/resourceformat.rs b/crates/qldm-core/src/resourceformat.rs new file mode 100644 index 0000000..b9b16e4 --- /dev/null +++ b/crates/qldm-core/src/resourceformat.rs @@ -0,0 +1,156 @@ +//! Resource table rows for the UI, ported from `src/devices/resourceformat.cpp`. +use crate::i18n::{arg, tr}; +use crate::propertytext::property_read_value; +use crate::resources::{Allocation, DeviceResources, ResourceType}; +use std::collections::HashMap; +use std::sync::Arc; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResourceRow { + pub key: String, + pub kind: String, + pub setting: String, + pub details: String, + pub source: String, + /// Resource type id as a number, or `"issues"`. + pub group: String, +} + +fn type_id(kind: ResourceType) -> i32 { + match kind { + ResourceType::Memory => 0, + ResourceType::Io => 1, + ResourceType::Irq => 2, + ResourceType::Dma => 3, + ResourceType::Bus => 4, + } +} + +pub fn resource_rows(data: &DeviceResources) -> Vec { + let mut rows = Vec::new(); + for r in &data.items { + let kind = tr(match r.kind { + ResourceType::Memory => "Memory range", + ResourceType::Io => "I/O range", + ResourceType::Irq => "IRQ", + ResourceType::Dma => "DMA channel", + ResourceType::Bus => "Bus range", + }); + let setting = match r.allocation { + Allocation::Assigned if matches!(r.kind, ResourceType::Irq | ResourceType::Dma) => r.start.to_string(), + Allocation::Assigned => { + let width = if r.kind == ResourceType::Memory { 16 } else { 4 }; + format!("{:0width$X} – {:0width$X}", r.start, r.end) + } + Allocation::Unassigned => tr("Unassigned"), + Allocation::Disabled => tr("Disabled"), + Allocation::Unavailable => tr("Address unavailable (zeroed or masked)"), + }; + let mut details: Vec = Vec::new(); + if r.pci { + details.push(match r.index { + 0..=5 => arg(tr("BAR %1"), &r.index.to_string()), + 6 => tr("Expansion ROM"), + _ => arg(tr("PCI resource %1"), &r.index.to_string()), + }); + } + // Decode only stable flags; never interpret overlap or BUSY as a conflict. + for (bit, text) in [(0x2000, "Prefetchable"), (0x4000, "Read-only"), (0x10_0000, "64-bit memory"), (0x20_0000, "Bridge window")] { + if r.flags & bit != 0 { + details.push(tr(text)); + } + } + if r.pci { + details.push(arg(tr("Flags: %1"), &format!("0x{:x}", r.flags))); + } + match r.mode.as_str() { + "msi" => details.push("MSI".into()), + "msix" => details.push("MSI-X".into()), + "reported" => details.push(tr("Reported irq attribute; active mode not established")), + _ => {} + } + if data.pnp_state == "disabled" { + details.push(tr("PnP device disabled")); + } + rows.push(ResourceRow { + key: format!("{}:{}:{}:{}:{}:{}", r.source, type_id(r.kind), r.index, r.start, r.end, r.mode), + kind, + setting, + details: details.join("; "), + source: r.source.clone(), + group: type_id(r.kind).to_string(), + }); + } + for problem in &data.issues { + rows.push(ResourceRow { + key: format!("issue:{}", problem.source), + kind: tr("Resource metadata"), + setting: property_read_value(&problem.error), + details: tr("Read or parse failed"), + source: problem.source.clone(), + group: "issues".into(), + }); + } + // Preserve multiple identical reported rows without colliding projection keys. + let mut duplicates: HashMap = HashMap::new(); + for row in &mut rows { + let seen = duplicates.entry(row.key.clone()).or_insert(0); + row.key = format!("{}:{seen}", row.key); + *seen += 1; + } + rows +} + +/// Equal content, treating an absent snapshot as an empty one. +pub fn same_device_resources(a: &Option>, b: &Option>) -> bool { + if let (Some(x), Some(y)) = (a, b) { + if Arc::ptr_eq(x, y) { + return true; + } + } + let empty = DeviceResources::default(); + a.as_deref().unwrap_or(&empty) == b.as_deref().unwrap_or(&empty) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resources::{parse_pci_resources, parse_pnp_resources}; + + #[test] + fn pci_rows_describe_bars_and_flags() { + let data = parse_pci_resources("0x00000007c0000000 0x00000007cfffffff 0x00142200\n0x0000ef00 0x0000efff 0x00000101\n0 0xfff 0x20000200\n", "s"); + let rows = resource_rows(&data); + assert_eq!(rows[0].kind, "Memory range"); + assert_eq!(rows[0].setting, "00000007C0000000 – 00000007CFFFFFFF"); + assert_eq!(rows[0].details, "BAR 0; Prefetchable; 64-bit memory; Flags: 0x142200"); + assert_eq!(rows[1].setting, "EF00 – EFFF"); + assert_eq!(rows[2].setting, "Unassigned"); + assert_eq!(rows[0].group, "0"); + assert!(rows[0].key.ends_with(":0")); + } + + #[test] + fn irq_modes_issues_and_duplicate_keys() { + let mut data = parse_pnp_resources("state = disabled\nirq 5\nirq 5\n", "p"); + data.issues.push(crate::resources::ResourceIssue { + source: "x/irq".into(), + error: crate::model::Attribute { state: crate::model::ReadState::PermissionDenied, ..Default::default() }, + }); + let rows = resource_rows(&data); + assert_eq!((rows[0].setting.as_str(), rows[0].details.as_str()), ("Disabled", "PnP device disabled")); + assert_ne!(rows[0].key, rows[1].key, "identical rows keep distinct keys"); + let issue = rows.last().unwrap(); + assert_eq!((issue.group.as_str(), issue.setting.as_str()), ("issues", "Permission denied")); + } + + #[test] + fn same_resources_treats_none_as_empty() { + let a = Some(Arc::new(parse_pnp_resources("state = active\nirq 5\n", "p"))); + let b = Some(Arc::new(parse_pnp_resources("state = active\nirq 5\n", "p"))); + assert!(same_device_resources(&a, &a.clone())); + assert!(same_device_resources(&a, &b)); + assert!(!same_device_resources(&a, &None)); + assert!(same_device_resources(&None, &Some(Arc::new(DeviceResources::default())))); + } +} diff --git a/crates/qldm-core/src/resources.rs b/crates/qldm-core/src/resources.rs new file mode 100644 index 0000000..4bde218 --- /dev/null +++ b/crates/qldm-core/src/resources.rs @@ -0,0 +1,500 @@ +//! Device resources (PCI BARs, PnP assignments, MSI vectors), ported from +//! `src/devices/deviceresources.cpp`. Raw kernel evidence only: overlap and BUSY are never +//! interpreted as conflicts, and a malformed table is rejected whole rather than shifted. +use crate::model::{Attribute, Device, ReadState}; +use std::fs::{self, File, OpenOptions}; +use std::io::Read; +use std::os::fd::AsRawFd; +use std::os::unix::fs::OpenOptionsExt; +use std::sync::atomic::{AtomicBool, Ordering}; + +// Stable userspace resource flag values: Linux include/linux/ioport.h. +const TYPE_MASK: u64 = 0x0000_1f00; +const IO: u64 = 0x0000_0100; +const MEMORY: u64 = 0x0000_0200; +pub const WINDOW: u64 = 0x0020_0000; +const DISABLED: u64 = 0x1000_0000; +const UNSET: u64 = 0x2000_0000; +const INPUT_LIMIT: usize = 65536; +const ROW_LIMIT: usize = 256; +const VECTOR_LIMIT: usize = 4096; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ResourceType { + #[default] + Memory, + Io, + Irq, + Dma, + Bus, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Allocation { + #[default] + Assigned, + Unassigned, + Disabled, + Unavailable, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DeviceResource { + pub kind: ResourceType, + pub allocation: Allocation, + pub start: u64, + pub end: u64, + pub flags: u64, + /// PCI resource slot, not necessarily a BAR; `-1` when not a PCI table row. + pub index: i32, + pub pci: bool, + /// MSI/MSI-X or reported irq; raw kernel evidence only. + pub mode: String, + pub source: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResourceIssue { + pub source: String, + pub error: Attribute, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DeviceResources { + pub items: Vec, + pub issues: Vec, + pub pnp_state: String, +} + +impl DeviceResources { + pub fn has_information(&self) -> bool { + !self.items.is_empty() || !self.issues.is_empty() + } +} + +fn resource(kind: ResourceType) -> DeviceResource { + DeviceResource { kind, index: -1, ..Default::default() } +} + +fn failure(error: i32) -> Attribute { + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENODEV => ReadState::Removed, + libc::ENOENT => ReadState::Unavailable, + _ => ReadState::Error, + }; + Attribute { state, value: String::new(), error } +} + +fn issue(result: &mut DeviceResources, source: &str, error: i32) { + result.issues.push(ResourceIssue { source: source.to_string(), error: failure(error) }); +} + +fn reject(result: &mut DeviceResources, source: &str) { + result.items.clear(); + issue(result, source, libc::EINVAL); +} + +/// `^(?:0[xX])?[0-9a-fA-F]{1,16}$` +fn hex_number(text: &str) -> Option { + let digits = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")).unwrap_or(text); + if (1..=16).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_hexdigit()) { + u64::from_str_radix(digits, 16).ok() + } else { + None + } +} + +/// `^[0-9]{1,10}$`, at most 32 bits. +fn decimal_number(text: &str) -> Option { + if (1..=10).contains(&text.len()) && text.bytes().all(|b| b.is_ascii_digit()) { + text.parse::().ok().filter(|v| *v <= 0xffff_ffff) + } else { + None + } +} + +pub fn parse_pci_resources(text: &str, source: &str) -> DeviceResources { + let mut result = DeviceResources::default(); + if text.len() > INPUT_LIMIT { + issue(&mut result, source, libc::EOVERFLOW); + return result; + } + let lines: Vec<&str> = text.split('\n').collect(); + let mut index = 0usize; + for (line, record) in lines.iter().enumerate() { + let record = record.trim(); + if record.is_empty() && line == lines.len() - 1 { + continue; + } + if index >= ROW_LIMIT { + issue(&mut result, source, libc::EOVERFLOW); + break; + } + let fields: Vec<&str> = record.split_whitespace().collect(); + let parsed = match fields[..] { + [a, b, c] => hex_number(a).zip(hex_number(b)).zip(hex_number(c)).map(|((s, e), f)| (s, e, f)), + _ => None, + }; + let Some((start, end, flags)) = parsed else { + // Do not shift slot numbers or invent ranges after malformed input. + reject(&mut result, source); + return result; + }; + let slot = index as i32; + index += 1; + if start == 0 && end == 0 && flags == 0 { + continue; // Empty resource slot. + } + let kind = match flags & TYPE_MASK { + IO => ResourceType::Io, + MEMORY => ResourceType::Memory, + _ => continue, + }; + let mut r = DeviceResource { pci: true, index: slot, start, end, flags, source: source.to_string(), ..resource(kind) }; + if flags & DISABLED != 0 { + r.allocation = Allocation::Disabled; + } else if flags & UNSET != 0 { + r.allocation = Allocation::Unassigned; + } else if start == 0 && end == 0 { + r.allocation = Allocation::Unavailable; + } else if end < start { + reject(&mut result, source); + return result; + } + result.items.push(r); + } + result +} + +/// `^(0[xX][0-9a-fA-F]+|0)-(0[xX][0-9a-fA-F]+|0)( window)?$` +fn pnp_range(value: &str) -> Option<(u64, u64, bool)> { + let (range, window) = match value.strip_suffix(" window") { + Some(range) => (range, true), + None => (value, false), + }; + let (a, b) = range.split_once('-')?; + let shape = |s: &str| s == "0" || (s.len() > 2 && (s.starts_with("0x") || s.starts_with("0X"))); + (shape(a) && shape(b)).then_some(())?; + Some((hex_number(a)?, hex_number(b)?, window)) +} + +pub fn parse_pnp_resources(text: &str, source: &str) -> DeviceResources { + let mut result = DeviceResources::default(); + if text.len() > INPUT_LIMIT { + issue(&mut result, source, libc::EOVERFLOW); + return result; + } + let lines: Vec<&str> = text.trim().split('\n').collect(); + if lines[0] != "state = active" && lines[0] != "state = disabled" { + issue(&mut result, source, libc::EINVAL); + return result; + } + result.pnp_state = lines[0][8..].to_string(); + for (line, text) in lines.iter().enumerate().skip(1) { + if line > ROW_LIMIT { + issue(&mut result, source, libc::EOVERFLOW); + break; + } + let Some((kind, value)) = text.trim().split_once(' ').filter(|(k, v)| matches!(*k, "io" | "mem" | "irq" | "dma" | "bus") && !v.is_empty()) else { + reject(&mut result, source); + return result; + }; + let kind = match kind { + "io" => ResourceType::Io, + "mem" => ResourceType::Memory, + "irq" => ResourceType::Irq, + "dma" => ResourceType::Dma, + _ => ResourceType::Bus, + }; + let mut r = DeviceResource { source: source.to_string(), ..resource(kind) }; + if value == "disabled" { + r.allocation = Allocation::Disabled; + } else if matches!(kind, ResourceType::Irq | ResourceType::Dma) { + if value == "-1" { + r.allocation = Allocation::Unassigned; + } else if let Some(number) = decimal_number(value) { + r.start = number; + } else { + reject(&mut result, source); + return result; + } + r.end = r.start; + } else { + match pnp_range(value).filter(|(s, e, _)| e >= s) { + Some((start, end, window)) => { + (r.start, r.end) = (start, end); + if window { + r.flags |= WINDOW; + } + if start == 0 && end == 0 { + r.allocation = Allocation::Unavailable; + } + } + None => { + reject(&mut result, source); + return result; + } + } + } + if result.pnp_state == "disabled" { + r.allocation = Allocation::Disabled; + } + result.items.push(r); + } + result +} + +/// Opens `name` inside an already-open directory without following a final symlink. Going +/// through the directory's own descriptor keeps a swapped path from redirecting the read. +fn open_in(dir: &File, name: &str, flags: i32) -> std::io::Result { + OpenOptions::new().read(true).custom_flags(flags | libc::O_CLOEXEC | libc::O_NOFOLLOW).open(format!("/proc/self/fd/{}/{name}", dir.as_raw_fd())) +} + +fn read_metadata(dir: &File, name: &str, limit: usize, cancel: Option<&AtomicBool>) -> Attribute { + let mut file = match open_in(dir, name, 0) { + Ok(f) => f, + Err(e) => return failure(e.raw_os_error().unwrap_or(libc::EIO)), + }; + let mut bytes = Vec::new(); + let mut buffer = [0u8; 4096]; + while bytes.len() <= limit { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return failure(libc::ECANCELED); + } + let wanted = buffer.len().min(limit + 1 - bytes.len()); + match file.read(&mut buffer[..wanted]) { + Ok(0) => break, + Ok(n) => bytes.extend_from_slice(&buffer[..n]), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return failure(e.raw_os_error().unwrap_or(libc::EIO)), + } + } + if bytes.len() > limit { + return failure(libc::EOVERFLOW); + } + Attribute { state: ReadState::Available, value: String::from_utf8_lossy(&bytes).into_owned(), error: 0 } +} + +fn record_read_error(result: &mut DeviceResources, source: &str, value: Attribute) { + if value.state != ReadState::Available && value.state != ReadState::Unavailable { + result.issues.push(ResourceIssue { source: source.to_string(), error: value }); + } +} + +/// Collects PCI or PnP resources for `device` from its open sysfs directory. Other +/// subsystems return nothing: no parent resources are guessed. +pub fn collect_device_resources(dir: &File, device: &Device, cancel: Option<&AtomicBool>) -> DeviceResources { + let mut result = DeviceResources::default(); + let cancelled = || cancel.is_some_and(|c| c.load(Ordering::Relaxed)); + let append = |result: &mut DeviceResources, parsed: DeviceResources| { + result.items.extend(parsed.items); + result.issues.extend(parsed.issues); + result.pnp_state = parsed.pnp_state; + }; + if device.subsystem == "pnp" { + let data = read_metadata(dir, "resources", INPUT_LIMIT, cancel); + let source = format!("{}/resources", device.path); + if data.state == ReadState::Available { + append(&mut result, parse_pnp_resources(&data.value, &source)); + } else { + record_read_error(&mut result, &source, data); + } + return result; + } + if device.subsystem != "pci" { + return result; + } + let ranges = read_metadata(dir, "resource", INPUT_LIMIT, cancel); + let source = format!("{}/resource", device.path); + if ranges.state == ReadState::Available { + append(&mut result, parse_pci_resources(&ranges.value, &source)); + } else { + record_read_error(&mut result, &source, ranges); + } + + let msi_source = format!("{}/msi_irqs", device.path); + let mut msi_observed = false; + match open_in(dir, "msi_irqs", libc::O_DIRECTORY) { + Ok(msi) => { + let mut vectors = Vec::new(); + let mut examined = 0; + match fs::read_dir(format!("/proc/self/fd/{}", msi.as_raw_fd())) { + Err(e) => issue(&mut result, &msi_source, e.raw_os_error().unwrap_or(libc::EIO)), + Ok(entries) => { + for entry in entries { + if cancelled() { + break; + } + let entry = match entry { + Ok(e) => e, + Err(e) => { + issue(&mut result, &msi_source, e.raw_os_error().unwrap_or(libc::EIO)); + break; + } + }; + examined += 1; + if examined > VECTOR_LIMIT { + issue(&mut result, &msi_source, libc::EOVERFLOW); + break; + } + let filename = entry.file_name().to_string_lossy().into_owned(); + let Some(number) = decimal_number(&filename).filter(|n| *n != 0) else { + issue(&mut result, &msi_source, libc::EINVAL); + continue; + }; + msi_observed = true; + let mode = read_metadata(&msi, &filename, 32, cancel); + let vector_source = format!("{msi_source}/{filename}"); + if mode.state != ReadState::Available { + // Even a vanished vector is an explicit incomplete observation. + result.issues.push(ResourceIssue { source: vector_source, error: mode }); + continue; + } + let value = mode.value.trim(); + if value != "msi" && value != "msix" { + issue(&mut result, &vector_source, libc::EINVAL); + continue; + } + vectors.push(DeviceResource { + start: number, + end: number, + mode: value.to_string(), + source: vector_source, + ..resource(ResourceType::Irq) + }); + } + } + } + vectors.sort_by_key(|v| v.start); + result.items.extend(vectors); + } + Err(e) if e.raw_os_error() != Some(libc::ENOENT) => { + issue(&mut result, &msi_source, e.raw_os_error().unwrap_or(libc::EIO)); + } + Err(_) => {} + } + + // irq may be the first MSI vector or the legacy INTx number during MSI-X. Prefer the + // complete msi_irqs allocation; never invent an active INTx IRQ. + if !msi_observed && !cancelled() { + let irq_source = format!("{}/irq", device.path); + let irq = read_metadata(dir, "irq", 32, cancel); + if irq.state != ReadState::Available { + record_read_error(&mut result, &irq_source, irq); + } else { + match decimal_number(irq.value.trim()) { + None => issue(&mut result, &irq_source, libc::EINVAL), + Some(0) => {} + Some(number) => { + result.items.push(DeviceResource { start: number, end: number, mode: "reported".into(), source: irq_source, ..resource(ResourceType::Irq) }) + } + } + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pci_table_keeps_slots_types_and_64_bit_addresses() { + let pci = parse_pci_resources( + "0x00000007c0000000 0x00000007cfffffff 0x00102200\n\ + 0x000000000000ef00 0x000000000000efff 0x00000100\n\ + 0 0 0\n\ + 0 0xfff 0x20000200\n\ + 0x1000 0x1fff 0x10000200\n\ + 0 0 0x200\n", + "fixture/resource", + ); + assert!(pci.issues.is_empty()); + assert_eq!(pci.items.len(), 5); + assert_eq!((pci.items[0].start, pci.items[0].end), (0x7_c000_0000, 0x7_cfff_ffff)); + assert_eq!((pci.items[1].kind, pci.items[1].start), (ResourceType::Io, 0xef00)); + assert_eq!((pci.items[2].index, pci.items[2].allocation), (3, Allocation::Unassigned)); + assert_eq!(pci.items[3].allocation, Allocation::Disabled); + assert_eq!(pci.items[4].allocation, Allocation::Unavailable); + assert_eq!(pci.items[0].source, "fixture/resource"); + } + + #[test] + fn malformed_pci_input_is_rejected_whole() { + let bad = parse_pci_resources("0x1000 0x1fff 0x200\ninvalid\n", "fixture/resource"); + assert!(bad.items.is_empty()); + assert_eq!(bad.issues.len(), 1); + for text in ["0x2000 0x1000 0x200\n", "0x10000000000000000 0x1fff 0x200\n"] { + assert!(!parse_pci_resources(text, "fixture/resource").issues.is_empty(), "{text}"); + } + assert!(!parse_pci_resources(&"x".repeat(65537), "fixture/resource").issues.is_empty()); + assert!(!parse_pci_resources("0 0 0\n", "fixture/resource").has_information(), "empty slots alone"); + } + + #[test] + fn pnp_assignments_are_typed() { + let pnp = parse_pnp_resources("state = active\nio 0x60-0x60\nirq 0\nmem 0xf0000000-0xffffffff window\nirq disabled\ndma 4\n", "fixture/resources"); + assert!(pnp.issues.is_empty()); + assert_eq!(pnp.items.len(), 5); + assert_eq!((pnp.items[1].kind, pnp.items[1].start, pnp.items[1].allocation), (ResourceType::Irq, 0, Allocation::Assigned)); + assert_eq!(pnp.items[2].flags, WINDOW); + assert_eq!(pnp.items[3].allocation, Allocation::Disabled); + let inactive = parse_pnp_resources("state = disabled\nio 0x60-0x64\n", "fixture/resources"); + assert_eq!((inactive.items.len(), inactive.items[0].allocation), (1, Allocation::Disabled)); + assert!(!parse_pnp_resources("state = active\nmem 0x20-0x10\n", "fixture/resources").issues.is_empty()); + assert!(!parse_pnp_resources("irq 7\n", "fixture/resources").issues.is_empty()); + let none = parse_pnp_resources("state = active\nirq -1\nio 0-0\n", "f"); + assert_eq!((none.items[0].allocation, none.items[1].allocation), (Allocation::Unassigned, Allocation::Unavailable)); + } + + fn pci_device(dir: &std::path::Path) -> Device { + Device { path: dir.to_string_lossy().into_owned(), subsystem: "pci".into(), ..Default::default() } + } + + #[test] + fn collect_prefers_msi_vectors_over_the_legacy_irq() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::write(root.join("resource"), "0x1000 0x1fff 0x200\n").unwrap(); + fs::write(root.join("irq"), "16\n").unwrap(); + fs::create_dir(root.join("msi_irqs")).unwrap(); + fs::write(root.join("msi_irqs/130"), "msix\n").unwrap(); + fs::write(root.join("msi_irqs/129"), "msix\n").unwrap(); + fs::write(root.join("msi_irqs/bad"), "msix\n").unwrap(); + let dir = File::open(root).unwrap(); + let found = collect_device_resources(&dir, &pci_device(root), None); + let irqs: Vec<_> = found.items.iter().filter(|r| r.kind == ResourceType::Irq).map(|r| (r.start, r.mode.as_str())).collect(); + assert_eq!(irqs, [(129, "msix"), (130, "msix")], "sorted MSI vectors, no INTx guess"); + assert!(found.issues.iter().any(|i| i.source.ends_with("msi_irqs") && i.error.error == libc::EINVAL)); + assert_eq!(found.items.iter().filter(|r| r.pci).count(), 1); + } + + #[test] + fn collect_falls_back_to_reported_irq_and_ignores_irq_zero() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::write(root.join("irq"), "16\n").unwrap(); + let dir = File::open(root).unwrap(); + let found = collect_device_resources(&dir, &pci_device(root), None); + assert_eq!(found.items.len(), 1); + assert_eq!((found.items[0].start, found.items[0].mode.as_str()), (16, "reported")); + assert!(found.issues.is_empty(), "missing resource file is not an issue"); + fs::write(root.join("irq"), "0\n").unwrap(); + assert!(collect_device_resources(&dir, &pci_device(root), None).items.is_empty()); + fs::write(root.join("irq"), "x\n").unwrap(); + assert_eq!(collect_device_resources(&dir, &pci_device(root), None).issues[0].error.error, libc::EINVAL); + } + + #[test] + fn collect_pnp_and_other_subsystems() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("resources"), "state = active\nio 0x60-0x60\n").unwrap(); + let dir = File::open(tmp.path()).unwrap(); + let pnp = Device { path: "/sys/x".into(), subsystem: "pnp".into(), ..Default::default() }; + let found = collect_device_resources(&dir, &pnp, None); + assert_eq!((found.items.len(), found.pnp_state.as_str()), (1, "active")); + let other = Device { subsystem: "usb".into(), ..Default::default() }; + assert!(!collect_device_resources(&dir, &other, None).has_information()); + } +} diff --git a/crates/qldm-core/src/search.rs b/crates/qldm-core/src/search.rs new file mode 100644 index 0000000..abcd3db --- /dev/null +++ b/crates/qldm-core/src/search.rs @@ -0,0 +1,458 @@ +//! Quick and deep device search, ported from `src/devices/devicesearch.{h,cpp}`. +//! +//! A search document is the flat list of labelled values a device exposes: names, identifiers, +//! raw udev/sysfs values (with decimal/hex aliases for documented numeric fields), resources, +//! storage metadata and UEFI bytes. Deep search builds these on a worker, caches them per +//! device generation (bounded to 64 MiB), and reports matches in batches. Camera capabilities, +//! desktop display configuration and UDisks2 health are deliberately not indexed, and a snapshot +//! whose read was invalidated contributes no partial properties. +use crate::i18n::{arg, tr}; +use crate::label::device_display_name; +use crate::model::{category_label, Attribute, Device, ReadState}; +use crate::properties::{collect_device_properties, DeviceProperties}; +use crate::propertytext::{property_entries, property_read_value}; +use crate::resources::{Allocation, ResourceType}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +const CACHE_LIMIT_BYTES: usize = 64 * 1024 * 1024; +const BATCH_INTERVAL: Duration = Duration::from_millis(100); +const BATCH_MATCHES: usize = 128; + +#[derive(Clone, Debug)] +pub struct SearchRecord { + pub device: Device, + /// The exact label the source model currently presents. + pub label: String, + pub grouped_paths: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchField { + pub label: String, + pub value: String, +} + +pub type SearchDocument = Vec; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchMatch { + pub path: String, + pub generation: u64, + pub field: String, +} + +/// Progress and matches of one deep-search request. `matches` carries only the matches found +/// since the previous batch; the counters are cumulative. +#[derive(Clone, Debug, Default)] +pub struct SearchBatch { + pub request: u64, + pub matches: Vec, + pub done: usize, + pub total: usize, + /// Records whose properties could not be fully read; their results may be incomplete. + pub unavailable: usize, +} + +/// A number as decimal, `0x` hex and zero-padded 16-digit hex, so any notation finds it. +fn numbers(value: u64) -> String { + format!("{value} 0x{value:x} {value:016x}") +} + +/// Aliases for documented numeric fields only; serial numbers and names stay literal. +fn numeric_aliases(key: &str, value: &str, subsystem: &str) -> String { + const HEX_KEYS: [&str; 10] = + ["idVendor", "idProduct", "bcdDevice", "vendor_id", "subsystem_id", "revision_id", "ID_VENDOR_ID", "ID_MODEL_ID", "PCI_CLASS", "flags"]; + const PCI_HEX: [&str; 6] = ["vendor", "device", "subsystem_vendor", "subsystem_device", "class", "revision"]; + const DECIMAL_KEYS: [&str; 6] = ["busnum", "devnum", "ifindex", "index", "authorized", "present"]; + let mut base = if HEX_KEYS.contains(&key) || (subsystem == "pci" && PCI_HEX.contains(&key)) { + 16 + } else if DECIMAL_KEYS.contains(&key) { + 10 + } else { + 0 + }; + let components: Vec<&str> = match key { + "PCI_ID" | "PCI_SUBSYS_ID" => { + base = 16; + value.split(':').collect() + } + "PRODUCT" => { + base = 16; + value.split('/').collect() + } + _ => vec![value], + }; + if base == 0 { + return String::new(); + } + components + .iter() + .filter_map(|part| { + let part = part.trim(); + let digits = if base == 16 { part.strip_prefix("0x").or_else(|| part.strip_prefix("0X")).unwrap_or(part) } else { part }; + u64::from_str_radix(digits, base).ok() + }) + .map(numbers) + .collect::>() + .join(" ") +} + +/// Builds the searchable text of one device from its inventory record and property snapshot. +pub fn device_search_document(record: &SearchRecord, p: &DeviceProperties) -> SearchDocument { + let mut doc: SearchDocument = Vec::new(); + let d = &record.device; + let mut add = |doc: &mut SearchDocument, label: &str, value: &str| { + if !value.is_empty() { + doc.push(SearchField { label: label.to_string(), value: value.to_string() }); + } + }; + let attribute = |doc: &mut SearchDocument, add: &mut dyn FnMut(&mut SearchDocument, &str, &str), key: &str, a: &Attribute, source: &str| { + add(doc, key, &if a.state == ReadState::Available { a.value.clone() } else { property_read_value(a) }); + if a.error != 0 { + add(doc, key, &a.error.to_string()); + } + if a.state == ReadState::Available { + add(doc, key, &numeric_aliases(key.rsplit('/').next().unwrap_or(key), &a.value, &d.subsystem)); + } + add(doc, key, source); + }; + let name_label = tr("Device name"); + add(&mut doc, &name_label, &record.label); + add(&mut doc, &name_label, &device_display_name(d, false)); + add(&mut doc, &tr("Original name"), &d.name); + add(&mut doc, &tr("Name source"), &d.name_source); + add(&mut doc, &tr("Category"), &category_label(d.category)); + add(&mut doc, &tr("Category"), d.category); + add(&mut doc, &tr("Full kernel path"), &d.path); + add(&mut doc, &tr("Parent device path"), &d.parent_path); + add(&mut doc, &tr("Kernel name"), &d.sysname); + add(&mut doc, &tr("Bus / subsystem"), &d.subsystem); + add(&mut doc, "DEVTYPE", &d.devtype); + add(&mut doc, &tr("Bound kernel driver"), &d.driver); + add(&mut doc, &tr("Represented by"), &d.represented_by_path); + add(&mut doc, &tr("Grouped records"), &record.grouped_paths); + for (key, value) in &d.properties { + let a = Attribute { state: ReadState::Available, value: value.clone(), error: 0 }; + attribute(&mut doc, &mut add, key, &a, d.property_sources.get(key).map_or("", String::as_str)); + } + for (key, a) in &d.attributes { + attribute(&mut doc, &mut add, key, a, ""); + } + // An invalidated read must not contribute partially collected properties. + if p.state != ReadState::Available { + let status = Attribute { state: p.state, value: String::new(), error: p.error }; + add(&mut doc, &tr("Device status"), &property_read_value(&status)); + return doc; + } + for (key, a) in &p.values { + attribute(&mut doc, &mut add, key, a, p.sources.get(key).map_or("", String::as_str)); + } + for entry in property_entries(p, false) { + add(&mut doc, &entry.label, &entry.value); + add(&mut doc, &entry.label, &entry.source); + } + if p.values.get("efi/hex").is_some_and(|a| a.state == ReadState::Available) { + let compact: String = p.efi_bytes.iter().map(|b| format!("{b:02x}")).collect(); + add(&mut doc, &tr("UEFI variable — hex dump"), &compact); + } + add(&mut doc, &tr("PnP device state"), &p.resources.pnp_state); + if p.resources.pnp_state == "disabled" { + add(&mut doc, &tr("PnP device state"), &tr("PnP device disabled")); + } + if p.efi_truncated { + add(&mut doc, &tr("UEFI variable — hex dump"), &tr("Truncated: first 65536 bytes shown.")); + } + for r in &p.resources.items { + let label = tr(match r.kind { + ResourceType::Memory => "Memory range", + ResourceType::Io => "I/O range", + ResourceType::Irq => "IRQ", + ResourceType::Dma => "DMA channel", + ResourceType::Bus => "Bus range", + }); + add(&mut doc, &label, &r.source); + add(&mut doc, &label, &format!("{} {} {}", numbers(r.start), numbers(r.end), numbers(r.flags))); + let width = if r.kind == ResourceType::Memory { 16 } else { 4 }; + add(&mut doc, &label, &format!("{:0width$x} – {:0width$x}", r.start, r.end)); + if r.pci { + add( + &mut doc, + &label, + &match r.index { + 0..=5 => arg(tr("BAR %1"), &r.index.to_string()), + 6 => tr("Expansion ROM"), + _ => arg(tr("PCI resource %1"), &r.index.to_string()), + }, + ); + } + match r.allocation { + Allocation::Assigned => {} + Allocation::Unassigned => add(&mut doc, &label, &tr("Unassigned")), + Allocation::Disabled => add(&mut doc, &label, &tr("Disabled")), + Allocation::Unavailable => add(&mut doc, &label, &tr("Address unavailable (zeroed or masked)")), + } + for (bit, text) in [(0x2000, "Prefetchable"), (0x4000, "Read-only"), (0x10_0000, "64-bit memory"), (0x20_0000, "Bridge window")] { + if r.flags & bit != 0 { + add(&mut doc, &label, &tr(text)); + } + } + add(&mut doc, &label, if r.mode == "msix" { "MSI-X" } else { &r.mode }); + if r.mode == "reported" { + add(&mut doc, &label, &tr("Reported irq attribute; active mode not established")); + } + } + for issue in &p.resources.issues { + attribute(&mut doc, &mut add, &tr("Resource metadata"), &issue.error, &issue.source); + } + doc +} + +/// The label of the first field whose value or label contains `query` (case-insensitive). +pub fn search_document_match(document: &SearchDocument, query: &str) -> Option { + if query.is_empty() { + return None; + } + let needle = query.to_lowercase(); + document.iter().find(|f| f.value.to_lowercase().contains(&needle) || f.label.to_lowercase().contains(&needle)).map(|f| f.label.clone()) +} + +struct Cached { + generation: u64, + document: SearchDocument, + unavailable: bool, +} + +/// Deep-search state that outlives one request: the per-device document cache, which is dropped +/// whenever the inventory revision changes. Run it on a worker thread; it stops between records +/// once `cancel` is set. +#[derive(Default)] +pub struct DeepSearchWorker { + cache: HashMap, + cache_bytes: usize, + cached_revision: u64, +} + +impl DeepSearchWorker { + pub fn new() -> Self { + Self::default() + } + + /// Searches `records` for `query`, calling `emit` with a batch whenever 100 ms have passed or + /// 128 matches accumulated, and once at the end (unless cancelled). + pub fn run(&mut self, records: &[SearchRecord], query: &str, revision: u64, request: u64, cancel: &AtomicBool, mut emit: impl FnMut(SearchBatch)) { + if revision != self.cached_revision { + self.cache.clear(); + self.cache_bytes = 0; + self.cached_revision = revision; + } + let mut batch = SearchBatch { request, total: records.len(), ..Default::default() }; + let mut timer = Instant::now(); + for record in records { + if cancel.load(Ordering::Relaxed) { + return; + } + let path = &record.device.path; + let (document, unavailable) = match self.cache.get(path).filter(|c| c.generation == record.device.generation) { + Some(cached) => (cached.document.clone(), cached.unavailable), + None => { + let properties = collect_device_properties(&record.device, false, Some(cancel)); + if cancel.load(Ordering::Relaxed) { + return; + } + let unavailable = properties.state != ReadState::Available + || !properties.resources.issues.is_empty() + || properties.values.values().any(|v| matches!(v.state, ReadState::PermissionDenied | ReadState::Error)); + let document = device_search_document(record, &properties); + let bytes: usize = document.iter().map(|f| 2 * (f.label.len() + f.value.len()) + std::mem::size_of::()).sum(); + // Bound cached payload to 64 MiB. Uncached records are still fully searched. + if self.cache_bytes + bytes <= CACHE_LIMIT_BYTES { + self.cache.insert(path.clone(), Cached { generation: record.device.generation, document: document.clone(), unavailable }); + self.cache_bytes += bytes; + } + (document, unavailable) + } + }; + if let Some(field) = search_document_match(&document, query) { + batch.matches.push(SearchMatch { path: path.clone(), generation: record.device.generation, field }); + } + batch.done += 1; + if unavailable { + batch.unavailable += 1; + } + if timer.elapsed() >= BATCH_INTERVAL || batch.matches.len() >= BATCH_MATCHES { + emit(batch.clone()); + batch.matches.clear(); + timer = Instant::now(); + } + } + emit(batch); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Attribute; + use crate::properties::DeviceProperties; + use crate::resources::DeviceResource; + use crate::storage::{Format, StorageEntity, StorageField, StorageProperties}; + + fn fixture() -> (Device, DeviceProperties) { + let mut device = Device { + path: "/sys/devices/fixture".into(), + incarnation: "fixture:1".into(), + name: "Visible device".into(), + category: "other", + subsystem: "pci".into(), + ..Default::default() + }; + device.properties.insert("ID_SERIAL".into(), "CaseSensitiveSerial".into()); + let mut properties = DeviceProperties { device: device.clone(), state: ReadState::Available, ..Default::default() }; + properties.values.insert("sysfs/vendor".into(), Attribute { state: ReadState::Available, value: "0x8086".into(), error: 0 }); + properties.resources.items.push(DeviceResource { + kind: ResourceType::Io, + start: 0x300, + end: 0x31f, + source: "/sys/devices/fixture/resource".into(), + index: -1, + ..Default::default() + }); + (device, properties) + } + fn record(device: &Device) -> SearchRecord { + SearchRecord { device: device.clone(), label: "Visible device".into(), grouped_paths: String::new() } + } + + #[test] + fn raw_values_are_case_insensitive_and_numbers_have_aliases() { + let (device, properties) = fixture(); + let document = device_search_document(&record(&device), &properties); + assert!(search_document_match(&document, "casesensitiveserial").is_some(), "raw metadata is case-insensitive"); + assert!(search_document_match(&document, "32902").is_some(), "PCI vendor has a decimal alias"); + assert!(search_document_match(&document, "0x8086").is_some()); + assert_eq!(search_document_match(&document, "768").as_deref(), Some("I/O range")); + assert!(search_document_match(&document, "0000000000000300").is_some(), "padded hex kept"); + assert!(search_document_match(&document, "").is_none()); + assert!(search_document_match(&document, "definitely absent").is_none()); + } + + #[test] + fn numeric_aliases_apply_only_to_documented_fields() { + assert_eq!(numeric_aliases("PCI_ID", "8086:7740", "pci"), "32902 0x8086 0000000000008086 30528 0x7740 0000000000007740"); + assert_eq!(numeric_aliases("PRODUCT", "18/6cb/cfc6/100", "input").split(' ').count(), 12); + assert_eq!(numeric_aliases("busnum", "7", "usb"), "7 0x7 0000000000000007"); + assert_eq!(numeric_aliases("vendor", "0x8086", "pci").split(' ').next(), Some("32902")); + assert_eq!(numeric_aliases("vendor", "0x8086", "usb"), "", "pci-only hex keys"); + assert_eq!(numeric_aliases("ID_SERIAL", "123456", "usb"), "", "serials stay literal"); + assert_eq!(numeric_aliases("busnum", "x", "usb"), ""); + } + + #[test] + fn uefi_bytes_are_searchable_as_spaced_compact_and_printable() { + let (device, _) = fixture(); + let efi = Device { subsystem: "efivarfs".into(), ..device }; + let mut properties = DeviceProperties { device: efi.clone(), state: ReadState::Available, efi_bytes: vec![0x41, 0x42, 0xff], ..Default::default() }; + properties.values.insert("efi/hex".into(), Attribute { state: ReadState::Available, ..Default::default() }); + let document = device_search_document(&SearchRecord { device: efi, label: "EFI fixture".into(), grouped_paths: String::new() }, &properties); + for needle in ["41 42 ff", "AB.", "4142ff"] { + assert!(search_document_match(&document, needle).is_some(), "{needle}"); + } + } + + #[test] + fn storage_is_searchable_but_invalid_snapshots_contribute_nothing() { + let (device, mut storage_fixture) = fixture(); + let mut volume = StorageEntity { id: "/sys/devices/storage-fixture".into(), ..Default::default() }; + let field = |id: &str, label: &'static str, value: &str, source: &str, format| StorageField { + id: id.into(), + label, + value: if value.is_empty() { Attribute::default() } else { Attribute { state: ReadState::Available, value: value.into(), error: 0 } }, + source: source.into(), + format, + }; + volume.fields.push(field("capacity", "Block capacity", "18446744073709551615", "fixture/size", Format::Bytes)); + volume.fields.push(field("health", "Cached NVMe critical warnings", "", "fixture/cache", Format::NvmeHealth)); + volume.fields.push(field("mounts", "Mount points", "/fixture mount", "fixture/mountinfo", Format::Text)); + storage_fixture.storage = StorageProperties { applicable: true, entities: vec![volume], ..Default::default() }; + let entries = property_entries(&storage_fixture, false); + assert!(entries.iter().any(|e| e.id.ends_with("/health") && e.value == "Unavailable"), "missing health is never healthy"); + assert!( + entries.iter().any(|e| e.id.ends_with("/capacity") && e.value.ends_with(" bytes") && !e.value.contains('(')), + "exact bytes above the signed range" + ); + let document = device_search_document(&record(&device), &storage_fixture); + assert!(search_document_match(&document, "/fixture mount").is_some(), "mounts are searchable"); + storage_fixture.state = ReadState::Removed; + let removed = device_search_document(&record(&device), &storage_fixture); + assert!(search_document_match(&removed, "/fixture mount").is_none(), "removed instances add no partial storage results"); + let (_, mut properties) = fixture(); + properties.state = ReadState::Removed; + let invalid = device_search_document(&record(&device), &properties); + assert!(search_document_match(&invalid, "32902").is_none(), "invalidated properties add nothing"); + assert!(search_document_match(&invalid, "casesensitiveserial").is_some(), "the last inventory stays searchable"); + assert!(search_document_match(&invalid, "Device removed").is_some()); + } + + #[test] + fn property_entry_statuses_stay_independent_of_displayed_text() { + use crate::propertytext::details_property_status; + let (_, mut status_fixture) = fixture(); + status_fixture.device.properties.clear(); + status_fixture.values.remove("sysfs/vendor"); + status_fixture.values.insert("sysfs/model".into(), Attribute { state: ReadState::Available, value: "Unavailable".into(), error: 0 }); + status_fixture.values.insert("sysfs/serial".into(), Attribute::default()); + status_fixture.values.insert("sysfs/revision".into(), Attribute { state: ReadState::PermissionDenied, value: String::new(), error: 13 }); + for entry in property_entries(&status_fixture, false) { + match entry.id.as_str() { + "sysfs/model" => assert!(details_property_status(&entry).is_empty(), "a literal status-like value stays meaningful"), + "sysfs/serial" => assert!(entry.state == ReadState::Unavailable && details_property_status(&entry) == "unavailable"), + "sysfs/revision" => assert!(entry.state == ReadState::PermissionDenied && details_property_status(&entry) == "permission denied"), + "identifiers" => assert_eq!(entry.state, ReadState::Unavailable, "missing derived identifiers are unavailable"), + _ => {} + } + } + let mut efi_status = status_fixture.clone(); + efi_status.device.subsystem = "efivarfs".into(); + let identifiers = property_entries(&efi_status, false).into_iter().find(|e| e.id == "identifiers").unwrap(); + assert!(identifiers.state == ReadState::NotApplicable && details_property_status(&identifiers) == "not applicable"); + } + + #[test] + fn deep_search_batches_caches_by_generation_and_cancels() { + let devices = crate::scan().expect("scan"); + let wanted = devices.iter().find(|d| d.subsystem == "pci" && !d.hidden).expect("a pci device").clone(); + let mut records: Vec = devices + .iter() + .filter(|d| !d.hidden) + .take(6) + .map(|d| SearchRecord { device: d.clone(), label: device_display_name(d, false), grouped_paths: String::new() }) + .collect(); + if !records.iter().any(|r| r.device.path == wanted.path) { + records.push(SearchRecord { device: wanted.clone(), label: "x".into(), grouped_paths: String::new() }); + } + let cancel = AtomicBool::new(false); + let mut worker = DeepSearchWorker::new(); + let mut batches = Vec::new(); + worker.run(&records, &wanted.sysname, 1, 7, &cancel, |b| batches.push(b)); + let last = batches.last().expect("final batch"); + assert_eq!((last.request, last.total, last.done), (7, records.len(), records.len())); + let hits: Vec<_> = batches.iter().flat_map(|b| b.matches.iter()).collect(); + assert!(hits.iter().any(|m| m.path == wanted.path), "the kernel name finds its device"); + assert_eq!(worker.cache.len(), records.len(), "documents cached per device"); + + // Same revision and generations: served from the cache, same answer. + let mut again = Vec::new(); + worker.run(&records, &wanted.sysname, 1, 8, &cancel, |b| again.push(b)); + assert_eq!(again.iter().map(|b| b.matches.len()).sum::(), hits.len()); + // A new revision drops the cache. + worker.run(&[], "x", 2, 9, &cancel, |_| {}); + assert!(worker.cache.is_empty()); + + let stop = AtomicBool::new(true); + let mut none = Vec::new(); + worker.run(&records, "x", 3, 10, &stop, |b| none.push(b)); + assert!(none.is_empty(), "a cancelled run publishes nothing"); + } +} diff --git a/crates/qldm-core/src/secureboot.rs b/crates/qldm-core/src/secureboot.rs new file mode 100644 index 0000000..df63063 --- /dev/null +++ b/crates/qldm-core/src/secureboot.rs @@ -0,0 +1,381 @@ +//! Secure Boot state and Machine Owner Key certificates, ported from +//! `src/devices/secureboot.cpp`. Everything is read from firmware variables the kernel already +//! exposes; certificates are parsed with `x509-parser` instead of Qt's SSL layer. A truncated +//! or malformed list never yields an apparently complete certificate list. +use crate::dirfd::{available, read_limited}; +use crate::model::{Attribute, ReadState}; +use std::fs::OpenOptions; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use x509_parser::prelude::*; + +const LIMIT: usize = 1024 * 1024; +/// EFI_CERT_X509_GUID in EFI's little-endian GUID representation. +const X509_GUID: [u8; 16] = [0xa1, 0x59, 0xc0, 0xa5, 0xe4, 0x94, 0xa7, 0x4a, 0x87, 0xb5, 0xab, 0x15, 0x5c, 0x2b, 0xf0, 0x72]; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MokCertificate { + pub subject: String, + pub issuer: String, + pub expires: String, + pub subject_display: String, + pub issuer_display: String, +} + +#[derive(Clone, Debug, Default)] +pub struct MokCertificates { + pub status: Attribute, + pub certificates: Vec, + pub other_signatures: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct SecureBootInformation { + pub state: Attribute, + pub mok: MokCertificates, + pub state_source: String, + pub mok_source: String, +} + +fn failure(error: i32) -> Attribute { + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENOENT => ReadState::Unavailable, + _ => ReadState::Error, + }; + Attribute { state, value: String::new(), error } +} + +/// Reads a firmware file (efivarfs files carry a 4-byte attribute prefix that is dropped). +fn read_binary(path: &Path, efivar: bool) -> Result, Attribute> { + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(path) + .map_err(|e| failure(e.raw_os_error().unwrap_or(libc::EIO)))?; + let mut bytes = read_limited(&mut file, LIMIT, &|| false).map_err(failure)?; + if bytes.len() > LIMIT { + return Err(failure(libc::EOVERFLOW)); + } + if efivar { + if bytes.len() < 4 { + return Err(failure(libc::EINVAL)); + } + bytes.drain(..4); + } + Ok(bytes) +} + +/// `SecureBoot` is exactly one byte, 0 or 1; anything else is an error, never "disabled". +pub fn parse_secure_boot(payload: &[u8]) -> Attribute { + match payload { + [0] => available("disabled"), + [1] => available("enabled"), + _ => failure(libc::EINVAL), + } +} + +/// Short attribute names in the order Qt reports them (alphabetical, case-sensitive). +fn attribute_name(oid: &str) -> String { + match oid { + "2.5.4.3" => "CN", + "2.5.4.6" => "C", + "2.5.4.7" => "L", + "2.5.4.8" => "ST", + "2.5.4.9" => "street", + "2.5.4.10" => "O", + "2.5.4.11" => "OU", + "1.2.840.113549.1.9.1" => "emailAddress", + "0.9.2342.19200300.100.1.25" => "DC", + "2.5.4.5" => "serialNumber", + other => other, + } + .to_string() +} + +fn name_fields(name: &X509Name<'_>) -> Vec<(String, String)> { + let mut fields = Vec::new(); + for attribute in name.iter_attributes() { + let value = attribute.as_str().map(str::to_string).unwrap_or_default(); + fields.push((attribute_name(&attribute.attr_type().to_id_string()), value)); + } + fields.sort_by(|a, b| a.0.cmp(&b.0)); + fields +} + +/// `O=…, CN=…` style text: every attribute, grouped by sorted name, values simplified. +fn full_name(name: &X509Name<'_>) -> String { + name_fields(name).iter().map(|(key, value)| format!("{key}={}", value.split_whitespace().collect::>().join(" "))).collect::>().join(", ") +} + +/// Organizational unit, organization and common name, which identify distro certificates +/// better than a generic CN, joined with an em dash. +fn display_name(name: &X509Name<'_>) -> String { + let fields = name_fields(name); + let mut parts: Vec = Vec::new(); + for wanted in ["OU", "O", "CN"] { + for (_, value) in fields.iter().filter(|(key, _)| key == wanted) { + let text = value.split_whitespace().collect::>().join(" "); + if !text.is_empty() && !parts.contains(&text) { + parts.push(text); + } + } + } + parts.join(" — ") +} + +fn expiry_iso(time: &ASN1Time) -> String { + let t = time.to_datetime(); + format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second()) +} + +fn invalid(error: i32) -> MokCertificates { + MokCertificates { status: failure(error), ..Default::default() } +} + +/// Parses concatenated `EFI_SIGNATURE_LIST`s: X.509 entries become certificates, other +/// signature types (hashes) are only counted. +pub fn parse_mok_certificates(payload: &[u8]) -> MokCertificates { + if payload.len() > LIMIT { + return invalid(libc::EOVERFLOW); + } + let mut result = MokCertificates::default(); + let (mut offset, mut lists, mut signatures) = (0usize, 0, 0); + let le = |at: usize| u32::from_le_bytes(payload[at..at + 4].try_into().expect("4 bytes")) as usize; + while offset < payload.len() { + lists += 1; + if lists > 256 { + return invalid(libc::EOVERFLOW); + } + if payload.len() - offset < 28 { + return invalid(libc::EINVAL); + } + let (size, header_size, signature_size) = (le(offset + 16), le(offset + 20), le(offset + 24)); + if size < 28 || size > payload.len() - offset || header_size > size - 28 || signature_size <= 16 { + return invalid(libc::EINVAL); + } + if (size - 28 - header_size) % signature_size != 0 { + return invalid(libc::EINVAL); + } + let is_x509 = payload[offset..offset + 16] == X509_GUID; + let mut position = offset + 28 + header_size; + while position < offset + size { + signatures += 1; + if signatures > 1024 { + return invalid(libc::EOVERFLOW); + } + if !is_x509 { + result.other_signatures += 1; + position += signature_size; + continue; + } + if result.certificates.len() >= 256 || signature_size > 65536 { + return invalid(libc::EOVERFLOW); + } + // Skip the signature-owner GUID. + let der = &payload[position + 16..position + signature_size]; + let Ok((rest, certificate)) = X509Certificate::from_der(der) else { return invalid(libc::EINVAL) }; + // Re-encoding must reproduce the input exactly: no trailing or ignored bytes. + if !rest.is_empty() || certificate.as_ref() != der { + return invalid(libc::EINVAL); + } + result.certificates.push(MokCertificate { + subject: full_name(certificate.subject()), + issuer: full_name(certificate.issuer()), + expires: expiry_iso(&certificate.validity().not_after), + subject_display: display_name(certificate.subject()), + issuer_display: display_name(certificate.issuer()), + }); + position += signature_size; + } + offset += size; + } + result.status = available(""); + result +} + +/// Reads Secure Boot and the MOK list below `efi_root` (normally `/sys/firmware/efi`). +pub fn collect_secure_boot_information_at(efi_root: &Path) -> SecureBootInformation { + let mut result = SecureBootInformation::default(); + let variables = efi_root.join("efivars"); + let source = |name: &str| -> PathBuf { variables.join(name) }; + let state_path = source("SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c"); + result.state_source = state_path.to_string_lossy().into_owned(); + result.state = match read_binary(&state_path, true) { + Ok(bytes) => parse_secure_boot(&bytes), + Err(state) => state, + }; + // The kernel's MOK configuration-table copy contains the complete runtime list. + let table_path = efi_root.join("mok-variables/MokListRT"); + result.mok_source = table_path.to_string_lossy().into_owned(); + let mut payload = read_binary(&table_path, false); + let table_failure = payload.as_ref().err().cloned(); + if let Some(table) = table_failure.filter(|a| a.error == libc::ENOENT || a.state == ReadState::PermissionDenied) { + let variable_path = source("MokListRT-605dab50-e046-4300-abb6-3dd810dd8b23"); + result.mok_source = variable_path.to_string_lossy().into_owned(); + payload = read_binary(&variable_path, true); + if matches!(&payload, Err(a) if a.error == libc::ENOENT) && table.state == ReadState::PermissionDenied { + payload = Err(table); + result.mok_source = table_path.to_string_lossy().into_owned(); + } + // A split runtime list cannot be presented as a complete certificate list. + let split = read_binary(&source("MokListRT1-605dab50-e046-4300-abb6-3dd810dd8b23"), true); + let split_exists = match &split { + Ok(_) => true, + Err(a) => a.error != libc::ENOENT, + }; + if payload.is_ok() && split_exists { + payload = Err(failure(libc::EOVERFLOW)); + } + } + result.mok = match payload { + Ok(bytes) => parse_mok_certificates(&bytes), + Err(status) => MokCertificates { status, ..Default::default() }, + }; + result +} + +pub fn collect_secure_boot_information() -> SecureBootInformation { + collect_secure_boot_information_at(Path::new("/sys/firmware/efi")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE_DER: &str = "MIIBGzCBwaADAgECAgEBMAoGCCqGSM49BAMCMBcxFTATBgNVBAMMDFFMRE0gZml4dHVyZTAeFw0yMDAxMDEwMDAwMDBaFw0zMDAxMDEwMDAwMDBaMBcxFTATBgNVBAMMDFFMRE0gZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABK9KZsQWrAixDhWcxerBMOstANWdiM9RdfPf+25bsZX6Hgz+pMLR2hpgxDWrLCpmGDVZNYAAya1/3hrgz6/Tm9gwCgYIKoZIzj0EAwIDSQAwRgIhAPjy8A6F++gXv0CNHw3xd5VwAHI0/Tpt5Bifyhf1KK5AAiEA839IXhcKhZWx329x4fNC1e8wt25T7EBf5Hjzq6wDviw="; + + fn base64(text: &str) -> Vec { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::new(); + let (mut buffer, mut bits) = (0u32, 0); + for c in text.bytes().filter(|c| *c != b'=') { + buffer = (buffer << 6) | ALPHABET.iter().position(|a| *a == c).unwrap() as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buffer >> bits) as u8); + buffer &= (1 << bits) - 1; + } + } + out + } + + fn signature_list(guid: &[u8], signature: &[u8]) -> Vec { + let mut list = guid.to_vec(); + for value in [28 + 16 + signature.len(), 0, 16 + signature.len()] { + list.extend_from_slice(&(value as u32).to_le_bytes()); + } + list.extend_from_slice(&[0; 16]); // Signature-owner GUID. + list.extend_from_slice(signature); + list + } + + #[test] + fn secure_boot_is_exactly_one_byte() { + assert_eq!(parse_secure_boot(&[1]).value, "enabled"); + assert_eq!(parse_secure_boot(&[0]).value, "disabled"); + for bad in [&[][..], &[2][..], &[0, 0, 0, 0, 0][..]] { + assert_eq!(parse_secure_boot(bad).state, ReadState::Error); + } + } + + #[test] + fn mok_certificates_keep_owner_issuer_and_expiry_together() { + let der = base64(FIXTURE_DER); + let list = signature_list(&X509_GUID, &der); + let mok = parse_mok_certificates(&list); + assert_eq!(mok.status.state, ReadState::Available); + assert_eq!(mok.certificates.len(), 1); + let certificate = &mok.certificates[0]; + assert!(certificate.subject.contains("QLDM fixture") && certificate.issuer.contains("QLDM fixture")); + assert_eq!(certificate.subject, "CN=QLDM fixture"); + assert_eq!(certificate.expires, "2030-01-01T00:00:00Z"); + assert_eq!(certificate.subject_display, "QLDM fixture"); + assert_eq!(parse_mok_certificates(&[list.clone(), list.clone()].concat()).certificates.len(), 2, "multiple lists"); + } + + #[test] + fn malformed_lists_never_look_complete() { + let der = base64(FIXTURE_DER); + let list = signature_list(&X509_GUID, &der); + assert_eq!(parse_mok_certificates(&list[..list.len() - 1]).status.state, ReadState::Error, "truncated"); + assert_eq!(parse_mok_certificates(&signature_list(&X509_GUID, b"invalid DER")).status.state, ReadState::Error); + let mut trailing = der.clone(); + trailing.push(0); + assert_eq!(parse_mok_certificates(&signature_list(&X509_GUID, &trailing)).status.state, ReadState::Error, "bytes after the certificate"); + assert_eq!(parse_mok_certificates(&vec![0; LIMIT + 1]).status.error, libc::EOVERFLOW); + assert_eq!(parse_mok_certificates(&[0; 10]).status.error, libc::EINVAL); + } + + #[test] + fn non_certificate_signatures_are_counted_not_decoded() { + let hashes = parse_mok_certificates(&signature_list(&[0; 16], &[1; 32])); + assert_eq!(hashes.status.state, ReadState::Available); + assert!(hashes.certificates.is_empty()); + assert_eq!(hashes.other_signatures, 1); + assert_eq!(parse_mok_certificates(&[]).status.state, ReadState::Available, "an empty list is a valid empty list"); + } + + fn efi_tree(secure_boot: Option<&[u8]>, table: Option<&[u8]>, variable: Option<&[u8]>, split: bool) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("efivars")).unwrap(); + std::fs::create_dir_all(tmp.path().join("mok-variables")).unwrap(); + let with_attributes = |bytes: &[u8]| [&[7, 0, 0, 0][..], bytes].concat(); + if let Some(b) = secure_boot { + std::fs::write(tmp.path().join("efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c"), with_attributes(b)).unwrap(); + } + if let Some(b) = table { + std::fs::write(tmp.path().join("mok-variables/MokListRT"), b).unwrap(); + } + if let Some(b) = variable { + std::fs::write(tmp.path().join("efivars/MokListRT-605dab50-e046-4300-abb6-3dd810dd8b23"), with_attributes(b)).unwrap(); + } + if split { + std::fs::write(tmp.path().join("efivars/MokListRT1-605dab50-e046-4300-abb6-3dd810dd8b23"), with_attributes(b"x")).unwrap(); + } + tmp + } + + #[test] + fn collection_prefers_the_configuration_table_and_rejects_split_variables() { + let list = signature_list(&X509_GUID, &base64(FIXTURE_DER)); + let table = efi_tree(Some(&[1]), Some(&list), None, false); + let found = collect_secure_boot_information_at(table.path()); + assert_eq!(found.state.value, "enabled"); + assert_eq!(found.mok.certificates.len(), 1); + assert!(found.mok_source.ends_with("mok-variables/MokListRT")); + + let variable = efi_tree(Some(&[0]), None, Some(&list), false); + let found = collect_secure_boot_information_at(variable.path()); + assert_eq!((found.state.value.as_str(), found.mok.certificates.len()), ("disabled", 1)); + assert!(found.mok_source.contains("efivars/MokListRT-"), "falls back to the variable"); + + let split = efi_tree(Some(&[0]), None, Some(&list), true); + let found = collect_secure_boot_information_at(split.path()); + assert_eq!(found.mok.status.error, libc::EOVERFLOW); + assert!(found.mok.certificates.is_empty(), "a split list is never shown as complete"); + + let none = efi_tree(None, None, None, false); + let found = collect_secure_boot_information_at(none.path()); + assert_eq!(found.state.state, ReadState::Unavailable); + assert_eq!(found.mok.status.state, ReadState::Unavailable); + } + + #[test] + fn short_efivar_payloads_are_invalid() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("v"), [1, 2, 3]).unwrap(); + assert_eq!(read_binary(&tmp.path().join("v"), true).unwrap_err().error, libc::EINVAL); + assert_eq!(read_binary(&tmp.path().join("v"), false).unwrap(), vec![1, 2, 3]); + } + + #[test] + fn this_machines_secure_boot_state_is_explicit() { + let found = collect_secure_boot_information(); + assert!(matches!(found.state.state, ReadState::Available | ReadState::Unavailable | ReadState::PermissionDenied | ReadState::Error)); + if found.state.state == ReadState::Available { + assert!(matches!(found.state.value.as_str(), "enabled" | "disabled")); + } + } +} diff --git a/crates/qldm-core/src/storage.rs b/crates/qldm-core/src/storage.rs new file mode 100644 index 0000000..e583902 --- /dev/null +++ b/crates/qldm-core/src/storage.rs @@ -0,0 +1,1180 @@ +//! Storage properties, ported from `src/devices/storageproperties.cpp`. +//! +//! Native metadata (sysfs, udev, procfs) is always collected; cached UDisks2 metadata over the +//! system bus is added when the `udisks2` feature is on and the daemon is already running. The +//! service is only ever asked for its cache: no activation, no authorization prompt, no SMART +//! update and no self-test, so a drive is never woken. Relationships are explicit edges, never +//! capacity totals. +use crate::dirfd::{failure, io_errno}; +use crate::model::{Attribute, Device, ReadState}; +use crate::sysfs::bounded_directory_names; +#[cfg(feature = "udisks2")] +use std::collections::HashMap; +use std::collections::{BTreeMap, HashSet}; +use std::io::Read; +use std::os::unix::fs::MetadataExt; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +const RECORD_LIMIT: usize = 4096; +const RELATED_LIMIT: usize = 256; +const TEXT_LIMIT: usize = 4096; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Format { + #[default] + Text, + Bytes, + Boolean, + Removable, + Epoch, + Kelvin, + Percent, + Hours, + Seconds, + Minutes, + Count, + AtaHealth, + NvmeHealth, + HealthQueryStatus, +} + +#[derive(Clone, Debug, Default)] +pub struct StorageField { + pub id: String, + /// Static translatable caption, not device input. + pub label: &'static str, + pub value: Attribute, + pub source: String, + pub format: Format, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AtaSmartAttribute { + pub id: u8, + /// UDisks2's drive-specific identifier, not guessed from the ID. + pub name: String, + pub flags: u16, + pub current: i32, + pub worst: i32, + pub threshold: i32, + pub interpreted: i64, + /// UDisks2 unit; zero means no interpreted value. + pub unit: i32, +} + +#[derive(Clone, Debug, Default)] +pub struct StorageEntity { + /// Canonical sysfs path or UDisks object path, snapshot-local. + pub id: String, + pub fields: Vec, + pub ata_attributes: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct StorageLink { + pub from: String, + pub to: String, + pub label: &'static str, + pub source: String, +} + +#[derive(Clone, Debug, Default)] +pub struct StorageProperties { + pub applicable: bool, + pub entities: Vec, + pub links: Vec, + pub notes: Vec, +} + +fn ok(text: impl Into) -> Attribute { + Attribute { state: ReadState::Available, value: text.into(), error: 0 } +} + +fn storage_failure(error: i32) -> Attribute { + // ENODATA counts as unavailable here too; identical to the shared mapping. + failure(error) +} + +fn text_file(path: &str, limit: usize) -> Attribute { + let mut file = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) => return storage_failure(io_errno(&e)), + }; + let mut bytes = Vec::new(); + if (&mut file).take(limit as u64 + 1).read_to_end(&mut bytes).is_err() { + return storage_failure(libc::EIO); + } + if bytes.len() > limit { + return storage_failure(libc::EOVERFLOW); + } + ok(String::from_utf8_lossy(&bytes).trim()) +} + +fn field(fields: &mut Vec, id: &str, label: &'static str, value: Attribute, source: impl Into, format: Format) { + fields.push(StorageField { id: id.into(), label, value, source: source.into(), format }); +} + +fn native(entity: &mut StorageEntity, key: &str, label: &'static str, format: Format) { + let path = format!("{}/{key}", entity.id); + let value = text_file(&path, TEXT_LIMIT); + field(&mut entity.fields, key, label, value, path, format); +} + +fn metadata(entity: &mut StorageEntity, raw: Option<&udev::Device>, key: &str, label: &'static str) { + let value = raw.and_then(|d| d.property_value(key)).map_or_else(Attribute::default, |v| { + if v.len() > TEXT_LIMIT { + storage_failure(libc::EOVERFLOW) + } else { + ok(v.to_string_lossy().into_owned()) + } + }); + let source = format!("udev: {key} on {}", entity.id); + field(&mut entity.fields, key, label, value, source, Format::Text); +} + +/// Decodes mountinfo escapes once: replacing `\134` first would double-decode literal escapes. +fn unescape_mount(value: &str) -> String { + value.replace("\\040", " ").replace("\\011", "\t").replace("\\012", "\n").replace("\\134", "\\") +} + +fn link(result: &mut StorageProperties, from: &str, to: &str, label: &'static str, source: impl Into) { + if from.is_empty() || to.is_empty() || from == to || to == "/" { + return; + } + if result.links.iter().any(|e| e.from == from && e.to == to && e.label == label) { + return; + } + result.links.push(StorageLink { from: from.into(), to: to.into(), label, source: source.into() }); +} + +fn canonical(path: &str) -> String { + std::fs::canonicalize(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_default() +} + +fn exists(path: &str) -> bool { + std::fs::symlink_metadata(path).is_ok() || Path::new(path).exists() +} + +fn parent_canonical(path: &str) -> String { + Path::new(path).parent().map(|p| canonical(&p.to_string_lossy())).unwrap_or_default() +} + +fn file_name(path: &str) -> String { + Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() +} + +fn inode_identity(path: &str) -> String { + std::fs::symlink_metadata(path).map(|i| format!("{}:{}", i.dev(), i.ino())).unwrap_or_default() +} + +/// Parses `/proc/self/mountinfo` into `major:minor` -> mount point descriptions. +pub fn parse_mountinfo(text: &str) -> BTreeMap> { + let mut mounts: BTreeMap> = BTreeMap::new(); + for line in text.split('\n') { + let parts: Vec<&str> = line.split(' ').collect(); + let Some(separator) = parts.iter().position(|p| *p == "-") else { continue }; + if parts.len() < 6 || separator < 6 || parts.len() <= separator + 2 { + continue; + } + mounts.entry(parts[2].to_string()).or_default().push(format!( + "{} [{}; root={}]", + unescape_mount(parts[4]), + parts[separator + 1], + unescape_mount(parts[3]) + )); + } + mounts +} + +/// Collects storage metadata for a `block` device or an `nvme` controller; any other device +/// returns an inapplicable (empty) snapshot. `cached_service` is false for deep search, which +/// still searches native metadata and relations but skips service snapshots. +pub fn collect_storage_properties(device: &Device, cached_service: bool, cancel: Option<&AtomicBool>) -> StorageProperties { + let mut result = StorageProperties { applicable: device.subsystem == "block" || device.subsystem == "nvme", ..Default::default() }; + if !result.applicable { + return result; + } + let cancelled = || cancel.is_some_and(|c| c.load(Ordering::Relaxed)); + let directory = "/sys/class/block"; + let names = match bounded_directory_names(directory, RECORD_LIMIT as i64, &[], cancel) { + Ok(names) => names, + Err(error) => { + field(&mut result.notes, "limit", "Storage inventory", storage_failure(error), directory, Format::Text); + return result; + } + }; + let mut paths: BTreeMap = BTreeMap::new(); + for name in &names { + let path = canonical(&format!("{directory}/{name}")); + if path.starts_with("/sys/devices/") { + paths.insert(name.clone(), path); + } + } + let all_paths: HashSet<&String> = paths.values().collect(); + let mut wanted: HashSet = HashSet::new(); + if device.subsystem == "block" { + wanted.insert(device.path.clone()); + } else { + for path in paths.values().filter(|p| p.starts_with(&format!("{}/", device.path))) { + wanted.insert(path.clone()); + } + } + let mut native_links: Vec = Vec::new(); + for path in paths.values() { + if cancelled() { + return result; + } + if exists(&format!("{path}/partition")) { + native_links.push(StorageLink { + from: path.clone(), + to: parent_canonical(path), + label: "Partition of", + source: format!("{path}/partition (kernel ancestry)"), + }); + } + for (dir, label) in [("slaves", "Backed by"), ("holders", "Used by")] { + let links = format!("{path}/{dir}"); + let related = match bounded_directory_names(&links, RECORD_LIMIT as i64, &[], cancel) { + Ok(names) => names, + Err(libc::ENOENT) => Vec::new(), + Err(error) => { + field(&mut result.notes, "limit", "Storage relationships", storage_failure(error), links, Format::Text); + return result; + } + }; + for name in related { + let target = canonical(&format!("{links}/{name}")); + if all_paths.contains(&target) { + native_links.push(StorageLink { from: path.clone(), to: target, label, source: format!("{links}/{name}") }); + } + if native_links.len() > RECORD_LIMIT * 4 { + field(&mut result.notes, "limit", "Storage relationships", storage_failure(libc::EOVERFLOW), directory, Format::Text); + return result; + } + } + } + } + for _ in 0..RELATED_LIMIT { + let before = wanted.len(); + for edge in &native_links { + if wanted.contains(&edge.from) || wanted.contains(&edge.to) { + wanted.insert(edge.from.clone()); + wanted.insert(edge.to.clone()); + } + } + if wanted.len() > RELATED_LIMIT { + field(&mut result.notes, "limit", "Related storage metadata", storage_failure(libc::EOVERFLOW), directory, Format::Text); + return result; + } + if wanted.len() == before { + break; + } + } + let identities: BTreeMap = wanted.iter().map(|p| (p.clone(), inode_identity(p))).collect(); + let mounts = text_file("/proc/self/mountinfo", 4 * 1024 * 1024); + field( + &mut result.notes, + "mountinfo", + "Mount metadata", + if mounts.state == ReadState::Available { ok("application-namespace") } else { mounts.clone() }, + "/proc/self/mountinfo", + Format::Text, + ); + let mount_points = if mounts.state == ReadState::Available { parse_mountinfo(&mounts.value) } else { BTreeMap::new() }; + + // Sorting is deterministic and each canonical block path appears once. + let mut selected: Vec = wanted.iter().cloned().collect(); + selected.sort(); + for path in &selected { + if cancelled() { + return result; + } + let mut entity = StorageEntity { id: path.clone(), ..Default::default() }; + let raw = udev::Device::from_syspath(Path::new(path)).ok(); + metadata(&mut entity, raw.as_ref(), "DEVNAME", "Device node"); + let number = text_file(&format!("{path}/dev"), TEXT_LIMIT); + field(&mut entity.fields, "dev", "Major:minor", number.clone(), format!("{path}/dev"), Format::Text); + let mut capacity = text_file(&format!("{path}/size"), TEXT_LIMIT); + if capacity.state == ReadState::Available { + capacity = match capacity.value.parse::().ok().and_then(|s| s.checked_mul(512)) { + Some(bytes) => ok(bytes.to_string()), + None => storage_failure(libc::EOVERFLOW), + }; + } + field(&mut entity.fields, "capacity", "Block capacity", capacity, format!("{path}/size (512-byte units)"), Format::Bytes); + let partition = exists(&format!("{path}/partition")); + let queue_owner = if partition { parent_canonical(path) } else { path.clone() }; + for (key, label) in [("logical_block_size", "Logical sector size"), ("physical_block_size", "Physical sector size")] { + let source = format!("{queue_owner}/queue/{key}"); + field(&mut entity.fields, &format!("queue/{key}"), label, text_file(&source, TEXT_LIMIT), source, Format::Bytes); + } + native(&mut entity, "removable", "Kernel removable media", Format::Boolean); + native(&mut entity, "ro", "Block read-only", Format::Boolean); + if partition { + native(&mut entity, "partition", "Partition number", Format::Text); + native(&mut entity, "start", "Partition start (512-byte sectors)", Format::Text); + } + for (key, label) in [ + ("ID_MODEL", "Disk model"), + ("ID_VENDOR", "Manufacturer"), + ("ID_SERIAL_SHORT", "Serial number"), + ("ID_REVISION", "Firmware revision"), + ("ID_BUS", "Transport"), + ("ID_FS_TYPE", "Filesystem / content type"), + ("ID_FS_USAGE", "Content usage"), + ("ID_FS_LABEL", "Filesystem / content label"), + ("ID_FS_UUID", "Filesystem / content UUID"), + ("ID_PART_TABLE_TYPE", "Partition table type"), + ] { + metadata(&mut entity, raw.as_ref(), key, label); + } + if exists(&format!("{path}/dm")) { + native(&mut entity, "dm/name", "Device-mapper name", Format::Text); + native(&mut entity, "dm/uuid", "Device-mapper UUID", Format::Text); + } + if exists(&format!("{path}/md")) { + native(&mut entity, "md/level", "RAID level", Format::Text); + native(&mut entity, "md/array_state", "RAID state", Format::Text); + native(&mut entity, "md/degraded", "RAID missing members", Format::Text); + } + // Read only selected SCSI/NVMe identity metadata, with parent source kept explicit. + let hardware = canonical(&format!("{path}/device")); + if hardware.starts_with("/sys/devices/") { + let subsystem = canonical(&format!("{hardware}/subsystem")); + if file_name(&subsystem) == "nvme" { + field(&mut entity.fields, "native-transport", "Native transport", ok("NVMe"), format!("{hardware}/subsystem"), Format::Text); + } else if raw.as_ref().and_then(|d| d.property_value("ID_ATA_SATA")).is_some_and(|v| v == "1") { + field(&mut entity.fields, "native-transport", "Native transport", ok("SATA"), format!("udev: ID_ATA_SATA on {path}"), Format::Text); + } + for (key, label) in [ + ("model", "Hardware model"), + ("vendor", "Hardware vendor"), + ("rev", "Hardware revision"), + ("serial", "Hardware serial"), + ("firmware_rev", "Hardware firmware revision"), + ] { + let source = format!("{hardware}/{key}"); + field(&mut entity.fields, &format!("hardware/{key}"), label, text_file(&source, TEXT_LIMIT), source, Format::Text); + } + } + let points = mount_points.get(&number.value).cloned().unwrap_or_default(); + let joined = points.join("\n"); + let mounts_value = if mounts.state != ReadState::Available { + mounts.clone() + } else if number.state != ReadState::Available { + number.clone() + } else if !points.is_empty() { + if joined.len() > TEXT_LIMIT { + storage_failure(libc::EOVERFLOW) + } else { + ok(joined) + } + } else { + ok("no-observed-mount") + }; + field( + &mut entity.fields, + "mounts", + "Mount points (application namespace)", + mounts_value, + format!("/proc/self/mountinfo : {}", number.value), + Format::Text, + ); + result.entities.push(entity); + } + for edge in &native_links { + if wanted.contains(&edge.from) && wanted.contains(&edge.to) { + link(&mut result, &edge.from, &edge.to, edge.label, edge.source.clone()); + } + } + if device.subsystem == "nvme" { + let mut controller = StorageEntity { id: device.path.clone(), ..Default::default() }; + native(&mut controller, "model", "Controller model", Format::Text); + native(&mut controller, "serial", "Controller serial number", Format::Text); + native(&mut controller, "firmware_rev", "Controller firmware revision", Format::Text); + result.entities.insert(0, controller); + for path in selected.iter().filter(|p| p.starts_with(&format!("{}/", device.path))) { + link(&mut result, &device.path, path, "Controller namespace / partition", "sysfs ancestry"); + } + } + if cached_service && !cancelled() { + #[cfg(feature = "udisks2")] + service::add_service(&mut result, &wanted, &device.path, cancel); + } + if !cfg!(feature = "udisks2") { + field( + &mut result.notes, + "service", + "UDisks2 metadata", + Attribute { state: ReadState::Unsupported, ..Default::default() }, + "UDisks2 support not enabled in this build", + Format::Text, + ); + } + // Related paths and device numbers can also be reused. Reject the whole storage snapshot + // if a native entity changed while the service was queried. + for (path, before) in &identities { + if before.is_empty() || inode_identity(path) != *before { + result.entities.clear(); + result.links.clear(); + result.notes.clear(); + field(&mut result.notes, "changed", "Storage topology changed during collection", storage_failure(libc::EAGAIN), path.clone(), Format::Text); + break; + } + } + result +} + +// --------------------------------------------------------------------------------------- +// UDisks2 over the system bus +// --------------------------------------------------------------------------------------- + +#[cfg(feature = "udisks2")] +mod service { + use super::*; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + use zbus::blocking::Connection; + use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + + type Props = HashMap; + type Interfaces = BTreeMap; + type Objects = BTreeMap; + + const PREFIX: &str = "org.freedesktop.UDisks2."; + const DESTINATION: &str = "org.freedesktop.UDisks2"; + /// Shared wait budget for all detailed health calls of one snapshot. + const HEALTH_BUDGET: Duration = Duration::from_millis(2500); + + // ---- value helpers (the QVariant conversions used by the C++) ---- + + fn text_of(value: &Value) -> String { + match value { + Value::Str(s) => s.to_string(), + Value::ObjectPath(p) => p.to_string(), + Value::Signature(s) => s.to_string(), + Value::Bool(b) => b.to_string(), + Value::U8(n) => n.to_string(), + Value::I16(n) => n.to_string(), + Value::U16(n) => n.to_string(), + Value::I32(n) => n.to_string(), + Value::U32(n) => n.to_string(), + Value::I64(n) => n.to_string(), + Value::U64(n) => n.to_string(), + Value::F64(n) => n.to_string(), + Value::Value(inner) => text_of(inner), + _ => String::new(), + } + } + + fn string_list(value: &Value) -> Vec { + match value { + Value::Array(items) => items.iter().map(text_of).collect(), + Value::Value(inner) => string_list(inner), + other => vec![text_of(other)], + } + } + + fn to_f64(value: &Value) -> f64 { + match value { + Value::U8(n) => *n as f64, + Value::I16(n) => *n as f64, + Value::U16(n) => *n as f64, + Value::I32(n) => *n as f64, + Value::U32(n) => *n as f64, + Value::I64(n) => *n as f64, + Value::U64(n) => *n as f64, + Value::F64(n) => *n, + Value::Bool(b) => *b as u8 as f64, + Value::Str(s) => s.parse().unwrap_or(0.0), + Value::Value(inner) => to_f64(inner), + _ => 0.0, + } + } + + fn to_u64(value: &Value) -> u64 { + match value { + Value::U8(n) => *n as u64, + Value::U16(n) => *n as u64, + Value::U32(n) => *n as u64, + Value::U64(n) => *n, + Value::I16(n) => (*n).max(0) as u64, + Value::I32(n) => (*n).max(0) as u64, + Value::I64(n) => (*n).max(0) as u64, + Value::Bool(b) => *b as u64, + Value::Str(s) => s.parse().unwrap_or(0), + Value::Value(inner) => to_u64(inner), + _ => 0, + } + } + + fn to_bool(value: &Value) -> bool { + match value { + Value::Bool(b) => *b, + Value::Str(s) => !matches!(s.as_str(), "" | "0" | "false"), + other => to_f64(other) != 0.0, + } + } + + fn object_path(props: Option<&Props>, key: &str) -> String { + match props.and_then(|p| p.get(key)).map(|v| &**v) { + Some(Value::ObjectPath(p)) => p.to_string(), + _ => String::new(), + } + } + + fn iface<'a>(interfaces: &'a Interfaces, name: &str) -> Option<&'a Props> { + interfaces.get(&format!("{PREFIX}{name}")) + } + + // ---- D-Bus calls with a bounded wait ---- + + enum CallError { + Cancelled, + TimedOut, + Bus(zbus::Error), + } + + fn dbus_error_name(error: &zbus::Error) -> String { + match error { + zbus::Error::MethodError(name, _, _) => name.to_string(), + _ => String::new(), + } + } + + /// Runs `call` on a helper thread and waits at most `timeout`, polling `cancel` every 50 ms. + /// A call that outlives the wait is abandoned; its result is dropped when it finally returns. + fn call_with_timeout( + timeout: Duration, + cancel: Option<&AtomicBool>, + call: impl FnOnce() -> zbus::Result + Send + 'static, + ) -> Result { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(call()); + }); + let started = Instant::now(); + loop { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return Err(CallError::Cancelled); + } + let remaining = timeout.saturating_sub(started.elapsed()); + if remaining.is_zero() { + return Err(CallError::TimedOut); + } + match receiver.recv_timeout(remaining.min(Duration::from_millis(50))) { + Ok(Ok(message)) => return Ok(message), + Ok(Err(error)) => return Err(CallError::Bus(error)), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return Err(CallError::TimedOut), + } + } + } + + fn timeout_like(name: &str) -> bool { + matches!(name, "org.freedesktop.DBus.Error.NoReply" | "org.freedesktop.DBus.Error.Timeout" | "org.freedesktop.DBus.Error.TimedOut") + } + + fn snapshot(result: &mut StorageProperties, cancel: Option<&AtomicBool>) -> Option<(Connection, Objects)> { + let source = "org.freedesktop.UDisks2: ObjectManager.GetManagedObjects"; + let note = |result: &mut StorageProperties, value: Attribute| { + field(&mut result.notes, "service", "UDisks2 metadata", value, source, Format::Text); + }; + let Ok(connection) = Connection::system() else { + note(result, storage_failure(libc::ENOTCONN)); + return None; + }; + // Read the existing cache; never start a storage daemon. + let owned = connection + .call_method(Some("org.freedesktop.DBus"), "/org/freedesktop/DBus", Some("org.freedesktop.DBus"), "NameHasOwner", &(DESTINATION,)) + .ok() + .and_then(|m| m.body().deserialize::().ok()); + if owned != Some(true) { + note(result, Attribute { state: ReadState::Unavailable, value: String::new(), error: libc::EIO }); + return None; + } + let worker = connection.clone(); + let reply = call_with_timeout(Duration::from_millis(2500), cancel, move || { + worker.call_method(Some(DESTINATION), "/org/freedesktop/UDisks2", Some("org.freedesktop.DBus.ObjectManager"), "GetManagedObjects", &()) + }); + let message = match reply { + Err(CallError::Cancelled) => return None, + Err(CallError::TimedOut) => { + note(result, Attribute { state: ReadState::Error, value: String::new(), error: libc::ETIMEDOUT }); + return None; + } + Err(CallError::Bus(error)) => { + let name = dbus_error_name(&error); + let state = match name.as_str() { + "org.freedesktop.DBus.Error.AccessDenied" => ReadState::PermissionDenied, + "org.freedesktop.DBus.Error.ServiceUnknown" | "org.freedesktop.DBus.Error.UnknownObject" => ReadState::Unavailable, + _ => ReadState::Error, + }; + note(result, Attribute { state, value: String::new(), error: if timeout_like(&name) { libc::ETIMEDOUT } else { libc::EIO } }); + return None; + } + Ok(message) => message, + }; + type Wire = HashMap>>; + let Ok(raw) = message.body().deserialize::() else { + note(result, storage_failure(libc::EINVAL)); + return None; + }; + if raw.len() > RECORD_LIMIT { + note(result, storage_failure(libc::EOVERFLOW)); + return None; + } + note(result, ok("cached")); + let objects: Objects = raw.into_iter().map(|(path, interfaces)| (path.to_string(), interfaces.into_iter().collect::())).collect(); + Some((connection, objects)) + } + + fn service_field(entity: &mut StorageEntity, props: Option<&Props>, interface: &str, key: &str, label: &'static str, format: Format, zero_unknown: bool) { + let mut data = Attribute::default(); + if let Some(value) = props.and_then(|p| p.get(key)) { + let mut text = if format == Format::NvmeHealth { string_list(value).join("\n") } else { text_of(value) }; + if matches!(format, Format::Boolean | Format::Removable | Format::AtaHealth) { + text = if to_bool(value) { "1" } else { "0" }.into(); + } + if text.len() > TEXT_LIMIT { + data = storage_failure(libc::EOVERFLOW); + } else if !(format == Format::Text && text.is_empty()) && (!zero_unknown || to_f64(value) != 0.0) { + data = ok(text); + } + } + let source = format!("{} : {interface}.{key}", entity.id); + field(&mut entity.fields, &format!("{interface}/{key}"), label, data, source, format); + } + + /// Budget shared by all detailed health calls; it starts at the first one. + #[derive(Default)] + struct HealthBudget { + started: Option, + } + + /// Cache-only `SmartGetAttributes` call: no activation, no interactive authorization. + fn health_reply( + connection: &Connection, + path: &str, + interface: &str, + budget: &mut HealthBudget, + cancel: Option<&AtomicBool>, + ) -> Result { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return Err(storage_failure(libc::ECANCELED)); + } + let started = *budget.started.get_or_insert_with(Instant::now); + let remaining = HEALTH_BUDGET.saturating_sub(started.elapsed()); + // Unavailable + ETIMEDOUT on HealthQueryStatus means no request was sent. + if remaining.is_zero() { + return Err(Attribute { state: ReadState::Unavailable, value: String::new(), error: libc::ETIMEDOUT }); + } + let (worker, path, interface) = (connection.clone(), path.to_string(), interface.to_string()); + let reply = call_with_timeout(remaining, cancel, move || { + let options: HashMap> = HashMap::new(); + worker.call_method(Some(DESTINATION), path.as_str(), Some(interface.as_str()), "SmartGetAttributes", &(options,)) + }); + match reply { + Ok(message) => Ok(message), + Err(CallError::Cancelled) => Err(storage_failure(libc::ECANCELED)), + Err(CallError::TimedOut) => Err(storage_failure(libc::ETIMEDOUT)), + Err(CallError::Bus(error)) => { + let name = dbus_error_name(&error); + let state = match name.as_str() { + "org.freedesktop.DBus.Error.AccessDenied" => ReadState::PermissionDenied, + "org.freedesktop.DBus.Error.UnknownMethod" | "org.freedesktop.DBus.Error.UnknownInterface" => ReadState::Unsupported, + "org.freedesktop.DBus.Error.ServiceUnknown" | "org.freedesktop.DBus.Error.UnknownObject" => ReadState::Unavailable, + _ => ReadState::Error, + }; + Err(Attribute { state, value: String::new(), error: if timeout_like(&name) { libc::ETIMEDOUT } else { libc::EIO } }) + } + } + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum Kind { + U8, + U16, + U32, + U64, + } + + fn typed_number(value: &Value, kind: Kind) -> Option { + match (kind, value) { + (Kind::U8, Value::U8(n)) => Some(*n as u64), + (Kind::U16, Value::U16(n)) => Some(*n as u64), + (Kind::U32, Value::U32(n)) => Some(*n as u64), + (Kind::U64, Value::U64(n)) => Some(*n), + _ => None, + } + } + + /// Fields from an NVMe `SmartGetAttributes` reply. + pub(super) fn nvme_fields(entity: &mut StorageEntity, values: &Props, interface: &str, source: &str) { + let mut add = |key: &str, label: &'static str, kind: Kind, format: Format, zero_unknown: bool| { + let mut data = Attribute::default(); + if let Some(value) = values.get(key) { + match typed_number(value, kind) { + None => data = storage_failure(libc::EINVAL), + Some(n) if !zero_unknown || n != 0 => data = ok(n.to_string()), + Some(_) => {} + } + } + field(&mut entity.fields, &format!("{interface}/SmartGetAttributes/{key}"), label, data, format!("{source} : {key}"), format); + }; + add("avail_spare", "Cached available spare", Kind::U8, Format::Percent, false); + add("spare_thresh", "Cached spare threshold", Kind::U8, Format::Percent, false); + add("percent_used", "Cached estimated endurance used", Kind::U8, Format::Percent, false); + add("total_data_read", "Cached total data read", Kind::U64, Format::Bytes, false); + add("total_data_written", "Cached total data written", Kind::U64, Format::Bytes, false); + add("ctrl_busy_time", "Cached controller busy time", Kind::U64, Format::Minutes, false); + add("power_cycles", "Cached power cycles", Kind::U64, Format::Count, false); + add("unsafe_shutdowns", "Cached unsafe shutdowns", Kind::U64, Format::Count, false); + add("media_errors", "Cached media and data integrity errors", Kind::U64, Format::Count, false); + add("num_err_log_entries", "Cached error information log entries", Kind::U64, Format::Count, false); + add("wctemp", "Cached warning temperature threshold", Kind::U16, Format::Kelvin, true); + add("cctemp", "Cached critical temperature threshold", Kind::U16, Format::Kelvin, true); + add("warning_temp_time", "Cached time above warning temperature", Kind::U32, Format::Minutes, false); + add("critical_temp_time", "Cached time above critical temperature", Kind::U32, Format::Minutes, false); + + const LABELS: [&str; 8] = [ + "Cached temperature sensor 1", + "Cached temperature sensor 2", + "Cached temperature sensor 3", + "Cached temperature sensor 4", + "Cached temperature sensor 5", + "Cached temperature sensor 6", + "Cached temperature sensor 7", + "Cached temperature sensor 8", + ]; + // At most eight items are decoded; a longer array is an overflow, a wrong type invalid. + let mut sensor_state = Attribute::default(); + let mut temperatures: Vec = Vec::new(); + if let Some(sensors) = values.get("temp_sensors") { + match &**sensors { + Value::Array(items) => { + let parsed: Option> = items.iter().map(|v| if let Value::U16(n) = v { Some(*n) } else { None }).collect(); + match parsed { + None => sensor_state = storage_failure(libc::EINVAL), + Some(list) if list.len() > 8 => sensor_state = storage_failure(libc::EOVERFLOW), + Some(list) => { + temperatures = list; + sensor_state = ok(""); + } + } + } + _ => sensor_state = storage_failure(libc::EINVAL), + } + } + for (i, label) in LABELS.iter().enumerate() { + let mut data = if sensor_state.state == ReadState::Available { Attribute::default() } else { sensor_state.clone() }; + if sensor_state.state == ReadState::Available && temperatures.get(i).is_some_and(|k| *k != 0) { + data = ok(temperatures[i].to_string()); + } + field( + &mut entity.fields, + &format!("{interface}/SmartGetAttributes/temp_sensors/{}", i + 1), + label, + data, + format!("{source} : temp_sensors[{i}]"), + Format::Kelvin, + ); + } + } + + type AtaWire = (u8, String, u16, i32, i32, i32, i64, i32, HashMap); + + /// Validates the full list of ATA attributes; any bad entry rejects the whole list. + pub(super) fn validate_ata(items: Vec) -> Result, i32> { + let mut attributes = Vec::new(); + let mut ids = HashSet::new(); + for (id, name, flags, current, worst, threshold, interpreted, unit, expansion) in items { + if attributes.len() >= 256 { + return Err(libc::EOVERFLOW); + } + if name.len() > 128 || expansion.len() > 64 { + return Err(libc::EOVERFLOW); + } + let normalized = |n: i32| (-1..=255).contains(&n); + if ids.contains(&id) || !normalized(current) || !normalized(worst) || !normalized(threshold) || unit < 0 { + return Err(libc::EINVAL); + } + ids.insert(id); + attributes.push(AtaSmartAttribute { id, name, flags, current, worst, threshold, interpreted, unit }); + } + Ok(attributes) + } + + fn nvme_attributes(entity: &mut StorageEntity, connection: &Connection, budget: &mut HealthBudget, cancel: Option<&AtomicBool>) { + let interface = "org.freedesktop.UDisks2.NVMe.Controller"; + let source = format!("{} : {interface}.SmartGetAttributes", entity.id); + let note = |entity: &mut StorageEntity, state: Attribute| { + field( + &mut entity.fields, + &format!("{interface}/SmartGetAttributes"), + "Cached NVMe health details", + state, + source.clone(), + Format::HealthQueryStatus, + ); + }; + let message = match health_reply(connection, &entity.id.clone(), interface, budget, cancel) { + Ok(message) => message, + Err(state) => return note(entity, state), + }; + let Ok(values) = message.body().deserialize::() else { return note(entity, storage_failure(libc::EINVAL)) }; + if values.len() > 64 { + return note(entity, storage_failure(libc::EOVERFLOW)); + } + nvme_fields(entity, &values, interface, &source); + } + + fn ata_attributes(entity: &mut StorageEntity, connection: &Connection, budget: &mut HealthBudget, cancel: Option<&AtomicBool>) { + let interface = "org.freedesktop.UDisks2.Drive.Ata"; + let source = format!("{} : {interface}.SmartGetAttributes", entity.id); + let note = |entity: &mut StorageEntity, state: Attribute| { + field( + &mut entity.fields, + &format!("{interface}/SmartGetAttributes"), + "Cached ATA SMART attributes", + state, + source.clone(), + Format::HealthQueryStatus, + ); + }; + let message = match health_reply(connection, &entity.id.clone(), interface, budget, cancel) { + Ok(message) => message, + Err(state) => return note(entity, state), + }; + let Ok(items) = message.body().deserialize::>() else { return note(entity, storage_failure(libc::EINVAL)) }; + match validate_ata(items) { + Err(error) => note(entity, storage_failure(error)), + Ok(list) if list.is_empty() => note(entity, Attribute::default()), + Ok(list) => entity.ata_attributes = list, // Publish a complete, validated list only. + } + } + + pub(super) fn add_service(result: &mut StorageProperties, native_paths: &HashSet, selected_path: &str, cancel: Option<&AtomicBool>) { + let Some((connection, objects)) = snapshot(result, cancel) else { return }; + let cancelled = || cancel.is_some_and(|c| c.load(Ordering::Relaxed)); + let mut wanted: HashSet = HashSet::new(); + let mut selected_drives: HashSet = HashSet::new(); + let mut blocks: HashMap = HashMap::new(); + for (key, interfaces) in &objects { + let Some(number) = iface(interfaces, "Block").and_then(|p| p.get("DeviceNumber")) else { continue }; + let number = to_u64(number) as libc::dev_t; + let path = canonical(&format!("/sys/dev/block/{}:{}", libc::major(number), libc::minor(number))); + if native_paths.contains(&path) { + wanted.insert(key.clone()); + blocks.insert(key.clone(), path.clone()); + if path == selected_path || path.starts_with(&format!("{selected_path}/")) { + let drive = object_path(iface(interfaces, "Block"), "Drive"); + if !drive.is_empty() && drive != "/" { + selected_drives.insert(drive); + } + } + } + } + // No guessed model/serial matching; each drive object is retained once. + const PAIRS: [(&str, &str); 7] = [ + ("Block", "Drive"), + ("Block", "CryptoBackingDevice"), + ("Block", "MDRaid"), + ("Block", "MDRaidMember"), + ("Partition", "Table"), + ("Block.LVM2", "LogicalVolume"), + ("LogicalVolume", "VolumeGroup"), + ]; + for _ in 0..RELATED_LIMIT { + let before = wanted.len(); + for (key, interfaces) in &objects { + if !wanted.contains(key) { + continue; + } + for (interface, property) in PAIRS { + let target = object_path(iface(interfaces, interface), property); + if !target.is_empty() && target != "/" && objects.contains_key(&target) { + wanted.insert(target); + } + } + } + if wanted.len() > RELATED_LIMIT { + field(&mut result.notes, "service-limit", "Related storage metadata", storage_failure(libc::EOVERFLOW), "UDisks2", Format::Text); + return; + } + if wanted.len() == before { + break; + } + } + let mut ordered: Vec<&String> = objects.keys().filter(|k| wanted.contains(*k)).collect(); + // Prioritize explicit selected-device associations; keep object-path order within each + // tier and do not guess a single drive for spanning volumes. + ordered.sort_by_key(|k| !selected_drives.contains(*k)); + let mut budget = HealthBudget::default(); + for path in ordered { + if cancelled() { + return; + } + let interfaces = &objects[path]; + let mut entity = StorageEntity { id: path.clone(), ..Default::default() }; + if let Some(block) = blocks.get(path) { + link(result, block, path, "UDisks2 block metadata", "UDisks2 Block.DeviceNumber / sysfs dev"); + } + let copy = |entity: &mut StorageEntity, interface: &str, key: &str, label: &'static str, format: Format, zero_unknown: bool| { + service_field(entity, iface(interfaces, interface), &format!("{PREFIX}{interface}"), key, label, format, zero_unknown); + }; + let has = |name: &str| iface(interfaces, name).is_some(); + if has("Drive") { + copy(&mut entity, "Drive", "Vendor", "Drive manufacturer", Format::Text, false); + copy(&mut entity, "Drive", "Model", "Drive model", Format::Text, false); + copy(&mut entity, "Drive", "Serial", "Drive serial number", Format::Text, false); + copy(&mut entity, "Drive", "Revision", "Drive firmware revision", Format::Text, false); + copy(&mut entity, "Drive", "ConnectionBus", "Drive transport", Format::Text, false); + copy(&mut entity, "Drive", "Size", "Drive capacity", Format::Bytes, true); + copy(&mut entity, "Drive", "Removable", "Removable/fixed hint", Format::Removable, false); + copy(&mut entity, "Drive", "MediaRemovable", "Drive media removable", Format::Boolean, false); + copy(&mut entity, "Drive", "MediaAvailable", "Drive media available", Format::Boolean, false); + } + if has("Block") { + copy(&mut entity, "Block", "IdUsage", "Content usage", Format::Text, false); + copy(&mut entity, "Block", "IdType", "Filesystem / content type", Format::Text, false); + copy(&mut entity, "Block", "IdVersion", "Filesystem / content version", Format::Text, false); + copy(&mut entity, "Block", "IdLabel", "Filesystem / content label", Format::Text, false); + copy(&mut entity, "Block", "IdUUID", "Filesystem / content UUID", Format::Text, false); + } + if has("Partition") { + copy(&mut entity, "Partition", "Number", "Partition number", Format::Text, false); + copy(&mut entity, "Partition", "Type", "Partition type", Format::Text, false); + copy(&mut entity, "Partition", "Offset", "Partition offset", Format::Bytes, false); + copy(&mut entity, "Partition", "Size", "Partition capacity", Format::Bytes, false); + copy(&mut entity, "Partition", "Name", "Partition name", Format::Text, false); + copy(&mut entity, "Partition", "UUID", "Partition UUID", Format::Text, false); + } + if has("PartitionTable") { + copy(&mut entity, "PartitionTable", "Type", "Partition table type", Format::Text, false); + } + if has("MDRaid") { + copy(&mut entity, "MDRaid", "Name", "RAID name", Format::Text, false); + copy(&mut entity, "MDRaid", "Level", "RAID level", Format::Text, false); + copy(&mut entity, "MDRaid", "UUID", "RAID UUID", Format::Text, false); + copy(&mut entity, "MDRaid", "NumDevices", "RAID member count", Format::Text, false); + } + if has("LogicalVolume") { + copy(&mut entity, "LogicalVolume", "Name", "Logical volume name", Format::Text, false); + copy(&mut entity, "LogicalVolume", "Type", "Logical volume type", Format::Text, false); + } + if has("VolumeGroup") { + copy(&mut entity, "VolumeGroup", "Name", "Volume group name", Format::Text, false); + } + let (ata, nvme) = (has("Drive.Ata"), has("NVMe.Controller")); + if ata || nvme { + let name = if ata { "Drive.Ata" } else { "NVMe.Controller" }; + let health = iface(interfaces, name); + let full = format!("{PREFIX}{name}"); + if ata { + copy(&mut entity, "Drive.Ata", "SmartSupported", "ATA SMART supported", Format::Boolean, false); + copy(&mut entity, "Drive.Ata", "SmartEnabled", "ATA SMART enabled", Format::Boolean, false); + } + service_field(&mut entity, health, &full, "SmartUpdated", "Cached health updated", Format::Epoch, true); + // Missing/zero timestamps invalidate every health assessment field. + if health.and_then(|h| h.get("SmartUpdated")).is_some_and(|v| to_u64(v) != 0) { + service_field(&mut entity, health, &full, "SmartTemperature", "Cached temperature", Format::Kelvin, true); + if ata { + copy(&mut entity, "Drive.Ata", "SmartPowerOnSeconds", "Cached power-on time", Format::Seconds, true); + copy(&mut entity, "Drive.Ata", "SmartFailing", "Cached ATA failure prediction", Format::AtaHealth, false); + for (key, label) in [ + ("SmartNumBadSectors", "Cached bad sectors"), + ("SmartNumAttributesFailing", "Cached failing attributes"), + ("SmartNumAttributesFailedInThePast", "Cached attributes failed in the past"), + ] { + let mut data = Attribute::default(); + if let Some(count) = health.and_then(|h| h.get(key)) { + let number = match (&**count, key) { + (Value::I64(n), "SmartNumBadSectors") => Some(*n), + (Value::I32(n), _) => Some(*n as i64), + _ => None, + }; + match number { + None => data = storage_failure(libc::EINVAL), + Some(n) if n < -1 => data = storage_failure(libc::EINVAL), + Some(n) if n >= 0 => data = ok(n.to_string()), + Some(_) => {} + } + } + field( + &mut entity.fields, + &format!("{PREFIX}Drive.Ata/{key}"), + label, + data, + format!("{} : {PREFIX}Drive.Ata.{key}", entity.id), + Format::Count, + ); + } + ata_attributes(&mut entity, &connection, &mut budget, cancel); + } else { + copy(&mut entity, "NVMe.Controller", "SmartCriticalWarning", "Cached NVMe critical warnings", Format::NvmeHealth, false); + copy(&mut entity, "NVMe.Controller", "SmartPowerOnHours", "Cached power-on time", Format::Hours, true); + nvme_attributes(&mut entity, &connection, &mut budget, cancel); + } + } else { + let source = format!("{} : {full}.SmartUpdated (never updated or unavailable)", entity.id); + field(&mut entity.fields, "health", "Cached health", Attribute::default(), source, Format::Text); + } + } else if has("Drive") { + let source = format!("{} : UDisks2 (no supported ATA or NVMe health interface)", entity.id); + field(&mut entity.fields, "health", "Cached health", Attribute { state: ReadState::Unsupported, ..Default::default() }, source, Format::Text); + } + for (name, props) in interfaces { + for key in ["Drive", "CryptoBackingDevice", "MDRaid", "MDRaidMember", "Table", "LogicalVolume", "VolumeGroup"] { + let target = object_path(Some(props), key); + if wanted.contains(&target) { + link(result, &entity.id, &target, "Storage relationship", format!("{name}.{key}")); + } + } + } + result.entities.push(entity); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mountinfo_groups_mounts_by_device_number_and_decodes_escapes_once() { + let text = "36 35 259:1 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p1 rw\n\ + 40 36 259:2 /my\\040dir /mnt/a\\040b rw - btrfs /dev/nvme0n1p2 rw\n\ + 41 36 259:2 /lit\\134040 /mnt/c rw - btrfs /dev/nvme0n1p2 rw\n\ + short line\n"; + let mounts = parse_mountinfo(text); + assert_eq!(mounts["259:1"], ["/ [ext4; root=/]"]); + assert_eq!(mounts["259:2"][0], "/mnt/a b [btrfs; root=/my dir]"); + assert_eq!(mounts["259:2"][1], "/mnt/c [btrfs; root=/lit\\040]", "an escaped backslash is not decoded twice"); + assert!(!mounts.contains_key("short")); + } + + #[test] + fn links_skip_empty_self_root_and_duplicate_edges() { + let mut result = StorageProperties::default(); + link(&mut result, "a", "b", "Used by", "s"); + link(&mut result, "a", "b", "Used by", "s"); + link(&mut result, "a", "a", "Used by", "s"); + link(&mut result, "", "b", "Used by", "s"); + link(&mut result, "a", "/", "Used by", "s"); + link(&mut result, "a", "b", "Backed by", "s"); + assert_eq!(result.links.len(), 2); + } + + #[test] + fn text_files_are_bounded_and_classified() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("v"); + std::fs::write(&file, " 8 \n").unwrap(); + assert_eq!(text_file(&file.to_string_lossy(), 16).value, "8"); + std::fs::write(&file, vec![b'x'; 20]).unwrap(); + assert_eq!(text_file(&file.to_string_lossy(), 16).error, libc::EOVERFLOW); + assert_eq!(text_file(&tmp.path().join("none").to_string_lossy(), 16).state, ReadState::Unavailable); + } + + #[test] + fn non_storage_devices_are_not_applicable() { + let device = Device { subsystem: "usb".into(), ..Default::default() }; + let found = collect_storage_properties(&device, true, None); + assert!(!found.applicable && found.entities.is_empty() && found.notes.is_empty()); + } + + #[test] + fn real_disks_report_native_metadata() { + let devices = crate::scan().expect("scan"); + let Some(disk) = devices.iter().find(|d| d.subsystem == "block" && d.devtype == "disk") else { return }; + let found = collect_storage_properties(disk, false, None); + assert!(found.applicable); + let entity = found.entities.iter().find(|e| e.id == disk.path).expect("selected disk entity"); + let value = |id: &str| entity.fields.iter().find(|f| f.id == id).map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(value("capacity").state, ReadState::Available); + assert!(value("capacity").value.parse::().unwrap() % 512 == 0); + assert_eq!(value("dev").state, ReadState::Available); + let mountinfo = found.notes.iter().find(|n| n.id == "mountinfo").expect("mountinfo note"); + assert_eq!(mountinfo.value.value, "application-namespace"); + // Deep search never queries the service; without the feature the note says why. + let service_notes = found.notes.iter().filter(|n| n.id == "service").count(); + assert_eq!(service_notes, usize::from(!cfg!(feature = "udisks2"))); + let cancelled = AtomicBool::new(true); + assert!(collect_storage_properties(disk, true, Some(&cancelled)).entities.is_empty()); + } + + #[cfg(feature = "udisks2")] + mod service_tests { + use super::super::service::{nvme_fields, validate_ata}; + use super::*; + use zbus::zvariant::{OwnedValue, Value}; + + fn owned(value: Value<'_>) -> OwnedValue { + OwnedValue::try_from(value).unwrap() + } + + #[test] + fn nvme_values_are_typed_and_zero_means_unknown_only_where_documented() { + let mut values = HashMap::new(); + values.insert("avail_spare".to_string(), owned(Value::U8(100))); + values.insert("percent_used".to_string(), owned(Value::U64(3))); // wrong type + values.insert("wctemp".to_string(), owned(Value::U16(0))); // zero = unknown + values.insert("power_cycles".to_string(), owned(Value::U64(0))); // zero is a real count + values.insert("temp_sensors".to_string(), owned(Value::from(vec![310u16, 0, 305]))); + let mut entity = StorageEntity { id: "/org/freedesktop/UDisks2/drives/x".into(), ..Default::default() }; + nvme_fields(&mut entity, &values, "org.freedesktop.UDisks2.NVMe.Controller", "src"); + let get = |id: &str| entity.fields.iter().find(|f| f.id.ends_with(id)).unwrap().value.clone(); + assert_eq!(get("avail_spare").value, "100"); + assert_eq!(get("percent_used").error, libc::EINVAL); + assert_eq!(get("wctemp").state, ReadState::Unavailable); + assert_eq!(get("power_cycles").value, "0"); + assert_eq!(get("temp_sensors/1").value, "310"); + assert_eq!(get("temp_sensors/2").state, ReadState::Unavailable); + assert_eq!(get("temp_sensors/3").value, "305"); + assert_eq!(get("temp_sensors/8").state, ReadState::Unavailable); + } + + #[test] + fn too_many_or_mistyped_sensors_are_explicit_errors() { + let mut entity = StorageEntity::default(); + let mut values = HashMap::new(); + values.insert("temp_sensors".to_string(), owned(Value::from(vec![1u16; 9]))); + nvme_fields(&mut entity, &values, "i", "s"); + assert!(entity.fields.iter().filter(|f| f.id.contains("temp_sensors")).all(|f| f.value.error == libc::EOVERFLOW)); + let mut other = StorageEntity::default(); + values.insert("temp_sensors".to_string(), owned(Value::from(vec![1u32]))); + nvme_fields(&mut other, &values, "i", "s"); + assert!(other.fields.iter().filter(|f| f.id.contains("temp_sensors")).all(|f| f.value.error == libc::EINVAL)); + } + + #[test] + fn ata_lists_are_accepted_whole_or_rejected_whole() { + let row = |id: u8, current: i32| (id, "raw-read-error-rate".to_string(), 0x2fu16, current, 100, 6, 0i64, 0, HashMap::new()); + let good = validate_ata(vec![row(1, 100), row(5, 200)]).unwrap(); + assert_eq!((good.len(), good[1].id), (2, 5)); + assert_eq!(validate_ata(vec![row(1, 100), row(1, 100)]), Err(libc::EINVAL), "duplicate ids"); + assert_eq!(validate_ata(vec![row(1, 300)]), Err(libc::EINVAL), "out-of-range value"); + let mut long = row(2, 1); + long.1 = "x".repeat(129); + assert_eq!(validate_ata(vec![long]), Err(libc::EOVERFLOW)); + let many: Vec<_> = (0..=256u32).map(|i| row((i % 255) as u8, 1)).collect(); + assert!(validate_ata(many).is_err()); + } + + #[test] + fn live_service_snapshot_is_cache_only_when_available() { + let devices = crate::scan().expect("scan"); + let Some(disk) = devices.iter().find(|d| d.subsystem == "block" && d.devtype == "disk") else { return }; + let found = collect_storage_properties(disk, true, None); + let service = found.notes.iter().find(|n| n.id == "service").expect("service note"); + if service.value.state == ReadState::Available { + assert_eq!(service.value.value, "cached"); + assert!(found.entities.iter().any(|e| e.id.starts_with("/org/freedesktop/UDisks2/"))); + } else { + assert!(matches!(service.value.state, ReadState::Unavailable | ReadState::PermissionDenied | ReadState::Error)); + } + } + } +} diff --git a/crates/qldm-core/src/storage_presentation.rs b/crates/qldm-core/src/storage_presentation.rs new file mode 100644 index 0000000..7c510c6 --- /dev/null +++ b/crates/qldm-core/src/storage_presentation.rs @@ -0,0 +1,696 @@ +//! Display model for the Storage tab, ported from `src/devices/storagepresentation.cpp`. +//! +//! Native and UDisks2 entities that an exact `Block.DeviceNumber` link proves to be the same +//! block device are merged; names that merely look alike never are. Health is per drive and +//! always carries its cached scope. No widgets or services are touched here. +use crate::i18n::{arg, argn, format_fixed, format_integer, tr}; +use crate::model::ReadState; +use crate::propertytext::storage_field_value; +use crate::storage::{AtaSmartAttribute, Format, StorageEntity, StorageField, StorageLink, StorageProperties}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StorageDisplayField { + pub label: String, + pub value: String, + pub source: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StorageDisplayGroup { + pub title: String, + pub fields: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StorageDisplayAtaAttribute { + pub id: String, + pub name: String, + pub current: String, + pub worst: String, + pub threshold: String, + pub interpreted: String, + pub flags: String, + pub source: String, +} + +#[derive(Clone, Debug, Default)] +pub struct StorageDisplayRow { + pub id: String, + pub name: String, + pub kind: String, + pub capacity: String, + pub filesystem: String, + pub mounts: String, + pub source: String, + pub fields: Vec, + pub health_groups: Vec, + pub ata_attributes: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct StoragePresentation { + pub overview: Vec, + pub volumes: Vec, + pub health: Vec, + /// Unique explicit association with the selected device, if any. + pub preferred_health_drive: String, + pub notes: String, +} + +fn field_of(label: impl Into, value: impl Into, source: impl Into) -> StorageDisplayField { + StorageDisplayField { label: label.into(), value: value.into(), source: source.into() } +} + +fn health_field(f: &StorageField) -> bool { + f.id == "health" || f.id.contains(".Drive.Ata/") || f.id.contains(".NVMe.Controller/") +} + +fn drive_entity(e: &StorageEntity) -> bool { + e.fields.iter().any(|f| f.id.starts_with("org.freedesktop.UDisks2.Drive/") || health_field(f)) +} + +fn available(f: &StorageField) -> bool { + f.value.state == ReadState::Available && !f.value.value.is_empty() +} + +fn value(entity: &StorageEntity, keys: &[&str]) -> String { + for key in keys { + if let Some(f) = entity.fields.iter().find(|f| f.id == *key && available(f)) { + return storage_field_value(f); + } + } + tr("Unavailable") +} + +fn file_name(path: &str) -> String { + path.rsplit('/').next().unwrap_or("").to_string() +} + +fn name(entity: &StorageEntity) -> String { + for key in [ + "DEVNAME", + "dm/name", + "org.freedesktop.UDisks2.LogicalVolume/Name", + "org.freedesktop.UDisks2.VolumeGroup/Name", + "org.freedesktop.UDisks2.MDRaid/Name", + "org.freedesktop.UDisks2.Drive/Model", + ] { + if let Some(f) = entity.fields.iter().find(|f| f.id == key && available(f)) { + return f.value.value.clone(); + } + } + // Kernel names/object basenames are display fallbacks, never identity keys. + file_name(&entity.id) +} + +fn kind(entity: &StorageEntity) -> String { + for f in &entity.fields { + if f.id == "partition" || f.id.contains(".Partition/") { + return tr("Partition"); + } + if f.id == "dm/name" { + return tr("Device mapper"); + } + if f.id == "md/level" || f.id.contains(".MDRaid/") { + return tr("RAID"); + } + if f.id.contains(".LogicalVolume/") { + return tr("Logical volume"); + } + if f.id.contains(".VolumeGroup/") { + return tr("Volume group"); + } + } + tr("Block device") +} + +fn display(f: &StorageField) -> StorageDisplayField { + field_of(tr(f.label), storage_field_value(f), f.source.clone()) +} + +/// Aliases describe the same fact on one block entity. All sources are kept; disagreeing +/// available values are shown together rather than silently overwritten. +fn fact(output: &mut Vec, entity: &StorageEntity, caption: &str, keys: &[&str]) { + let mut values: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); + let mut fallback: Option<&StorageField> = None; + for key in keys { + for f in entity.fields.iter().filter(|f| f.id == *key) { + fallback.get_or_insert(f); + if !sources.contains(&f.source) { + sources.push(f.source.clone()); + } + if available(f) { + let text = storage_field_value(f); + if !values.contains(&text) { + values.push(text); + } + } + } + } + let Some(fallback) = fallback else { return }; + let mut text = if values.is_empty() { storage_field_value(fallback) } else { values.join("\n") }; + if values.len() > 1 { + text = format!("{}\n{text}", tr("Conflicting metadata:")); + } + output.push(field_of(tr(caption), text, sources.join("\n"))); +} + +fn ata_attribute(attribute: &AtaSmartAttribute, drive: &str) -> StorageDisplayAtaAttribute { + let normalized = |n: i32| if n < 0 { tr("Unavailable") } else { format_integer(n as u64) }; + let interpreted = if attribute.unit == 0 || (attribute.unit == 4 && attribute.interpreted == 0) { + tr("Unavailable") + } else if attribute.interpreted < 0 { + tr("Invalid health metadata") + } else { + let number = format_integer(attribute.interpreted as u64); + match attribute.unit { + 1 => number, + 2 => arg(tr("%1 milliseconds"), &number), + 3 => arg(tr("%1 sectors"), &number), + 4 => arg(tr("%1 °C"), &format_fixed(attribute.interpreted as f64 / 1000.0 - 273.15, 1)), + unit => arg(tr("Unavailable (unknown unit %1)"), &unit.to_string()), + } + }; + StorageDisplayAtaAttribute { + id: attribute.id.to_string(), + name: if attribute.name.is_empty() { tr("Unnamed attribute") } else { attribute.name.clone() }, + current: normalized(attribute.current), + worst: normalized(attribute.worst), + threshold: normalized(attribute.threshold), + interpreted, + flags: format!("0x{:04x}", attribute.flags), + source: format!("{drive} : org.freedesktop.UDisks2.Drive.Ata.SmartGetAttributes : ID {}", attribute.id), + } +} + +fn caption_for(key: &str) -> Option<&'static str> { + Some(match key { + "health" => "Health reporting", + "SmartUpdated" => "Last health update", + "SmartSupported" => "ATA SMART supported", + "SmartEnabled" => "ATA SMART enabled", + "SmartFailing" => "Drive failure prediction", + "SmartCriticalWarning" => "Critical warnings", + "SmartPowerOnSeconds" | "SmartPowerOnHours" => "Power-on time", + "SmartTemperature" => "Temperature", + "SmartNumBadSectors" => "Bad sectors", + "SmartNumAttributesFailing" => "Failing attributes", + "SmartNumAttributesFailedInThePast" => "Attributes failed in the past", + "avail_spare" => "Available spare", + "spare_thresh" => "Spare threshold", + "percent_used" => "Estimated endurance used", + "total_data_read" => "Total data read", + "total_data_written" => "Total data written", + "ctrl_busy_time" => "Controller busy time", + "power_cycles" => "Power cycles", + "unsafe_shutdowns" => "Unsafe shutdowns", + "media_errors" => "Media and data integrity errors", + "num_err_log_entries" => "Error information log entries", + "wctemp" => "Warning temperature threshold", + "cctemp" => "Critical temperature threshold", + "warning_temp_time" => "Time above warning temperature", + "critical_temp_time" => "Time above critical temperature", + _ => return None, + }) +} + +fn health_groups(row: &mut StorageDisplayRow, entity: &StorageEntity) { + let (mut summary, mut usage, mut errors, mut temperatures) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let (mut nvme, mut sensor_error_shown) = (false, false); + for f in &entity.fields { + if !health_field(f) { + continue; + } + nvme = nvme || f.id.contains(".NVMe.Controller/"); + let key = f.id.rsplit('/').next().unwrap_or(""); + let mut shown = display(f); + if let Some(caption) = caption_for(key) { + shown.label = tr(caption); + } + if key == "SmartGetAttributes" { + shown.label = if f.id.contains(".Drive.Ata/") { tr("ATA SMART attributes") } else { tr("NVMe health details") }; + } + if f.id.contains("/temp_sensors/") { + // Zero/unreported slots are absent evidence, not failed reads. + if f.value.state == ReadState::Unavailable && f.value.error == 0 { + continue; + } + if f.value.state != ReadState::Available { + if sensor_error_shown { + continue; + } + sensor_error_shown = true; + shown.label = tr("Temperature sensors"); + } else { + shown.label = arg(tr("Temperature sensor %1"), key); + } + temperatures.push(shown); + } else if f.format == Format::Kelvin || key.ends_with("temp_time") { + temperatures.push(shown); + } else if matches!( + key, + "SmartPowerOnSeconds" | "SmartPowerOnHours" | "total_data_read" | "total_data_written" | "ctrl_busy_time" | "power_cycles" | "unsafe_shutdowns" + ) { + usage.push(shown); + } else if matches!( + key, + "SmartNumBadSectors" | "SmartNumAttributesFailing" | "SmartNumAttributesFailedInThePast" | "media_errors" | "num_err_log_entries" + ) { + errors.push(shown); + } else if key == "SmartUpdated" { + summary.insert(0, shown); + } else { + summary.push(shown); + } + } + let mut group = |title: String, fields: Vec| { + if !fields.is_empty() { + row.health_groups.push(StorageDisplayGroup { title, fields }); + } + }; + group(if nvme { tr("Health and endurance") } else { tr("Health") }, summary); + group(tr("Usage"), usage); + group(tr("Errors"), errors); + group(tr("Temperatures"), temperatures); + let mut attributes = entity.ata_attributes.clone(); + attributes.sort_by_key(|a| a.id); + row.ata_attributes.extend(attributes.iter().map(|a| ata_attribute(a, &entity.id))); +} + +fn relation(link: &StorageLink) -> String { + let source = link.source.as_str(); + let text = if source.ends_with(".Table") { + "Partition of" + } else if source.ends_with(".CryptoBackingDevice") { + "Backed by" + } else if source.ends_with(".Drive") { + "Drive" + } else if source.ends_with(".MDRaidMember") { + "RAID member of" + } else if source.ends_with(".MDRaid") { + "RAID device" + } else if source.ends_with(".LogicalVolume") { + "Logical volume" + } else if source.ends_with(".VolumeGroup") { + "Volume group" + } else { + link.label + }; + tr(text) +} + +fn overview(fields: &mut Vec, entity: &StorageEntity) { + fields.push(field_of(tr("Device"), name(entity), entity.id.clone())); + fact(fields, entity, "Model", &["model", "hardware/model", "ID_MODEL", "org.freedesktop.UDisks2.Drive/Model"]); + fact(fields, entity, "Manufacturer", &["hardware/vendor", "ID_VENDOR", "org.freedesktop.UDisks2.Drive/Vendor"]); + fact(fields, entity, "Serial number", &["serial", "hardware/serial", "ID_SERIAL_SHORT", "org.freedesktop.UDisks2.Drive/Serial"]); + fact( + fields, + entity, + "Firmware revision", + &["firmware_rev", "hardware/firmware_rev", "hardware/rev", "ID_REVISION", "org.freedesktop.UDisks2.Drive/Revision"], + ); + fact(fields, entity, "Transport", &["ID_BUS", "org.freedesktop.UDisks2.Drive/ConnectionBus"]); + fact(fields, entity, "Native transport", &["native-transport"]); + // A Drive.Size is a physical-drive fact, not a namespace/partition capacity alias. + fact(fields, entity, "Block capacity", &["capacity"]); + fact(fields, entity, "Drive capacity", &["org.freedesktop.UDisks2.Drive/Size"]); + fact(fields, entity, "Logical sector size", &["queue/logical_block_size"]); + fact(fields, entity, "Physical sector size", &["queue/physical_block_size"]); + fact(fields, entity, "Kernel removable media", &["removable"]); + fact(fields, entity, "Block read-only", &["ro"]); + for f in &entity.fields { + if f.id.ends_with("/Removable") || f.id.ends_with("/MediaRemovable") || f.id.ends_with("/MediaAvailable") { + fields.push(display(f)); + } + } +} + +const BLOCK_LINK: &str = "UDisks2 Block.DeviceNumber / sysfs dev"; + +pub fn storage_presentation(snapshot: &StorageProperties, selected_path: &str) -> StoragePresentation { + let mut result = StoragePresentation::default(); + let mut canonical: BTreeMap = BTreeMap::new(); + let mut entities: BTreeMap = snapshot.entities.iter().map(|e| (e.id.clone(), e.clone())).collect(); + for link in &snapshot.links { + if link.source != BLOCK_LINK || !entities.contains_key(&link.from) || !entities.contains_key(&link.to) { + continue; + } + canonical.insert(link.to.clone(), link.from.clone()); + let merged = entities[&link.to].fields.clone(); + entities.get_mut(&link.from).expect("checked").fields.extend(merged); + } + for key in canonical.keys() { + entities.remove(key); + } + let resolve = |id: &str| canonical.get(id).cloned().unwrap_or_else(|| id.to_string()); + + let mut selected_drives: BTreeSet = BTreeSet::new(); + for link in &snapshot.links { + if resolve(&link.from) == selected_path && link.source.ends_with(".Drive") { + selected_drives.insert(resolve(&link.to)); + } + } + // Controller dialogs cover their namespaces; this is an explicit descendant relation. + if selected_path.starts_with("/sys/") { + for link in &snapshot.links { + if resolve(&link.from).starts_with(&format!("{selected_path}/")) && link.source.ends_with(".Drive") { + selected_drives.insert(resolve(&link.to)); + } + } + } + let mut selected = entities.get(selected_path).cloned().unwrap_or_default(); + // Identity aliases from a single explicitly associated drive share the overview. Drive + // capacity retains its own caption: it is never a block capacity alias. + if selected_drives.len() == 1 { + let drive = selected_drives.iter().next().expect("one").clone(); + if let Some(entity) = entities.get(&drive) { + selected.fields.extend(entity.fields.clone()); + result.overview.push(field_of(tr("Associated drive"), name(entity), drive)); + } + } + if entities.contains_key(selected_path) { + overview(&mut result.overview, &selected); + } else { + result.overview.push(field_of(tr("Storage snapshot"), tr("Unavailable"), selected_path)); + } + for entity in entities.values() { + let mut row = StorageDisplayRow { id: entity.id.clone(), name: name(entity), source: entity.id.clone(), ..Default::default() }; + for (key, target) in &canonical { + if *target == entity.id { + row.source.push('\n'); + row.source.push_str(key); + } + } + if drive_entity(entity) { + let mut nodes: Vec = Vec::new(); + for link in &snapshot.links { + if resolve(&link.to) == entity.id && link.source.ends_with(".Drive") { + if let Some(block) = entities.get(&resolve(&link.from)) { + let partition = block.fields.iter().any(|f| f.id == "partition" || f.id.contains(".Partition/")); + if !partition { + let node = name(block); + if !nodes.contains(&node) { + nodes.push(node); + } + } + } + } + } + if !nodes.is_empty() { + row.name.push_str(&format!(" — {}", nodes.join(", "))); + } + row.kind = tr("Cached drive health"); + health_groups(&mut row, entity); + if row.health_groups.is_empty() && row.ata_attributes.is_empty() { + row.fields.push(field_of(tr("Cached health"), tr("Unavailable"), entity.id.clone())); + } + if selected_drives.len() == 1 && selected_drives.contains(&entity.id) { + result.preferred_health_drive = entity.id.clone(); + } + if selected_drives.len() > 1 && selected_drives.contains(&entity.id) { + result.overview.push(field_of(tr("Associated drive"), row.name.clone(), entity.id.clone())); + overview(&mut result.overview, entity); + } + result.health.push(row); + continue; + } + // The selected NVMe controller is an overview, not another volume. + let block = entity.fields.iter().any(|f| f.id == "dev"); + if entity.id == selected_path && entity.id.starts_with("/sys/") && !block && entity.fields.iter().any(|f| f.id == "firmware_rev") { + continue; + } + row.kind = kind(entity); + row.capacity = value(entity, &["capacity", "org.freedesktop.UDisks2.Partition/Size"]); + row.filesystem = value(entity, &["ID_FS_TYPE", "org.freedesktop.UDisks2.Block/IdType"]); + row.mounts = value(entity, &["mounts"]); + let label = value(entity, &["ID_FS_LABEL", "org.freedesktop.UDisks2.Block/IdLabel"]); + if label != tr("Unavailable") { + row.name.push_str(&format!(" — {label}")); + } + const FACTS: [(&str, &[&str]); 7] = [ + ("Block capacity", &["capacity", "org.freedesktop.UDisks2.Partition/Size"]), + ("Filesystem / content type", &["ID_FS_TYPE", "org.freedesktop.UDisks2.Block/IdType"]), + ("Content usage", &["ID_FS_USAGE", "org.freedesktop.UDisks2.Block/IdUsage"]), + ("Filesystem / content label", &["ID_FS_LABEL", "org.freedesktop.UDisks2.Block/IdLabel"]), + ("Filesystem / content UUID", &["ID_FS_UUID", "org.freedesktop.UDisks2.Block/IdUUID"]), + ("Partition number", &["partition", "org.freedesktop.UDisks2.Partition/Number"]), + ("Partition table type", &["ID_PART_TABLE_TYPE", "org.freedesktop.UDisks2.PartitionTable/Type"]), + ]; + let mut grouped: BTreeSet<&str> = BTreeSet::new(); + for (caption, keys) in FACTS { + fact(&mut row.fields, entity, caption, keys); + grouped.extend(keys.iter().copied()); + } + for f in &entity.fields { + if !health_field(f) && !grouped.contains(f.id.as_str()) { + row.fields.push(display(f)); + } + } + for link in &snapshot.links { + if resolve(&link.from) != entity.id || resolve(&link.to) == entity.id { + continue; + } + let target = resolve(&link.to); + let target_name = entities.get(&target).map_or_else(|| file_name(&target), name); + row.fields.push(field_of(relation(link), target_name, format!("{}\n{target}", link.source))); + } + result.volumes.push(row); + } + if result.health.is_empty() { + let row_name = entities.get(selected_path).map_or_else(|| file_name(selected_path), name); + result.health.push(StorageDisplayRow { + id: selected_path.to_string(), + name: row_name, + fields: vec![field_of(tr("Cached health"), tr("Unavailable"), tr("No supported cached drive health in this snapshot"))], + ..Default::default() + }); + } + let notes: Vec = snapshot.notes.iter().map(|note| format!("{}: {}\n{}", tr(note.label), storage_field_value(note), note.source)).collect(); + result.notes = notes.join("\n\n"); + result +} + +pub fn storage_fields_text(fields: &[StorageDisplayField]) -> String { + fields.iter().map(|f| format!("{}: {}\n{}", f.label, f.value, arg(tr("Source: %1"), &f.source))).collect::>().join("\n\n") +} + +pub fn storage_rows_text(rows: &[StorageDisplayRow]) -> String { + rows.iter() + .map(|row| { + let mut sections = vec![row.name.clone()]; + if !row.health_groups.is_empty() || !row.ata_attributes.is_empty() { + sections.push(tr("Cached drive health")); + } + if !row.fields.is_empty() { + sections.push(storage_fields_text(&row.fields)); + } + for group in &row.health_groups { + sections.push(format!("{}\n{}", group.title, storage_fields_text(&group.fields))); + } + if !row.ata_attributes.is_empty() { + let mut attributes = vec![tr("ATA SMART attributes")]; + for a in &row.ata_attributes { + attributes.push(argn( + tr("%1 (ID %2)\nNormalized value: %3\nWorst normalized value: %4\nThreshold: %5\nInterpreted value: %6\nFlags: %7\nSource: %8"), + &[&a.name, &a.id, &a.current, &a.worst, &a.threshold, &a.interpreted, &a.flags, &a.source], + )); + } + sections.push(attributes.join("\n\n")); + } + sections.join("\n\n") + }) + .collect::>() + .join("\n\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Attribute; + + fn sf(id: &str, value: &str, format: Format) -> StorageField { + StorageField { + id: id.into(), + label: "Fixture", + value: Attribute { state: ReadState::Available, value: value.into(), error: 0 }, + source: format!("fixture:{id}"), + format, + } + } + fn text(id: &str, value: &str) -> StorageField { + sf(id, value, Format::Text) + } + fn entity(id: &str, fields: Vec) -> StorageEntity { + StorageEntity { id: id.into(), fields, ata_attributes: vec![] } + } + fn edge(from: &str, to: &str, label: &'static str, source: &str) -> StorageLink { + StorageLink { from: from.into(), to: to.into(), label, source: source.into() } + } + + fn fixture() -> (StorageProperties, StorageEntity, StorageEntity, StorageEntity) { + let native = entity( + "/sys/devices/fixture/block/sda", + vec![ + text("DEVNAME", "/dev/sda"), + text("dev", "8:0"), + sf("capacity", "1024", Format::Bytes), + text("ID_FS_TYPE", "ext4"), + text("ID_FS_LABEL", "data"), + text("mounts", "/data"), + ], + ); + let cached_block = entity("/org/freedesktop/UDisks2/block_devices/sda", vec![text("org.freedesktop.UDisks2.Block/IdType", "xfs")]); + let drive = entity( + "/org/freedesktop/UDisks2/drives/fixture", + vec![ + text("org.freedesktop.UDisks2.Drive/Model", "Fixture Drive"), + sf("org.freedesktop.UDisks2.Drive/Size", "4096", Format::Bytes), + sf("org.freedesktop.UDisks2.Drive.Ata/SmartFailing", "0", Format::AtaHealth), + ], + ); + let storage = StorageProperties { + applicable: true, + entities: vec![native.clone(), cached_block.clone(), drive.clone()], + links: vec![ + edge(&native.id, &cached_block.id, "UDisks2 block metadata", BLOCK_LINK), + edge(&cached_block.id, &drive.id, "Storage relationship", "org.freedesktop.UDisks2.Block.Drive"), + ], + notes: vec![], + }; + (storage, native, cached_block, drive) + } + + #[test] + fn exact_block_mapping_merges_and_keeps_conflicts_visible() { + let (storage, native, _, drive) = fixture(); + let presented = storage_presentation(&storage, &native.id); + assert_eq!(presented.volumes.len(), 1); + assert_eq!(presented.volumes[0].name, "/dev/sda — data"); + assert!(storage_fields_text(&presented.volumes[0].fields).contains("Conflicting metadata:")); + assert!(presented.health.len() == 1 && presented.health[0].name.contains("/dev/sda")); + assert!(storage_rows_text(&presented.health).contains("not a guarantee of health")); + let overview = storage_fields_text(&presented.overview); + assert!(overview.contains("Block capacity:") && overview.contains("Drive capacity:")); + assert_eq!(presented.preferred_health_drive, drive.id); + } + + #[test] + fn related_drives_do_not_steal_the_preferred_health_drive() { + let (storage, native, _, drive) = fixture(); + let mut related = storage.clone(); + let mut other = drive.clone(); + other.id = "/org/freedesktop/UDisks2/drives/aaa_related".into(); + related.entities.push(other.clone()); + let multiple = storage_presentation(&related, &native.id); + assert!(multiple.health.len() == 2 && multiple.health[0].id == other.id); + assert_eq!(multiple.preferred_health_drive, drive.id, "sort order cannot choose it"); + let controller = "/sys/devices/fixture"; + assert_eq!(storage_presentation(&related, controller).preferred_health_drive, drive.id); + let other_block = entity(&format!("{controller}/block/sdb"), vec![text("DEVNAME", "/dev/sdb")]); + related.entities.push(other_block.clone()); + related.links.push(edge(&other_block.id, &other.id, "Storage relationship", "org.freedesktop.UDisks2.Block.Drive")); + assert!(storage_presentation(&related, controller).preferred_health_drive.is_empty(), "two explicit drives: no unique choice"); + } + + #[test] + fn matching_basenames_never_merge_without_an_exact_mapping() { + let (mut storage, native, ..) = fixture(); + storage.links.remove(0); + assert_eq!(storage_presentation(&storage, &native.id).volumes.len(), 2); + assert!(storage_presentation(&storage, &native.id).preferred_health_drive.is_empty()); + storage.entities = vec![native.clone()]; + storage.links.clear(); + let presented = storage_presentation(&storage, &native.id); + assert!(presented.health.len() == 1 && storage_rows_text(&presented.health).contains("Unavailable")); + assert!(presented.preferred_health_drive.is_empty()); + } + + #[test] + fn nvme_health_groups_keep_overflow_zero_counters_sensors_and_unknown_warnings() { + let nvme = "org.freedesktop.UDisks2.NVMe.Controller/"; + let attributes = format!("{nvme}SmartGetAttributes/"); + let sensor_error = |n: u8| StorageField { + id: format!("{attributes}temp_sensors/{n}"), + label: "Fixture", + value: Attribute { state: ReadState::Error, value: String::new(), error: libc::EPROTO }, + source: "fixture:invalid".into(), + format: Format::Kelvin, + }; + let absent = StorageField { + id: format!("{attributes}temp_sensors/2"), + label: "Fixture", + value: Attribute::default(), + source: "fixture:absent".into(), + format: Format::Kelvin, + }; + let drive = entity( + "/org/freedesktop/UDisks2/drives/nvme_fixture", + vec![ + text("org.freedesktop.UDisks2.Drive/Model", "NVMe fixture"), + sf(&format!("{nvme}SmartUpdated"), "1720000000", Format::Epoch), + sf(&format!("{nvme}SmartCriticalWarning"), "readonly\nfuture_warning", Format::NvmeHealth), + sf(&format!("{attributes}percent_used"), "150", Format::Percent), + sf(&format!("{attributes}media_errors"), "0", Format::Count), + sf(&format!("{attributes}temp_sensors/1"), "306", Format::Kelvin), + absent, + sensor_error(3), + sensor_error(4), + ], + ); + let health = StorageProperties { entities: vec![drive.clone()], ..Default::default() }; + let report = storage_presentation(&health, &drive.id); + let copied = storage_rows_text(&report.health); + assert_eq!(report.health[0].health_groups[0].title, "Health and endurance"); + assert!(copied.contains("150%") && copied.contains("Media and data integrity errors: 0")); + assert!(copied.contains("Temperature sensor 1: 33 °C") && !copied.contains("Temperature sensor 2")); + assert_eq!(copied.matches("Temperature sensors:").count(), 1); + assert!(copied.contains("Media is in read-only mode") && copied.contains("future_warning")); + } + + #[test] + fn ata_table_is_ordered_and_precise() { + let mut drive = entity( + "/org/freedesktop/UDisks2/drives/ata_fixture", + vec![text("org.freedesktop.UDisks2.Drive/Model", "ATA fixture"), sf("org.freedesktop.UDisks2.Drive.Ata/SmartFailing", "0", Format::AtaHealth)], + ); + let row = |id, name: &str, flags, current, worst, threshold, interpreted, unit| AtaSmartAttribute { + id, + name: name.into(), + flags, + current, + worst, + threshold, + interpreted, + unit, + }; + drive.ata_attributes = vec![ + row(194, "temperature-celsius", 0, 100, 95, 0, 306000, 4), + row(9, "power-on-hours", 0, 0, -1, 0, 1234567, 0), + row(5, "reallocated-sector-count", 3, 100, 100, 36, 0, 3), + ]; + let health = StorageProperties { entities: vec![drive.clone()], ..Default::default() }; + let report = storage_presentation(&health, &drive.id); + let table = &report.health[0].ata_attributes; + assert_eq!(table.iter().map(|a| a.id.as_str()).collect::>(), ["5", "9", "194"]); + assert_eq!((table[1].current.as_str(), table[1].worst.as_str(), table[1].interpreted.as_str()), ("0", "Unavailable", "Unavailable")); + assert_eq!(table[2].interpreted, "32.9 °C"); + let copy = storage_rows_text(&report.health); + assert!(copy.contains("reallocated-sector-count (ID 5)") && copy.contains("0 sectors")); + assert!(copy.contains("Flags: 0x0003") && copy.contains(&format!("Source: {}", drive.id))); + } + + #[test] + fn controller_overview_is_not_a_volume_and_notes_are_listed() { + let controller = entity("/sys/devices/pci/nvme0", vec![text("model", "Ctl"), text("firmware_rev", "1.0")]); + let namespace = entity("/sys/devices/pci/nvme0/nvme0n1", vec![text("dev", "259:0")]); + let storage = + StorageProperties { applicable: true, entities: vec![controller.clone(), namespace], notes: vec![text("service", "cached")], ..Default::default() }; + let report = storage_presentation(&storage, &controller.id); + assert_eq!(report.volumes.len(), 1, "controller is the overview, not a row"); + assert!(report.overview.iter().any(|f| f.label == "Model" && f.value == "Ctl")); + assert!(report.notes.contains("Available; cached properties only")); + } +} diff --git a/crates/qldm-core/src/sysfs.rs b/crates/qldm-core/src/sysfs.rs new file mode 100644 index 0000000..8ddf91e --- /dev/null +++ b/crates/qldm-core/src/sysfs.rs @@ -0,0 +1,151 @@ +//! Bounded, read-only sysfs helpers, ported from `readAttribute` in +//! `src/devices/enumerator.cpp` and `src/devices/directoryscan.h`. +use crate::model::{Attribute, ReadState}; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Largest attribute value accepted; one more byte read means overflow. +const ATTRIBUTE_LIMIT: u64 = 4096; + +/// Reads one small text attribute without following a final symlink. +pub fn read_attribute(path: impl AsRef) -> Attribute { + let mut file = match OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path) { + Ok(file) => file, + Err(e) => { + let error = e.raw_os_error().unwrap_or(libc::EIO); + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENOENT => ReadState::Unavailable, + _ => ReadState::Error, + }; + return Attribute { state, value: String::new(), error }; + } + }; + let mut buffer = Vec::new(); + if let Err(e) = (&mut file).take(ATTRIBUTE_LIMIT + 1).read_to_end(&mut buffer) { + let error = e.raw_os_error().unwrap_or(libc::EIO); + let state = match error { + libc::ENODEV => ReadState::Removed, + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + _ => ReadState::Error, + }; + return Attribute { state, value: String::new(), error }; + } + if buffer.len() as u64 > ATTRIBUTE_LIMIT { + return Attribute { state: ReadState::Error, value: String::new(), error: libc::EOVERFLOW }; + } + Attribute { state: ReadState::Available, value: String::from_utf8_lossy(&buffer).trim().to_string(), error: 0 } +} + +/// Shell-style `*` / `?` match, as used by `QDirListing` name filters. +fn glob_matches(pattern: &[u8], text: &[u8]) -> bool { + match (pattern.split_first(), text.split_first()) { + (None, None) => true, + (Some((b'*', rest)), _) => glob_matches(rest, text) || (!text.is_empty() && glob_matches(pattern, &text[1..])), + (Some((b'?', rest)), Some((_, t))) => glob_matches(rest, t), + (Some((p, rest)), Some((t, tail))) if p == t => glob_matches(rest, tail), + _ => false, + } +} + +/// Sorted names of sub-directories (symlinks to directories included), or an errno. +/// +/// Hidden entries, files and broken links are omitted. More than `limit` matches +/// is `EOVERFLOW` and never a partial list; a set `cancel` flag is `ECANCELED`. +pub fn bounded_directory_names(path: impl AsRef, limit: i64, filters: &[&str], cancel: Option<&AtomicBool>) -> Result, i32> { + let cancelled = || cancel.is_some_and(|c| c.load(Ordering::Relaxed)); + if limit < 0 { + return Err(libc::EINVAL); + } + if cancelled() { + return Err(libc::ECANCELED); + } + let path = path.as_ref(); + let mut names = Vec::new(); + for entry in fs::read_dir(path).map_err(|e| e.raw_os_error().unwrap_or(libc::EIO))? { + if cancelled() { + return Err(libc::ECANCELED); + } + let Ok(entry) = entry else { continue }; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') || !fs::metadata(entry.path()).is_ok_and(|m| m.is_dir()) { + continue; + } + if !filters.is_empty() && !filters.iter().any(|f| glob_matches(f.as_bytes(), name.as_bytes())) { + continue; + } + if names.len() as i64 >= limit { + return Err(libc::EOVERFLOW); + } + names.push(name); + } + if cancelled() { + return Err(libc::ECANCELED); + } + names.sort(); + Ok(names) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + #[test] + fn directory_names_follow_cpp_fixture() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + for d in ["zeta", "alpha", ".hidden"] { + fs::create_dir(root.join(d)).unwrap(); + } + fs::write(root.join("regular"), b"").unwrap(); + symlink("alpha", root.join("linked")).unwrap(); + symlink("regular", root.join("file-link")).unwrap(); + symlink("missing", root.join("broken")).unwrap(); + + assert_eq!(bounded_directory_names(root, 3, &[], None), Ok(vec!["alpha".into(), "linked".into(), "zeta".into()])); + assert_eq!(bounded_directory_names(root, 2, &[], None), Err(libc::EOVERFLOW)); + assert_eq!(bounded_directory_names(root, 1, &["a*"], None), Ok(vec!["alpha".into()])); + assert_eq!(bounded_directory_names(root, 0, &[], None), Err(libc::EOVERFLOW)); + assert_eq!(bounded_directory_names(root, -1, &[], None), Err(libc::EINVAL)); + fs::create_dir(root.join("empty")).unwrap(); + assert_eq!(bounded_directory_names(root.join("empty"), 0, &[], None), Ok(vec![])); + let cancel = AtomicBool::new(true); + assert_eq!(bounded_directory_names(root, 3, &[], Some(&cancel)), Err(libc::ECANCELED)); + } + + #[test] + fn glob_supports_star_and_question_mark() { + assert!(glob_matches(b"a*", b"alpha")); + assert!(glob_matches(b"?lpha", b"alpha")); + assert!(!glob_matches(b"a*z", b"alpha")); + } + + #[test] + fn attribute_states() { + let dir = tempfile::tempdir().unwrap(); + let ok = dir.path().join("name"); + fs::write(&ok, b" Touchpad \n").unwrap(); + let a = read_attribute(&ok); + assert_eq!((a.state, a.value.as_str(), a.error), (ReadState::Available, "Touchpad", 0)); + + let missing = read_attribute(dir.path().join("none")); + assert_eq!((missing.state, missing.error), (ReadState::Unavailable, libc::ENOENT)); + + let big = dir.path().join("big"); + fs::write(&big, vec![b'x'; 4097]).unwrap(); + let o = read_attribute(&big); + assert_eq!((o.state, o.error), (ReadState::Error, libc::EOVERFLOW)); + fs::write(&big, vec![b'x'; 4096]).unwrap(); + assert_eq!(read_attribute(&big).state, ReadState::Available); + + symlink(&ok, dir.path().join("link")).unwrap(); + assert_eq!(read_attribute(dir.path().join("link")).state, ReadState::Error, "final symlink is not followed"); + + // A directory opens but cannot be read: EISDIR is a generic Error. + assert_eq!(read_attribute(dir.path()).state, ReadState::Error); + } +} diff --git a/crates/qldm-core/src/system.rs b/crates/qldm-core/src/system.rs new file mode 100644 index 0000000..6840765 --- /dev/null +++ b/crates/qldm-core/src/system.rs @@ -0,0 +1,1078 @@ +//! System information, ported from `src/devices/systemproperties.cpp`. +//! +//! One read-only pass over kernel and firmware interfaces: OS, kernel, CPU models and feature +//! flags, topology and caches, installed and usable memory, DMI identity, GPU Resizable BAR +//! capability, boot mode, Secure Boot and virtualization. Pure parsers are kept separate so +//! fixtures can drive them; no command is ever run and no shell syntax is evaluated. Unknown is +//! never reported as zero, and a value that changed during collection is rejected. +use crate::cpuinfo::{parse_cpu_models, read_cpuinfo}; +use crate::dirfd::{available, failure as dir_failure}; +use crate::model::{Attribute, ReadState}; +use crate::secureboot::{collect_secure_boot_information, MokCertificates}; +use crate::sysfs::bounded_directory_names; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::io::Read; +use std::sync::atomic::{AtomicBool, Ordering}; + +fn failed(error: i32) -> Attribute { + let state = match error { + libc::EACCES | libc::EPERM => ReadState::PermissionDenied, + libc::ENOENT => ReadState::Unavailable, + _ => ReadState::Error, + }; + Attribute { state, value: String::new(), error } +} + +#[derive(Clone, Debug, Default)] +pub struct FirmwareMemoryDevice { + /// Firmware record index, not a socket/channel identifier. + pub index: usize, + /// Stable udev property suffixes. + pub values: HashMap, +} + +#[derive(Clone, Debug, Default)] +pub struct FirmwareMemoryInformation { + pub status: Attribute, + pub devices: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct GpuResizableBar { + pub pci_address: String, + /// Available: sysfs exposes the capability; Unavailable: not exposed. + pub status: Attribute, + /// Raw validated hexadecimal bitmaps, by BAR number. + pub supported_sizes: BTreeMap, + /// Allocated aperture size in bytes, by BAR number. + pub current_sizes: BTreeMap, +} + +#[derive(Clone, Debug, Default)] +pub struct SystemProperties { + pub gpu_rebar_status: Attribute, + pub gpu_rebar: Vec, + pub memory_devices: FirmwareMemoryInformation, + pub mok: MokCertificates, + pub values: HashMap, + pub sources: HashMap, +} + +/// Small text file, trimmed; an empty file is an unavailable value (not an empty string). +fn read_text(path: &str, limit: usize, cancel: Option<&AtomicBool>) -> Attribute { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return failed(libc::ECANCELED); + } + let mut file = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) => return failed(e.raw_os_error().unwrap_or(libc::EIO)), + }; + let mut bytes = Vec::new(); + if let Err(e) = (&mut file).take(limit as u64 + 1).read_to_end(&mut bytes) { + return failed(e.raw_os_error().unwrap_or(libc::EIO)); + } + if bytes.len() > limit { + return failed(libc::EOVERFLOW); + } + let text = String::from_utf8_lossy(&bytes).trim().to_string(); + if text.is_empty() { + Attribute::default() + } else { + available(text) + } +} + +fn non_negative(value: &Attribute) -> Option { + (value.state == ReadState::Available).then(|| value.value.parse::().ok()).flatten().filter(|n| *n >= 0) +} + +fn unsigned_decimal(text: &str) -> Option { + (!text.is_empty() && text.len() <= 20 && text.bytes().all(|b| b.is_ascii_digit())).then(|| text.parse().ok()).flatten() +} + +fn simplified(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +// --------------------------------------------------------------------------------------- +// Pure parsers +// --------------------------------------------------------------------------------------- + +/// Supported-size bitmap of a `resourceN_resize` attribute; bit `n` means `2^(n+20)` bytes. +pub fn parse_resizable_bar_sizes(text: &str) -> Attribute { + if text.is_empty() || text.len() > 16 || !text.bytes().all(|b| b.is_ascii_hexdigit()) { + return failed(libc::EINVAL); + } + let bitmap = match u64::from_str_radix(text, 16) { + Ok(0) | Err(_) => return failed(libc::EINVAL), + Ok(n) => n, + }; + // Do not shift beyond a 64-bit size. + if bitmap >> 44 != 0 { + return failed(libc::EOVERFLOW); + } + available(format!("{bitmap:x}")) +} + +/// Size in bytes of the memory aperture currently allocated to one of the six PCI BARs. +pub fn parse_resizable_bar_allocation(resources: &str, bar: usize) -> Attribute { + if bar >= 6 || resources.len() > 65536 { + return failed(libc::EINVAL); + } + let lines: Vec<&str> = resources.split('\n').collect(); + let Some(line) = lines.get(bar) else { return failed(libc::EINVAL) }; + let fields: Vec = simplified(line).split(' ').map(str::to_string).collect(); + if fields.len() != 3 { + return failed(libc::EINVAL); + } + let mut values = [0u64; 3]; + for (slot, field) in values.iter_mut().zip(&fields) { + let digits = field.strip_prefix("0x").or_else(|| field.strip_prefix("0X")).unwrap_or(field); + if digits.is_empty() || digits.len() > 16 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) { + return failed(libc::EINVAL); + } + let Ok(parsed) = u64::from_str_radix(digits, 16) else { return failed(libc::EINVAL) }; + *slot = parsed; + } + let [start, end, flags] = values; + if flags & 0x1f00 != 0x200 { + return Attribute::default(); // No reported memory resource. + } + if flags & 0x3000_0000 != 0 { + return Attribute::default(); // Unassigned or disabled resource, not a BIOS ReBAR verdict. + } + if start == 0 && end == 0 { + return Attribute::default(); // Empty/masked addresses never mean a one-byte BAR. + } + if end < start { + return failed(libc::EINVAL); + } + if end - start == u64::MAX { + return failed(libc::EOVERFLOW); + } + available((end - start + 1).to_string()) +} + +/// Kernel feature tokens per processor, grouped so identical lists share one line +/// (`0, 1avx2 sse`). Never a union: different CPUs keep different lists. +pub fn parse_cpu_instruction_sets(cpuinfo: &str) -> Attribute { + if cpuinfo.len() > 4 * 1024 * 1024 { + return failed(libc::EOVERFLOW); + } + let mut processors: BTreeMap = BTreeMap::new(); + let mut record: HashMap<&str, &str> = HashMap::new(); + let mut error: Option = None; + let commit = |record: &HashMap<&str, &str>, processors: &mut BTreeMap, error: &mut Option| { + let Some(id) = record.get("processor") else { return }; + let Some(id) = id.parse::().ok().filter(|id| !processors.contains_key(id)) else { + *error = Some(failed(libc::EINVAL)); + return; + }; + if processors.len() >= 4096 { + *error = Some(failed(libc::EOVERFLOW)); + return; + } + let features = ["flags", "Features", "isa"].iter().filter_map(|k| record.get(k)).find(|v| !v.is_empty()); + let Some(features) = features else { + error.get_or_insert_with(Attribute::default); + return; + }; + let mut tokens: Vec<&str> = features.split_whitespace().collect(); + if tokens.len() > 4096 { + *error = Some(failed(libc::EOVERFLOW)); + return; + } + tokens.sort_unstable(); + tokens.dedup(); + processors.insert(id, tokens.join(" ")); + }; + for line in cpuinfo.split('\n') { + if line.trim().is_empty() { + commit(&record, &mut processors, &mut error); + record.clear(); + continue; + } + let Some((key, value)) = line.split_once(':') else { continue }; + let key = key.trim(); + if matches!(key, "processor" | "flags" | "Features" | "isa") { + record.insert(key, value.trim()); + } + } + commit(&record, &mut processors, &mut error); + if let Some(error) = error { + return error; // Includes the "incomplete" (unavailable) marker. + } + if processors.is_empty() { + return Attribute::default(); + } + let mut groups: BTreeMap<&str, Vec> = BTreeMap::new(); + for (id, features) in &processors { + groups.entry(features.as_str()).or_default().push(id.to_string()); + } + available(groups.iter().map(|(features, ids)| format!("{}\t{features}", ids.join(", "))).collect::>().join("\n")) +} + +/// `PRETTY_NAME` of os-release without evaluating anything: quotes and the shell escapes +/// permitted inside them are decoded, `$(…)` stays literal text. Last definition wins. +pub fn parse_os_name(text: &str) -> Attribute { + let mut result = available("Linux"); // Specified default, only after a successful file read. + for line in text.split('\n') { + let Some(value) = line.strip_prefix("PRETTY_NAME=") else { continue }; + let value = value.trim(); + let chars: Vec = value.chars().collect(); + let quote = chars.first().copied().filter(|c| *c == '\'' || *c == '"'); + let body: &[char] = if let Some(q) = quote { + if chars.len() < 2 || *chars.last().expect("non-empty") != q { + return failed(libc::EINVAL); + } + &chars[1..chars.len() - 1] + } else { + &chars + }; + let mut decoded = String::new(); + let mut i = 0; + while i < body.len() { + let c = body[i]; + if quote == Some(c) { + return failed(libc::EINVAL); + } + if c == '\\' && quote != Some('\'') { + i += 1; + let Some(&next) = body.get(i) else { return failed(libc::EINVAL) }; + if quote == Some('"') && !matches!(next, '"' | '\\' | '$' | '`') { + decoded.push('\\'); + } + decoded.push(next); + } else { + decoded.push(c); + } + i += 1; + } + result = if decoded.is_empty() { Attribute::default() } else { available(decoded) }; + } + result +} + +/// Kernel CPU list syntax (`0-3,8,10-11`) into actual CPU ids; any malformed input yields none. +pub fn parse_cpu_list(text: &str) -> Option> { + let mut parsed = BTreeSet::new(); + for range in text.split(',') { + let limits: Vec<&str> = range.trim().split('-').collect(); + if limits.len() > 2 { + return None; + } + let first: i64 = limits[0].parse().ok()?; + let last: i64 = if limits.len() == 1 { first } else { limits[1].parse().ok()? }; + if first < 0 || last < first || last > 1_048_575 || last - first >= 4096 { + return None; + } + parsed.extend((first..=last).map(|cpu| cpu as u32)); + if parsed.len() > 4096 { + return None; + } + } + (!parsed.is_empty()).then_some(parsed) +} + +const MAX_MEMORY_DEVICES: u64 = 4096; +const MAX_DETAILED_MEMORY_DEVICES: u64 = 256; +const MAX_MEMORY_STRING: usize = 256; +const MAX_MEMORY_BYTES: usize = 1024 * 1024; +const MEMORY_SUFFIXES: [&str; 12] = [ + "SIZE", + "PRESENT", + "NON_VOLATILE_SIZE", + "VOLATILE_SIZE", + "LOCATOR", + "BANK_LOCATOR", + "TYPE", + "FORM_FACTOR", + "SPEED_MTS", + "CONFIGURED_SPEED_MTS", + "MANUFACTURER", + "PART_NUMBER", +]; + +/// Installed RAM in bytes from the firmware-reported DMI properties. +pub fn parse_installed_memory(properties: &HashMap) -> Attribute { + let get = |key: &str| properties.get(key).map_or("", String::as_str); + let Some(count) = properties.get("MEMORY_ARRAY_NUM_DEVICES") else { return Attribute::default() }; + let Some(count) = unsigned_decimal(count) else { return failed(libc::EINVAL) }; + if count > MAX_MEMORY_DEVICES { + return failed(libc::EOVERFLOW); + } + let mut total: u64 = 0; + for slot in 0..count { + let prefix = format!("MEMORY_DEVICE_{slot}_"); + let present = get(&format!("{prefix}PRESENT")); + let size = get(&format!("{prefix}SIZE")); + if present == "0" { + if !size.is_empty() && size != "0" { + return failed(libc::EINVAL); + } + continue; // Explicitly empty slots do not require a capacity. + } + if !present.is_empty() && present != "1" { + return failed(libc::EINVAL); + } + if size.is_empty() { + return Attribute::default(); // Unknown slot invalidates the entire total. + } + let Some(bytes) = unsigned_decimal(size) else { return failed(libc::EINVAL) }; + if bytes == 0 { + return Attribute::default(); + } + let persistent = get(&format!("{prefix}NON_VOLATILE_SIZE")); + if !persistent.is_empty() && persistent != "Unknown" { + let Some(non_volatile) = unsigned_decimal(persistent) else { return failed(libc::EINVAL) }; + // SIZE may include persistent capacity; do not call that installed RAM. + if non_volatile != 0 { + return Attribute { state: ReadState::Unsupported, ..Default::default() }; + } + } + let Some(sum) = total.checked_add(bytes) else { return failed(libc::EOVERFLOW) }; + total = sum; + } + if total == 0 { + Attribute::default() + } else { + available(total.to_string()) + } +} + +/// Per-slot firmware records, keeping populated, unknown and explicitly empty slots apart. +pub fn parse_memory_devices(properties: &HashMap) -> FirmwareMemoryInformation { + let mut result = FirmwareMemoryInformation::default(); + let Some(count) = properties.get("MEMORY_ARRAY_NUM_DEVICES") else { return result }; + let Some(count) = unsigned_decimal(count) else { + result.status = failed(libc::EINVAL); + return result; + }; + if count > MAX_DETAILED_MEMORY_DEVICES { + result.status = failed(libc::EOVERFLOW); + return result; + } + const PLACEHOLDERS: [&str; 6] = ["unknown", "not specified", "not provided", "none", "to be filled by o.e.m.", "default string"]; + let numeric = |key: &str| matches!(key, "SIZE" | "VOLATILE_SIZE" | "NON_VOLATILE_SIZE" | "SPEED_MTS" | "CONFIGURED_SPEED_MTS"); + let mut bytes = 0usize; + for index in 0..count { + let mut record = FirmwareMemoryDevice { index: index as usize, ..Default::default() }; + let prefix = format!("MEMORY_DEVICE_{index}_"); + for key in MEMORY_SUFFIXES { + let raw = properties.get(&format!("{prefix}{key}")).map_or("", String::as_str); + if raw.len() > MAX_MEMORY_STRING || bytes > MAX_MEMORY_BYTES - raw.len() { + result.devices.clear(); + result.status = failed(libc::EOVERFLOW); + return result; + } + bytes += raw.len(); + let text = raw.trim(); + let mut value = Attribute::default(); + if !text.is_empty() && !PLACEHOLDERS.contains(&text.to_lowercase().as_str()) { + value = available(text); + if key == "PRESENT" { + if text != "0" && text != "1" { + value = failed(libc::EINVAL); + } + } else if numeric(key) { + match unsigned_decimal(text) { + None => value = failed(libc::EINVAL), + Some(0) if key == "SPEED_MTS" || key == "CONFIGURED_SPEED_MTS" => value = Attribute::default(), + Some(_) => {} + } + } + } + record.values.insert(key.into(), value); + } + let present = record.values["PRESENT"].clone(); + if present.state == ReadState::Available && present.value == "0" { + for key in ["SIZE", "VOLATILE_SIZE", "NON_VOLATILE_SIZE"] { + let capacity = record.values.get_mut(key).expect("all suffixes inserted"); + let nonzero = capacity.state == ReadState::Available && capacity.value.parse::().unwrap_or(0) != 0; + if capacity.state == ReadState::Error || nonzero { + *capacity = failed(libc::EINVAL); + record.values.insert("PRESENT".into(), failed(libc::EINVAL)); + } else { + *capacity = Attribute { state: ReadState::NotApplicable, ..Default::default() }; + } + } + } else { + // A zero total capacity does not establish an empty device. + let capacity = record.values.get_mut("SIZE").expect("inserted"); + if capacity.state == ReadState::Available && capacity.value.parse::().unwrap_or(1) == 0 { + *capacity = Attribute::default(); + } + } + result.devices.push(record); + } + result.status = available(count.to_string()); + result +} + +// --------------------------------------------------------------------------------------- +// Collection +// --------------------------------------------------------------------------------------- + +fn installed_memory(result: &mut SystemProperties, cancel: Option<&AtomicBool>) { + let cancelled = || cancel.is_some_and(|c| c.load(Ordering::Relaxed)); + let fail = |result: &mut SystemProperties, status: Attribute| { + result.values.insert("physical_memory".into(), status.clone()); + result.memory_devices.status = status; + }; + let device = match udev::Device::from_subsystem_sysname("dmi".into(), "id".into()) { + Ok(d) => d, + Err(e) => { + let status = e.raw_os_error().map_or_else(Attribute::default, failed); + return fail(result, status); + } + }; + let mut properties: HashMap = HashMap::new(); + let mut copied = 0usize; + let mut copy = |key: &str, limit: usize, properties: &mut HashMap| -> bool { + let Some(raw) = device.property_value(key) else { return true }; + let text = raw.to_string_lossy(); + if text.len() > limit || copied > MAX_MEMORY_BYTES - text.len() { + return false; + } + copied += text.len(); + properties.insert(key.into(), text.into_owned()); + true + }; + if !copy("MEMORY_ARRAY_NUM_DEVICES", 64, &mut properties) { + return fail(result, failed(libc::EOVERFLOW)); + } + let Some(count) = properties.get("MEMORY_ARRAY_NUM_DEVICES") else { return }; + let Some(count) = unsigned_decimal(count) else { return fail(result, failed(libc::EINVAL)) }; + if count > MAX_MEMORY_DEVICES { + return fail(result, failed(libc::EOVERFLOW)); + } + // Capacity and metadata share one udev snapshot. Optional metadata bounds must not + // invalidate the installed-RAM calculation. + for index in 0..count { + if cancelled() { + return fail(result, failed(libc::ECANCELED)); + } + for suffix in ["SIZE", "PRESENT", "NON_VOLATILE_SIZE"] { + if !copy(&format!("MEMORY_DEVICE_{index}_{suffix}"), 64, &mut properties) { + return fail(result, failed(libc::EOVERFLOW)); + } + } + } + result.values.insert("physical_memory".into(), parse_installed_memory(&properties)); + if count > MAX_DETAILED_MEMORY_DEVICES { + result.memory_devices.status = failed(libc::EOVERFLOW); + return; + } + for index in 0..count { + if cancelled() { + result.memory_devices.status = failed(libc::ECANCELED); + return; + } + for suffix in MEMORY_SUFFIXES { + let key = format!("MEMORY_DEVICE_{index}_{suffix}"); + if !properties.contains_key(&key) && !copy(&key, MAX_MEMORY_STRING, &mut properties) { + result.memory_devices.status = failed(libc::EOVERFLOW); + return; + } + } + } + result.memory_devices = parse_memory_devices(&properties); +} + +fn gpu_resizable_bar(result: &mut SystemProperties, cancel: Option<&AtomicBool>) { + let base = "/sys/bus/pci/devices"; + if let Err(e) = std::fs::read_dir(base) { + result.gpu_rebar_status = failed(e.raw_os_error().unwrap_or(libc::EIO)); + return; + } + let devices = match bounded_directory_names(base, 4096, &[], cancel) { + Ok(names) => names, + Err(error) => { + result.gpu_rebar_status = failed(error); + return; + } + }; + let abort = |result: &mut SystemProperties, status: Attribute| { + result.gpu_rebar.clear(); + result.gpu_rebar_status = status; + }; + for name in &devices { + if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) { + return abort(result, failed(libc::ECANCELED)); + } + let path = format!("{base}/{name}"); + let Ok(before) = std::fs::metadata(&path) else { + return abort(result, failed(std::io::Error::last_os_error().raw_os_error().unwrap_or(libc::EIO))); + }; + let class = read_text(&format!("{path}/class"), 32, cancel); + let code = class.value.trim_start_matches("0x").pipe_hex(); + if class.state != ReadState::Available || code.is_none_or(|c| c > 0xff_ffff) { + return abort(result, if class.state == ReadState::Available { failed(libc::EINVAL) } else { class }); + } + if code.expect("checked") >> 16 != 0x03 { + continue; // PCI display controllers only; no GPU-id database. + } + if result.gpu_rebar.len() >= 64 { + return abort(result, failed(libc::EOVERFLOW)); + } + let mut gpu = GpuResizableBar { pci_address: name.clone(), ..Default::default() }; + let mut resources = Attribute::default(); + let mut have_capability = false; + for bar in 0..6usize { + let bitmap_path = format!("{path}/resource{bar}_resize"); + if let Err(e) = std::fs::metadata(&bitmap_path) { + if e.raw_os_error() == Some(libc::ENOENT) { + continue; + } + gpu.status = failed(e.raw_os_error().unwrap_or(libc::EIO)); + break; + } + let bitmap = read_text(&bitmap_path, 32, cancel); + have_capability = true; // An existing sysfs attribute is positive evidence, even if unreadable. + gpu.supported_sizes.insert( + bar, + match bitmap.state { + ReadState::Available => parse_resizable_bar_sizes(&bitmap.value), + ReadState::Unavailable => failed(libc::ENODATA), + _ => bitmap, + }, + ); + if resources.state == ReadState::Unavailable && resources.error == 0 { + resources = read_text(&format!("{path}/resource"), 65536, cancel); + } + gpu.current_sizes + .insert(bar, if resources.state == ReadState::Available { parse_resizable_bar_allocation(&resources.value, bar) } else { resources.clone() }); + } + if gpu.status.state == ReadState::Unavailable && gpu.status.error == 0 { + gpu.status = if have_capability { available("reported") } else { Attribute::default() }; + } + // Catch removal/replacement or allocation changes observed during collection. + use std::os::unix::fs::MetadataExt; + let mut stable = std::fs::metadata(&path).is_ok_and(|after| after.dev() == before.dev() && after.ino() == before.ino()); + if stable && have_capability { + let final_resources = read_text(&format!("{path}/resource"), 65536, cancel); + stable = resources == final_resources; + } + if stable && have_capability { + for (bar, recorded) in &gpu.supported_sizes { + let mut again = read_text(&format!("{path}/resource{bar}_resize"), 32, cancel); + if again.state == ReadState::Available { + again = parse_resizable_bar_sizes(&again.value); + } else if again.state == ReadState::Unavailable && again.error == 0 { + again = failed(libc::ENODATA); + } + if again != *recorded { + stable = false; + } + } + } + if !stable { + gpu.status = failed(libc::EAGAIN); + gpu.supported_sizes.clear(); + gpu.current_sizes.clear(); + } + result.gpu_rebar.push(gpu); + } + match bounded_directory_names(base, 4096, &[], cancel) { + Ok(after) if after == devices => {} + Ok(_) => return abort(result, failed(libc::EAGAIN)), + Err(error) => return abort(result, failed(error)), + } + result.gpu_rebar_status = available(result.gpu_rebar.len().to_string()); +} + +/// Hexadecimal text to a number (for sysfs `class`). +trait HexText { + fn pipe_hex(&self) -> Option; +} +impl HexText for str { + fn pipe_hex(&self) -> Option { + u32::from_str_radix(self.trim(), 16).ok() + } +} + +fn cpu_topology(result: &mut SystemProperties, cancel: Option<&AtomicBool>) { + let base = "/sys/devices/system/cpu"; + let present = read_text(&format!("{base}/present"), 4096, cancel); + let online = read_text(&format!("{base}/online"), 4096, cancel); + let present_cpus = (present.state == ReadState::Available).then(|| parse_cpu_list(&present.value)).flatten(); + let online_cpus = (online.state == ReadState::Available).then(|| parse_cpu_list(&online.value)).flatten(); + let count_or = |cpus: &Option>, source: &Attribute| match cpus { + Some(set) => available(set.len().to_string()), + None if source.state == ReadState::Available => failed(libc::EINVAL), + None => source.clone(), + }; + result.values.insert("logical".into(), count_or(&present_cpus, &present)); + result.values.insert("online".into(), count_or(&online_cpus, &online)); + let mut packages: HashSet = HashSet::new(); + let mut cores: HashSet = HashSet::new(); + let mut topology_error = Attribute::default(); + let mut topology_complete = present_cpus.is_some(); + if let Some(cpus) = &present_cpus { + for cpu in cpus { + let path = format!("{base}/cpu{cpu}/topology/"); + let package = read_text(&format!("{path}physical_package_id"), 4096, cancel); + let core = read_text(&format!("{path}core_id"), 4096, cancel); + let die = read_text(&format!("{path}die_id"), 4096, cancel); + let (Some(package_id), Some(core_id)) = (non_negative(&package), non_negative(&core)) else { + topology_error = if package.state != ReadState::Available { + package + } else if core.state != ReadState::Available { + core + } else { + failed(libc::ENODATA) + }; + topology_complete = false; + break; // No apparently complete count from partial topology. + }; + if matches!(die.state, ReadState::PermissionDenied | ReadState::Error) { + topology_error = die; + topology_complete = false; + break; + } + packages.insert(package_id.to_string()); + let die_text = non_negative(&die).map_or_else(|| "unknown".to_string(), |d| d.to_string()); + cores.insert(format!("{package_id}:{die_text}:{core_id}")); + } + } else { + topology_error = result.values["logical"].clone(); + } + result.values.insert("sockets".into(), if topology_complete { available(packages.len().to_string()) } else { topology_error.clone() }); + result.values.insert("cores".into(), if topology_complete { available(cores.len().to_string()) } else { topology_error }); + for key in ["logical", "online", "sockets", "cores"] { + result.sources.insert(key.into(), format!("{base}/present, online and cpu*/topology")); + } + + let Some(active) = online_cpus else { + result.values.insert("caches".into(), result.values["online"].clone()); + return; + }; + // Deduplicate by level/type and canonical sharing set, never by CPU number. + let mut seen: HashMap = HashMap::new(); + let mut totals: BTreeMap = BTreeMap::new(); + let mut entries = 0i64; + let mut cache_error = Attribute::default(); + let mut complete = true; + 'cpus: for cpu in &active { + let path = format!("{base}/cpu{cpu}/cache"); + if let Err(e) = std::fs::read_dir(&path) { + cache_error = failed(e.raw_os_error().unwrap_or(libc::EIO)); + complete = false; + break; + } + let indexes = match bounded_directory_names(&path, 16384 - entries, &["index*"], cancel) { + Ok(names) => names, + Err(error) => { + cache_error = failed(error); + complete = false; + break; + } + }; + if indexes.is_empty() { + complete = false; + break; + } + for index in indexes { + entries += 1; + let prefix = format!("{path}/{index}/"); + let level = read_text(&format!("{prefix}level"), 4096, cancel); + let kind = read_text(&format!("{prefix}type"), 4096, cancel); + let size = read_text(&format!("{prefix}size"), 4096, cancel); + let sharing = read_text(&format!("{prefix}shared_cpu_list"), 4096, cancel); + if let Some(bad) = [&level, &kind, &size, &sharing].into_iter().find(|v| v.state != ReadState::Available) { + cache_error = bad.clone(); + complete = false; + break 'cpus; + } + let (mut size_text, mut scale) = (size.value.as_str(), 1u64); + if let Some(rest) = size_text.strip_suffix('K') { + (size_text, scale) = (rest, 1024); + } else if let Some(rest) = size_text.strip_suffix('M') { + (size_text, scale) = (rest, 1024 * 1024); + } + let amount = size_text.parse::().ok(); + let level_number = non_negative(&level).filter(|l| *l != 0); + let siblings = parse_cpu_list(&sharing.value); + let valid = level_number.is_some() + && amount.is_some_and(|a| a != 0 && a <= u64::MAX / scale) + && siblings.as_ref().is_some_and(|s| s.contains(cpu)) + && matches!(kind.value.as_str(), "Data" | "Instruction" | "Unified"); + if !valid { + cache_error = failed(libc::EINVAL); + complete = false; + break 'cpus; + } + let ids: Vec = siblings.expect("valid").iter().map(u32::to_string).collect(); + let label = format!("L{} {}", level_number.expect("valid"), kind.value); + let key = format!("{label}:{}", ids.join(",")); + let bytes = amount.expect("valid") * scale; + if let Some(previous) = seen.get(&key) { + if *previous != bytes { + cache_error = failed(libc::EAGAIN); + complete = false; + break 'cpus; + } + } else { + seen.insert(key, bytes); + let total = totals.entry(label).or_default(); + if bytes > i64::MAX as u64 || total.0 > i64::MAX as u64 - bytes { + cache_error = failed(libc::EOVERFLOW); + complete = false; + break 'cpus; + } + total.0 += bytes; + total.1 += 1; + } + } + } + let lines: Vec = totals.iter().map(|(kind, (bytes, count))| format!("{kind}\t{bytes}\t{count}")).collect(); + result.values.insert("caches".into(), if complete && !lines.is_empty() { available(lines.join("\n")) } else { cache_error }); + result.sources.insert("caches".into(), format!("{base}/cpu*/cache/index*/{{level,type,size,shared_cpu_list}}; online CPUs only")); + // Detect observed hotplug during collection rather than publish mixed counts. + if read_text(&format!("{base}/present"), 4096, cancel).value != present.value || read_text(&format!("{base}/online"), 4096, cancel).value != online.value { + for key in ["logical", "online", "sockets", "cores", "caches"] { + result.values.insert(key.into(), failed(libc::EAGAIN)); + } + } +} + +/// The machine's host name, or empty if unavailable. +pub fn machine_hostname() -> String { + hostname().value +} + +fn hostname() -> Attribute { + let mut buffer = [0u8; 256]; + // SAFETY: the buffer is valid for its full length and gethostname NUL-terminates within it. + let status = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len() - 1) }; + if status != 0 { + return dir_failure(std::io::Error::last_os_error().raw_os_error().unwrap_or(libc::EIO)); + } + let length = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len()); + let name = String::from_utf8_lossy(&buffer[..length]).into_owned(); + if name.is_empty() { + Attribute::default() + } else { + available(name) + } +} + +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +fn cpuid_hypervisor() -> Option { + #[cfg(target_arch = "x86")] + use std::arch::x86::{__cpuid, __get_cpuid_max}; + #[cfg(target_arch = "x86_64")] + use std::arch::x86_64::{__cpuid, __get_cpuid_max}; + // CPUID is available on every x86 CPU this program can run on; the max-leaf query checks + // that leaf 1 exists first, and the instructions only read processor identification. + let (max, _) = __get_cpuid_max(0); + if max < 1 || __cpuid(1).ecx & (1 << 31) == 0 { + return None; + } + let leaf = __cpuid(0x4000_0000); + if leaf.eax < 0x4000_0000 || leaf.eax >= 0x4001_0000 { + return None; + } + let mut vendor = Vec::new(); + for register in [leaf.ebx, leaf.ecx, leaf.edx] { + vendor.extend_from_slice(®ister.to_le_bytes()); + } + let name: String = String::from_utf8_lossy(&vendor).chars().filter(|c| *c != '\0').collect(); + Some(name.trim().to_string()) +} + +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +fn cpuid_hypervisor() -> Option { + None +} + +pub fn collect_system_properties(cancel: Option<&AtomicBool>) -> SystemProperties { + let mut result = SystemProperties::default(); + macro_rules! put { + ($key:expr, $value:expr, $source:expr) => {{ + result.values.insert($key.to_string(), $value); + result.sources.insert($key.to_string(), $source.to_string()); + }}; + } + put!("hostname", hostname(), "gethostname"); + let mut os_path = "/etc/os-release"; + let mut os = read_text(os_path, 65536, cancel); + if os.error == libc::ENOENT { + os_path = "/usr/lib/os-release"; + os = read_text(os_path, 65536, cancel); + } + put!("os", if os.state == ReadState::Available { parse_os_name(&os.value) } else { os }, format!("{os_path}: PRETTY_NAME")); + // SAFETY: utsname is plain data filled by uname. + let kernel = unsafe { + let mut name: libc::utsname = std::mem::zeroed(); + (libc::uname(&mut name) == 0).then_some(name) + }; + let text = |field: &[libc::c_char]| { + // SAFETY: utsname fields are NUL-terminated C strings. + unsafe { std::ffi::CStr::from_ptr(field.as_ptr()) }.to_string_lossy().into_owned() + }; + match kernel { + Some(name) => { + put!("architecture", available(text(&name.machine)), "uname.machine (kernel architecture)"); + put!("kernel", available(format!("{} {}", text(&name.sysname), text(&name.release))), "uname.sysname, release"); + put!("build", available(text(&name.version)), "uname.version"); + } + None => { + let error = dir_failure(std::io::Error::last_os_error().raw_os_error().unwrap_or(libc::EIO)); + for key in ["architecture", "kernel", "build"] { + put!(key, error.clone(), "uname"); + } + } + } + let cpu = read_cpuinfo(); + let mut models: Vec = parse_cpu_models(&cpu.value).into_values().collect(); + models.sort_by_key(|m| m.to_lowercase()); + models.dedup(); + put!( + "cpu", + if cpu.state == ReadState::Available { + if models.is_empty() { + Attribute::default() + } else { + available(models.join("\n")) + } + } else { + cpu.clone() + }, + "/proc/cpuinfo: per-processor model fields" + ); + put!( + "instruction_sets", + if cpu.state == ReadState::Available { parse_cpu_instruction_sets(&cpu.value) } else { cpu.clone() }, + "/proc/cpuinfo: per-processor flags, Features or isa; kernel-exposed instruction sets and CPU features" + ); + gpu_resizable_bar(&mut result, cancel); + result.sources.insert( + "gpu_rebar".into(), + "/sys/bus/pci/devices: display-controller class, resourceN_resize supported-size bitmaps, resource allocation metadata; BIOS setting is not exposed" + .into(), + ); + cpu_topology(&mut result, cancel); + installed_memory(&mut result, cancel); + result + .sources + .insert("physical_memory".into(), "libudev: dmi/id MEMORY_ARRAY_NUM_DEVICES and MEMORY_DEVICE_n_SIZE/PRESENT; firmware-reported installed RAM".into()); + result.sources.insert("memory_devices".into(), "libudev: dmi/id cached MEMORY_ARRAY_NUM_DEVICES and MEMORY_DEVICE_n_* properties".into()); + let memory = read_text("/proc/meminfo", 65536, cancel); + let mut total = if memory.state == ReadState::Available { Attribute::default() } else { memory.clone() }; + if let Some(line) = memory.value.split('\n').find(|l| l.starts_with("MemTotal:")) { + let parts: Vec<&str> = line["MemTotal:".len()..].split_whitespace().collect(); + total = match parts.as_slice() { + [kib, "kB"] => match kib.parse::().ok().filter(|k| *k != 0 && *k <= u64::MAX / 1024) { + Some(kib) => available((kib * 1024).to_string()), + None => failed(libc::EINVAL), + }, + _ => failed(libc::EINVAL), + }; + } + put!("memory", total, "/proc/meminfo: MemTotal; usable physical RAM, not installed DIMM capacity"); + let uptime = read_text("/proc/uptime", 4096, cancel); + let seconds = uptime.value.split(' ').next().and_then(|s| s.parse::().ok()); + put!( + "uptime", + if uptime.state != ReadState::Available { + uptime.clone() + } else { + match seconds.filter(|s| s.is_finite() && *s >= 0.0 && *s < 1e12) { + Some(s) => available((s as u64).to_string()), + None => failed(libc::EINVAL), + } + }, + "/proc/uptime" + ); + for (key, file) in [ + ("manufacturer", "sys_vendor"), + ("model", "product_name"), + ("board_vendor", "board_vendor"), + ("board_model", "board_name"), + ("firmware", "bios_version"), + ("firmware_date", "bios_date"), + ] { + let path = format!("/sys/class/dmi/id/{file}"); + put!(key, read_text(&path, 4096, cancel), path); + } + let boot = match std::fs::metadata("/sys/firmware/efi") { + Ok(m) if m.is_dir() => available("uefi"), + Ok(_) => failed(libc::ENOTDIR), + Err(e) if e.raw_os_error() == Some(libc::ENOENT) => available("not-exposed"), + Err(e) => failed(e.raw_os_error().unwrap_or(libc::EIO)), + }; + put!("boot", boot, "/sys/firmware/efi directory presence"); + let secure_boot = collect_secure_boot_information(); + put!("secure_boot", secure_boot.state, secure_boot.state_source); + result.mok = secure_boot.mok; + result.sources.insert("mok".into(), secure_boot.mok_source); + let mut hypervisor = read_text("/sys/hypervisor/type", 4096, cancel); + let mut hypervisor_source = "/sys/hypervisor/type".to_string(); + if hypervisor.state != ReadState::Available { + if let Some(name) = cpuid_hypervisor() { + hypervisor = if name.is_empty() { Attribute::default() } else { available(name) }; + hypervisor_source = "CPUID hypervisor-present bit and vendor leaf 0x40000000".into(); + } + } + put!("virtualization", hypervisor, hypervisor_source); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn map(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn os_names_decode_quotes_without_evaluating_anything() { + assert_eq!(parse_os_name("PRETTY_NAME=\"Example Linux 2\"\n").value, "Example Linux 2"); + assert_eq!(parse_os_name("PRETTY_NAME='Example $literal'\n").value, "Example $literal"); + assert_eq!(parse_os_name("PRETTY_NAME=\"Escaped \\\"quote\\\" and \\$dollar\"\n").value, "Escaped \"quote\" and $dollar"); + assert_eq!(parse_os_name("PRETTY_NAME=\"$(must-not-run)\"\n").value, "$(must-not-run)"); + assert_eq!(parse_os_name("PRETTY_NAME=\"unterminated\n").state, ReadState::Error); + assert_eq!(parse_os_name("NAME=Example\n").value, "Linux"); + assert_eq!(parse_os_name("PRETTY_NAME=First\nPRETTY_NAME=Second\n").value, "Second"); + assert_eq!(parse_os_name("PRETTY_NAME=\"a\\x\"\n").value, "a\\x", "unlisted escapes keep the backslash in double quotes"); + } + + #[test] + fn cpu_lists_keep_real_ids_and_reject_bad_input_whole() { + let cpus = parse_cpu_list("0-3,8,10-11").unwrap(); + assert!(cpus.len() == 7 && cpus.contains(&11) && !cpus.contains(&9)); + assert!(parse_cpu_list("3-1").is_none()); + assert!(parse_cpu_list("0-500000").is_none(), "expansion is bounded"); + for bad in ["0,", "-1", "word", "", "1-2-3"] { + assert!(parse_cpu_list(bad).is_none(), "{bad}"); + } + } + + #[test] + fn instruction_sets_group_identical_processors_and_never_union() { + assert_eq!(parse_cpu_instruction_sets("processor : 0\nflags : sse avx2 sse\n\nprocessor : 1\nflags : avx2 sse\n").value, "0, 1\tavx2 sse"); + let mixed = parse_cpu_instruction_sets("processor : 0\nflags : sse avx2\n\nprocessor : 1\nflags : sse\n"); + assert!(mixed.value.contains("0\tavx2 sse") && mixed.value.contains("1\tsse")); + assert_eq!(parse_cpu_instruction_sets("processor : 0\nFeatures : aes asimd\n").value, "0\taes asimd"); + assert_eq!(parse_cpu_instruction_sets("processor : 0\nisa : rv64imafdc\n").value, "0\trv64imafdc"); + assert_eq!(parse_cpu_instruction_sets("processor : 0\nflags : sse\n\nprocessor : 1\nmodel name : Example\n").state, ReadState::Unavailable); + assert_eq!(parse_cpu_instruction_sets("processor : 0\nflags : sse\n\nprocessor : 0\nflags : avx\n").error, libc::EINVAL); + assert_eq!(parse_cpu_instruction_sets("Hardware : Board\nFeatures : aes\n").state, ReadState::Unavailable); + assert_eq!(parse_cpu_instruction_sets(&"x".repeat(4 * 1024 * 1024 + 1)).error, libc::EOVERFLOW); + } + + #[test] + fn resizable_bar_bitmaps_and_allocations() { + assert_eq!(parse_resizable_bar_sizes("00000000000001c0").value, "1c0"); + assert_eq!(parse_resizable_bar_sizes("100").value, "100"); + assert_eq!(parse_resizable_bar_sizes("0").error, libc::EINVAL); + assert_eq!(parse_resizable_bar_sizes("wrong").error, libc::EINVAL); + assert_eq!(parse_resizable_bar_sizes("100000000000").error, libc::EOVERFLOW); + assert_eq!(parse_resizable_bar_allocation("0000009800000000 000000980fffffff 000000000014220c", 0).value, "268435456"); + for unavailable in ["0 0 200", "0 ffffffff 20000200", "100 1ff 100"] { + assert_eq!(parse_resizable_bar_allocation(unavailable, 0).state, ReadState::Unavailable, "{unavailable}"); + } + assert_eq!(parse_resizable_bar_allocation("200 100 200", 0).error, libc::EINVAL); + assert_eq!(parse_resizable_bar_allocation("garbage", 0).error, libc::EINVAL); + assert_eq!(parse_resizable_bar_allocation("0 ffffffffffffffff 200", 0).error, libc::EOVERFLOW); + assert_eq!(parse_resizable_bar_allocation("0 0 0", 6).error, libc::EINVAL); + } + + #[test] + fn installed_memory_requires_every_slot_to_be_known() { + let mut memory = map(&[("MEMORY_ARRAY_NUM_DEVICES", "2"), ("MEMORY_DEVICE_0_SIZE", "17179869184"), ("MEMORY_DEVICE_1_SIZE", "17179869184")]); + assert_eq!(parse_installed_memory(&memory).value, "34359738368"); + memory.insert("MEMORY_ARRAY_NUM_DEVICES".into(), "3".into()); + memory.insert("MEMORY_DEVICE_2_PRESENT".into(), "0".into()); + assert_eq!(parse_installed_memory(&memory).value, "34359738368", "an explicitly empty slot is fine"); + memory.remove("MEMORY_DEVICE_2_PRESENT"); + assert_eq!(parse_installed_memory(&memory).state, ReadState::Unavailable, "unknown slot, no partial total"); + memory.insert("MEMORY_DEVICE_2_SIZE".into(), "garbage".into()); + assert_eq!(parse_installed_memory(&memory).state, ReadState::Error); + memory.insert("MEMORY_DEVICE_2_SIZE".into(), "18446744073709551615".into()); + assert_eq!(parse_installed_memory(&memory).error, libc::EOVERFLOW); + memory.insert("MEMORY_DEVICE_2_PRESENT".into(), "0".into()); + assert_eq!(parse_installed_memory(&memory).state, ReadState::Error, "a size on an empty slot conflicts"); + memory.remove("MEMORY_DEVICE_2_SIZE"); + memory.insert("MEMORY_DEVICE_0_NON_VOLATILE_SIZE".into(), "0".into()); + memory.insert("MEMORY_DEVICE_1_NON_VOLATILE_SIZE".into(), "Unknown".into()); + assert_eq!(parse_installed_memory(&memory).value, "34359738368"); + memory.insert("MEMORY_DEVICE_0_NON_VOLATILE_SIZE".into(), "17179869184".into()); + assert_eq!(parse_installed_memory(&memory).state, ReadState::Unsupported, "persistent memory is not RAM"); + memory.insert("MEMORY_ARRAY_NUM_DEVICES".into(), "4097".into()); + assert_eq!(parse_installed_memory(&memory).error, libc::EOVERFLOW); + assert_eq!(parse_installed_memory(&HashMap::new()).state, ReadState::Unavailable, "missing firmware data is not zero RAM"); + } + + #[test] + fn firmware_memory_records_stay_separate_and_explicit() { + let mut records = map(&[ + ("MEMORY_ARRAY_NUM_DEVICES", "3"), + ("MEMORY_DEVICE_0_SIZE", "17179869184"), + ("MEMORY_DEVICE_0_LOCATOR", "Same locator"), + ("MEMORY_DEVICE_1_LOCATOR", "Same locator"), + ("MEMORY_DEVICE_0_TYPE", "LPDDR5"), + ("MEMORY_DEVICE_0_SPEED_MTS", "8533"), + ("MEMORY_DEVICE_0_CONFIGURED_SPEED_MTS", "0"), + ("MEMORY_DEVICE_0_PART_NUMBER", "Unknown"), + ("MEMORY_DEVICE_1_SIZE", "Unknown"), + ("MEMORY_DEVICE_2_PRESENT", "0"), + ]); + let details = parse_memory_devices(&records); + assert!(details.status.state == ReadState::Available && details.devices.len() == 3); + let value = |d: &FirmwareMemoryInformation, i: usize, k: &str| d.devices[i].values[k].clone(); + assert_eq!(value(&details, 0, "PRESENT").state, ReadState::Unavailable); + assert_eq!(value(&details, 0, "SIZE").value, "17179869184", "a missing presence flag keeps the known capacity"); + assert_eq!(value(&details, 0, "LOCATOR").value, value(&details, 1, "LOCATOR").value, "duplicate locators do not merge"); + assert_eq!(value(&details, 1, "SIZE").state, ReadState::Unavailable); + assert_eq!(value(&details, 2, "SIZE").state, ReadState::NotApplicable, "unknown differs from explicitly empty"); + assert_eq!(value(&details, 0, "SPEED_MTS").value, "8533"); + assert_eq!(value(&details, 0, "CONFIGURED_SPEED_MTS").state, ReadState::Unavailable, "zero rate"); + assert_eq!(value(&details, 0, "PART_NUMBER").state, ReadState::Unavailable, "firmware placeholder"); + + records.insert("MEMORY_DEVICE_0_NON_VOLATILE_SIZE".into(), "1073741824".into()); + records.insert("MEMORY_DEVICE_0_VOLATILE_SIZE".into(), "16106127360".into()); + records.insert("MEMORY_DEVICE_1_SPEED_MTS".into(), "bad".into()); + records.insert("MEMORY_DEVICE_2_SIZE".into(), "1024".into()); + let details = parse_memory_devices(&records); + assert_eq!(value(&details, 0, "NON_VOLATILE_SIZE").value, "1073741824"); + assert_eq!(value(&details, 0, "VOLATILE_SIZE").value, "16106127360"); + assert_eq!(value(&details, 1, "SPEED_MTS").error, libc::EINVAL); + assert_eq!(value(&details, 2, "SIZE").error, libc::EINVAL); + assert_eq!(value(&details, 2, "PRESENT").error, libc::EINVAL, "contradictory empty record"); + + records.insert("MEMORY_DEVICE_0_MANUFACTURER".into(), "x".repeat(257)); + let oversized = parse_memory_devices(&records); + assert!(oversized.status.error == libc::EOVERFLOW && oversized.devices.is_empty(), "no partial list"); + records.remove("MEMORY_DEVICE_0_MANUFACTURER"); + records.insert("MEMORY_ARRAY_NUM_DEVICES".into(), "257".into()); + assert_eq!(parse_memory_devices(&records).status.error, libc::EOVERFLOW); + assert_eq!(parse_memory_devices(&HashMap::new()).status.state, ReadState::Unavailable, "missing cache is not a permission failure"); + let zero = parse_memory_devices(&map(&[("MEMORY_ARRAY_NUM_DEVICES", "0")])); + assert!(zero.status.state == ReadState::Available && zero.devices.is_empty()); + } + + #[test] + fn this_machines_system_information_is_complete_and_explicit() { + let found = collect_system_properties(None); + let value = |k: &str| found.values[k].clone(); + assert_eq!(value("kernel").state, ReadState::Available); + assert!(value("kernel").value.starts_with("Linux ")); + assert_eq!(value("os").state, ReadState::Available); + assert!(!value("os").value.is_empty()); + assert!(value("memory").value.parse::().unwrap() > 0); + assert!(value("uptime").value.parse::().unwrap() > 0); + assert!(value("logical").value.parse::().unwrap() >= 1); + assert!(matches!(value("boot").value.as_str(), "uefi" | "not-exposed")); + assert!(found.values["cpu"].state == ReadState::Available && found.values["instruction_sets"].value.contains('\t')); + assert!(found.sources["memory"].contains("MemTotal")); + assert!(matches!(found.gpu_rebar_status.state, ReadState::Available | ReadState::Unavailable | ReadState::Error | ReadState::PermissionDenied)); + if found.values["caches"].state == ReadState::Available { + assert!(found.values["caches"].value.lines().all(|l| l.split('\t').count() == 3)); + } + let cancelled = AtomicBool::new(true); + assert_eq!(collect_system_properties(Some(&cancelled)).values["os"].error, libc::ECANCELED); + } +} diff --git a/crates/qldm-core/src/system_report.rs b/crates/qldm-core/src/system_report.rs new file mode 100644 index 0000000..d1b9503 --- /dev/null +++ b/crates/qldm-core/src/system_report.rs @@ -0,0 +1,582 @@ +//! Text of the System Information window, ported from `src/ui/systemdialog.cpp` without toolkit +//! types: which fields each tab has, how every collected value is worded, the per-field source +//! tooltips, the MOK certificate blocks, the firmware memory records and the Copy text. +use crate::i18n::{arg, arg2, argn, format_data_size_with, format_fixed, format_integer, tr}; +use crate::model::{Attribute, ReadState}; +use crate::propertytext::property_read_value; +use crate::system::{FirmwareMemoryDevice, SystemProperties}; + +pub struct SystemField { + pub key: &'static str, + pub caption: String, +} + +pub struct SystemSection { + pub title: String, + pub fields: Vec, +} + +fn field(key: &'static str, caption: &str) -> SystemField { + SystemField { key, caption: tr(caption) } +} + +/// Tabs and their fields, in display order. Hardware also carries the MOK certificate block and +/// RAM/Memory the firmware memory records; both are variable-length and built separately. +pub fn sections() -> Vec { + vec![ + SystemSection { + title: tr("Operating System"), + fields: vec![ + field("hostname", "Hostname"), + field("os", "Linux distribution"), + field("architecture", "Kernel architecture"), + field("kernel", "Kernel version"), + field("build", "Kernel build"), + field("boot", "Boot mode"), + field("secure_boot", "Secure Boot"), + field("virtualization", "Virtualization environment"), + field("uptime", "Uptime"), + field("gpu_rebar", "Resizable BAR (GPU)"), + ], + }, + SystemSection { + title: tr("Hardware"), + fields: vec![ + field("manufacturer", "System manufacturer"), + field("model", "System model"), + field("board_vendor", "Motherboard manufacturer"), + field("board_model", "Motherboard model"), + field("firmware", "Firmware version"), + field("firmware_date", "Firmware date (reported)"), + ], + }, + SystemSection { + title: tr("Processor"), + fields: vec![ + field("cpu", "CPU model(s)"), + field("sockets", "Sockets (reported topology)"), + field("cores", "Physical cores (reported topology)"), + field("logical", "Logical CPUs present"), + field("online", "Logical CPUs online"), + field("caches", "CPU caches (online CPUs)"), + field("instruction_sets", "Instruction sets / CPU features"), + ], + }, + SystemSection { title: tr("RAM/Memory"), fields: vec![field("physical_memory", "Physical RAM"), field("memory", "Usable RAM")] }, + ] +} + +const MEMORY_FIELDS: [(&str, &str); 12] = [ + ("PRESENT", "Presence"), + ("SIZE", "Capacity (total)"), + ("VOLATILE_SIZE", "Volatile capacity"), + ("NON_VOLATILE_SIZE", "Nonvolatile capacity"), + ("LOCATOR", "Device locator"), + ("BANK_LOCATOR", "Bank locator"), + ("TYPE", "Memory type"), + ("FORM_FACTOR", "Form factor"), + ("SPEED_MTS", "Rated transfer rate"), + ("CONFIGURED_SPEED_MTS", "Configured transfer rate"), + ("MANUFACTURER", "Manufacturer"), + ("PART_NUMBER", "Part number"), +]; + +pub fn memory_scope() -> String { + tr("Firmware-reported records; these do not establish removable modules, physical slots, memory channels, upgradeability or active ECC. Transfer rates are firmware declarations, not live measurements.") +} + +fn bytes_text(bytes: u64, precision: usize) -> String { + if bytes <= i64::MAX as u64 { + format_data_size_with(bytes, precision) + } else { + arg(tr("%1 bytes"), &format_integer(bytes)) + } +} + +pub fn memory_value(key: &str, value: &Attribute) -> String { + if key == "PRESENT" && value.state == ReadState::Unavailable { + return tr("Unknown (no explicit presence flag)"); + } + if value.state != ReadState::Available { + return property_read_value(value); + } + match key { + "PRESENT" => { + if value.value == "0" { + tr("No device reported present") + } else { + tr("Device reported present") + } + } + "SPEED_MTS" | "CONFIGURED_SPEED_MTS" => arg(tr("%1 MT/s"), &format_integer(value.value.parse().unwrap_or(0))), + "SIZE" | "VOLATILE_SIZE" | "NON_VOLATILE_SIZE" => bytes_text(value.value.parse().unwrap_or(0), 2), + _ => value.value.clone(), + } +} + +fn memory_lines(device: &FirmwareMemoryDevice) -> Vec { + MEMORY_FIELDS + .iter() + .map(|(key, caption)| format!("{}: {}", tr(caption), memory_value(key, &device.values.get(*key).cloned().unwrap_or_default()))) + .collect() +} + +fn iec(bytes: &Attribute) -> String { + if bytes.state != ReadState::Available { + return property_read_value(bytes); + } + bytes_text(bytes.value.parse().unwrap_or(0), 0) +} + +fn rebar_text(snapshot: &SystemProperties) -> String { + if snapshot.gpu_rebar_status.state != ReadState::Available { + return property_read_value(&snapshot.gpu_rebar_status); + } + if snapshot.gpu_rebar.is_empty() { + return tr("No PCI display controller reported"); + } + let mut devices = Vec::new(); + for gpu in &snapshot.gpu_rebar { + let mut lines = vec![arg(tr("GPU [%1]"), &gpu.pci_address)]; + if gpu.status.state == ReadState::Unavailable { + lines.push(tr("Capability: Not exposed by kernel")); + } else if gpu.status.state != ReadState::Available { + lines.push(property_read_value(&gpu.status)); + } else { + lines.push(tr("Capability: Supported")); + for (bar, bitmap) in &gpu.supported_sizes { + let supported = if bitmap.state == ReadState::Available { + let bits = u64::from_str_radix(&bitmap.value, 16).unwrap_or(0); + (0..44).filter(|bit| bits & (1u64 << bit) != 0).map(|bit| bytes_text(1u64 << (bit + 20), 0)).collect::>().join(", ") + } else { + property_read_value(bitmap) + }; + let current = iec(&gpu.current_sizes.get(bar).cloned().unwrap_or_default()); + lines.push(argn(tr("BAR %1 — Current allocation: %2; supported sizes: %3"), &[&bar.to_string(), ¤t, &supported])); + } + } + lines.push(tr("BIOS setting: Unknown")); + devices.push(lines.join("\n")); + } + devices.join("\n\n") +} + +fn known(text: &str) -> String { + if text.is_empty() { + tr("Unavailable") + } else { + text.to_string() + } +} + +/// Wording of one field's value (also used for the Copy text). +pub fn display_value(snapshot: &SystemProperties, key: &str, value: &Attribute) -> String { + if key == "virtualization" && value.state == ReadState::Unavailable { + return tr("Unknown (no hypervisor identity reported)"); + } + if key == "memory_devices" { + let mut lines = vec![memory_scope()]; + let memory = &snapshot.memory_devices; + if memory.status.state != ReadState::Available { + lines.push(property_read_value(&memory.status)); + } else if memory.devices.is_empty() { + lines.push(tr("No memory-device records reported")); + } else { + for device in &memory.devices { + lines.push(arg2(tr("Memory device %1 (firmware record %2)"), &(device.index + 1).to_string(), &device.index.to_string())); + lines.extend(memory_lines(device)); + } + } + return lines.join("\n"); + } + if key == "gpu_rebar" { + return rebar_text(snapshot); + } + if key == "mok" { + let mok = &snapshot.mok; + if mok.status.state != ReadState::Available { + return property_read_value(&mok.status); + } + let mut certificates: Vec = mok + .certificates + .iter() + .map(|c| argn(tr("Owner: %1\nIssuer: %2\nExpires: %3"), &[&known(&c.subject), &known(&c.issuer), &known(&c.expires)])) + .collect(); + if certificates.is_empty() { + certificates.push(tr("No X.509 certificates in the exposed MOK list")); + } + if mok.other_signatures > 0 { + certificates.push(arg(tr("%1 non-certificate signatures omitted"), &mok.other_signatures.to_string())); + } + return certificates.join("\n\n"); + } + if value.state != ReadState::Available { + return property_read_value(value); + } + match key { + "secure_boot" => { + if value.value == "enabled" { + tr("Enabled") + } else { + tr("Disabled") + } + } + "boot" => { + if value.value == "uefi" { + tr("UEFI") + } else { + tr("UEFI not exposed (legacy boot or restricted environment)") + } + } + "virtualization" => arg(tr("Reported hypervisor: %1"), &value.value), + "memory" | "physical_memory" => { + const GIB: u64 = 1024 * 1024 * 1024; + const MIB: u64 = 1024 * 1024; + let bytes: u64 = value.value.parse().unwrap_or(0); + let precision = if key == "physical_memory" && bytes.is_multiple_of(GIB) { 0 } else { 2 }; + let mut text = arg(tr("%1 GiB"), &format_fixed(bytes as f64 / GIB as f64, precision)); + let physical = snapshot.values.get("physical_memory").cloned().unwrap_or_default(); + if key == "memory" && physical.state == ReadState::Available { + let installed: u64 = physical.value.parse().unwrap_or(0); + if installed >= bytes { + text += &arg(tr(" (%1 MiB is system reserved)"), &format_fixed((installed - bytes) as f64 / MIB as f64, 0)); + } + } + text + } + "uptime" => { + let seconds: u64 = value.value.parse().unwrap_or(0); + argn(tr("%1 days, %2 hours, %3 minutes"), &[&(seconds / 86400).to_string(), &(seconds / 3600 % 24).to_string(), &(seconds / 60 % 60).to_string()]) + } + "instruction_sets" => { + let groups: Vec<&str> = value.value.split('\n').collect(); + groups + .iter() + .map(|group| { + let mut parts = group.split('\t'); + let (cpus, features) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); + if groups.len() == 1 { + features.to_string() + } else { + arg2(tr("Logical CPUs %1: %2"), cpus, features) + } + }) + .collect::>() + .join("\n\n") + } + "caches" => value + .value + .split('\n') + .map(|raw| { + let parts: Vec<&str> = raw.split('\t').collect(); + let kind = parts.first().copied().unwrap_or(""); + let (level, ty) = kind.split_once(' ').unwrap_or((kind, "")); + let ty = match ty { + "Data" => tr("data"), + "Instruction" => tr("instruction"), + "Unified" => tr("unified"), + other => other.to_string(), + }; + let size = format_data_size_with(parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0), 2); + argn(tr("%1 %2: %3 (%4 unique instances)"), &[level, &ty, &size, parts.get(2).copied().unwrap_or("")]) + }) + .collect::>() + .join("\n"), + _ => value.value.clone(), + } +} + +/// Tooltip text for a field: where it came from, plus what it does not establish. +pub fn field_source(snapshot: &SystemProperties, key: &str) -> String { + let mut source = snapshot.sources.get(key).cloned().unwrap_or_default(); + let extra = match key { + "secure_boot" => Some(tr("Firmware SecureBoot state; does not establish shim validation or kernel lockdown policy.")), + "gpu_rebar" => Some(tr("GPU capability and current OS resource allocation are separate from the firmware setting. A small BAR is not proof of disabled ReBAR; a large BAR is not proof of driver use. No portable BIOS Enabled/Disabled value is inferred. Only sysfs metadata is read; BAR memory is never accessed.")), + "instruction_sets" => Some(tr("Kernel-exposed instruction-set and CPU-feature flags for processors listed in /proc/cpuinfo. These include non-instruction features and may be limited by the kernel, operating system or hypervisor. Different reported feature lists remain separate; this is not a complete raw hardware capability inventory.")), + "memory" => Some(tr("System reserved is reported physical RAM minus Linux MemTotal. It includes all memory unavailable to Linux, not just firmware reservations. It is omitted if physical capacity is unavailable or smaller than usable RAM.")), + _ => None, + }; + if let Some(extra) = extra { + source.push('\n'); + source.push_str(&extra); + } + source +} + +/// One block of the firmware memory section. +pub enum MemoryBlock { + Message { text: String, tooltip: String }, + Heading(String), + Field { caption: String, value: String, tooltip: String }, +} + +pub fn memory_blocks(snapshot: &SystemProperties) -> Vec { + let memory = &snapshot.memory_devices; + if memory.status.state != ReadState::Available { + return vec![MemoryBlock::Message { + text: property_read_value(&memory.status), + tooltip: snapshot.sources.get("memory_devices").cloned().unwrap_or_default(), + }]; + } + let mut blocks = Vec::new(); + if memory.devices.is_empty() { + blocks.push(MemoryBlock::Message { text: tr("No memory-device records reported"), tooltip: String::new() }); + } + for device in &memory.devices { + blocks.push(MemoryBlock::Heading(arg(tr("Memory device %1"), &(device.index + 1).to_string()))); + for (key, caption) in MEMORY_FIELDS { + blocks.push(MemoryBlock::Field { + caption: tr(caption), + value: memory_value(key, &device.values.get(key).cloned().unwrap_or_default()), + tooltip: format!("libudev: dmi/id MEMORY_DEVICE_{}_{key}\n{}", device.index, memory_scope()), + }); + } + } + blocks +} + +pub fn mok_scope(snapshot: &SystemProperties) -> String { + format!( + "{}\n{}", + snapshot.sources.get("mok").cloned().unwrap_or_default(), + tr("Exposed runtime MOK certificates. Issuer and expiration are certificate metadata, not verification of the signer or proof that this key signed the running kernel. Dates are UTC. Firmware db certificates and pending enrollments are not included.") + ) +} + +/// One block of the MOK section. +pub enum MokBlock { + Message(String), + Certificate { title: String, fields: Vec<(String, String, String)> }, +} + +const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/// `15 Oct 2030, 00:00:00 UTC` from an ISO UTC time, or the raw text when it is not one. +pub fn certificate_date(iso: &str) -> String { + let parts = (|| { + let (date, time) = iso.strip_suffix('Z')?.split_once('T')?; + let mut d = date.split('-'); + let (year, month, day) = (d.next()?, d.next()?.parse::().ok()?, d.next()?.parse::().ok()?); + MONTHS.get(month.checked_sub(1)?).map(|m| format!("{day} {m} {year}, {time}")) + })(); + match parts { + Some(text) => arg(tr("%1 UTC"), &text), + None => known(iso), + } +} + +pub fn mok_blocks(snapshot: &SystemProperties) -> Vec { + let mok = &snapshot.mok; + if mok.status.state != ReadState::Available || mok.certificates.is_empty() { + return vec![MokBlock::Message(display_value(snapshot, "mok", &Attribute::default()))]; + } + let mut blocks = Vec::new(); + for (index, certificate) in mok.certificates.iter().enumerate() { + let title = if mok.certificates.len() == 1 { tr("MOK Certificate") } else { arg(tr("MOK Certificate %1"), &(index + 1).to_string()) }; + let owner = if certificate.subject_display.is_empty() { &certificate.subject } else { &certificate.subject_display }; + let issuer = if !certificate.subject.is_empty() && certificate.subject == certificate.issuer { + tr("Same as owner") + } else if certificate.issuer_display.is_empty() { + certificate.issuer.clone() + } else { + certificate.issuer_display.clone() + }; + blocks.push(MokBlock::Certificate { + title, + fields: vec![ + (tr("Owner:"), known(owner), certificate.subject.clone()), + (tr("Issuer:"), known(&issuer), certificate.issuer.clone()), + (tr("Expires:"), certificate_date(&certificate.expires), certificate.expires.clone()), + ], + }); + } + if mok.other_signatures > 0 { + blocks.push(MokBlock::Message(arg(tr("%1 non-certificate signatures omitted"), &mok.other_signatures.to_string()))); + } + blocks +} + +/// What "Copy all" places on the clipboard: every field as `Caption: value`. +pub fn copy_all(snapshot: &SystemProperties) -> String { + let mut lines = Vec::new(); + for section in sections() { + for f in §ion.fields { + lines.push(format!("{}: {}", f.caption, display_value(snapshot, f.key, &snapshot.values.get(f.key).cloned().unwrap_or_default()))); + } + if section.fields.first().is_some_and(|f| f.key == "manufacturer") { + lines.push(format!("{}: {}", tr("MOK certificates"), display_value(snapshot, "mok", &Attribute::default()))); + } + if section.fields.first().is_some_and(|f| f.key == "physical_memory") { + lines.push(format!("{}: {}", tr("RAM/Memory"), display_value(snapshot, "memory_devices", &Attribute::default()))); + } + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secureboot::{MokCertificate, MokCertificates}; + use crate::system::{FirmwareMemoryInformation, GpuResizableBar}; + use std::collections::{BTreeMap, HashMap}; + + fn available(value: &str) -> Attribute { + Attribute { state: ReadState::Available, value: value.into(), error: 0 } + } + fn snapshot() -> SystemProperties { + let mut s = SystemProperties::default(); + for (k, v) in [ + ("hostname", "box"), + ("os", "openSUSE Tumbleweed"), + ("secure_boot", "enabled"), + ("boot", "uefi"), + ("uptime", "190861"), + ("memory", "33370574848"), + ("physical_memory", "34359738368"), + ("virtualization", "KVM"), + ("instruction_sets", "0, 1\tavx2 sse\n2\tsse"), + ("caches", "L1 Data\t65536\t8\nL3 Unified\t33554432\t1"), + ] { + s.values.insert(k.into(), available(v)); + } + s.sources.insert("memory".into(), "/proc/meminfo".into()); + s + } + fn value(s: &SystemProperties, key: &str) -> String { + display_value(s, key, &s.values.get(key).cloned().unwrap_or_default()) + } + + #[test] + fn plain_fields_are_worded_for_people() { + let s = snapshot(); + assert_eq!(value(&s, "secure_boot"), "Enabled"); + assert_eq!(value(&s, "boot"), "UEFI"); + assert_eq!(value(&s, "uptime"), "2 days, 5 hours, 1 minutes"); + assert_eq!(value(&s, "physical_memory"), "32 GiB"); + assert_eq!(value(&s, "memory"), "31.08 GiB (943 MiB is system reserved)"); + assert_eq!(value(&s, "virtualization"), "Reported hypervisor: KVM"); + assert_eq!(value(&s, "instruction_sets"), "Logical CPUs 0, 1: avx2 sse\n\nLogical CPUs 2: sse"); + assert_eq!(value(&s, "caches"), "L1 data: 64.00 KiB (8 unique instances)\nL3 unified: 32.00 MiB (1 unique instances)"); + let mut single = snapshot(); + single.values.insert("instruction_sets".into(), available("0, 1\tavx2 sse")); + assert_eq!(value(&single, "instruction_sets"), "avx2 sse", "one group needs no CPU prefix"); + let none = Attribute::default(); + assert_eq!(display_value(&s, "virtualization", &none), "Unknown (no hypervisor identity reported)"); + assert_eq!(display_value(&s, "boot", &available("not-exposed")), "UEFI not exposed (legacy boot or restricted environment)"); + assert_eq!(display_value(&s, "os", &none), "Unavailable"); + assert!(field_source(&s, "memory").starts_with("/proc/meminfo\nSystem reserved is reported physical RAM")); + assert_eq!(field_source(&s, "hostname"), ""); + } + + #[test] + fn system_reserved_memory_is_omitted_when_not_meaningful() { + let mut s = snapshot(); + s.values.insert("physical_memory".into(), Attribute::default()); + assert_eq!(value(&s, "memory"), "31.08 GiB"); + s.values.insert("physical_memory".into(), available("1000")); + assert_eq!(value(&s, "memory"), "31.08 GiB", "physical smaller than usable"); + s.values.insert("physical_memory".into(), available("34359738369")); + assert_eq!(value(&s, "physical_memory"), "32.00 GiB", "a non-whole GiB keeps two decimals"); + } + + #[test] + fn resizable_bar_lists_each_gpu_with_sizes() { + let mut s = snapshot(); + s.gpu_rebar_status = available("1"); + s.gpu_rebar.push(GpuResizableBar { + pci_address: "0000:03:00.0".into(), + status: available("reported"), + supported_sizes: BTreeMap::from([(0, available("1c0")), (2, Attribute::default())]), + current_sizes: BTreeMap::from([(0, available("268435456"))]), + }); + let text = display_value(&s, "gpu_rebar", &Attribute::default()); + assert!(text.starts_with("GPU [0000:03:00.0]\nCapability: Supported\n"), "{text}"); + assert!(text.contains("BAR 0 — Current allocation: 256 MiB; supported sizes: 64 MiB, 128 MiB, 256 MiB"), "{text}"); + assert!(text.contains("BAR 2 — Current allocation: Unavailable; supported sizes: Unavailable"), "{text}"); + assert!(text.ends_with("BIOS setting: Unknown")); + s.gpu_rebar[0].status = Attribute::default(); + assert!(display_value(&s, "gpu_rebar", &Attribute::default()).contains("Capability: Not exposed by kernel")); + s.gpu_rebar.clear(); + assert_eq!(display_value(&s, "gpu_rebar", &Attribute::default()), "No PCI display controller reported"); + s.gpu_rebar_status = Attribute { state: ReadState::PermissionDenied, ..Default::default() }; + assert_eq!(display_value(&s, "gpu_rebar", &Attribute::default()), "Permission denied"); + } + + #[test] + fn mok_certificates_show_owner_issuer_and_utc_dates() { + let mut s = snapshot(); + s.mok = MokCertificates { + status: available(""), + certificates: vec![MokCertificate { + subject: "CN=Example Key".into(), + issuer: "CN=Example Key".into(), + expires: "2030-01-05T04:03:02Z".into(), + subject_display: "Example Key".into(), + issuer_display: "Example Key".into(), + }], + other_signatures: 2, + }; + let blocks = mok_blocks(&s); + let MokBlock::Certificate { title, fields } = &blocks[0] else { panic!("expected a certificate") }; + assert_eq!(title, "MOK Certificate"); + assert_eq!(fields[0].1, "Example Key"); + assert_eq!(fields[1].1, "Same as owner"); + assert_eq!(fields[2].1, "5 Jan 2030, 04:03:02 UTC"); + assert!(matches!(&blocks[1], MokBlock::Message(m) if m == "2 non-certificate signatures omitted")); + assert!(display_value(&s, "mok", &Attribute::default()).starts_with("Owner: CN=Example Key\nIssuer: CN=Example Key\nExpires: 2030-01-05T04:03:02Z")); + s.mok.certificates.push(s.mok.certificates[0].clone()); + let MokBlock::Certificate { title, .. } = &mok_blocks(&s)[1] else { panic!() }; + assert_eq!(title, "MOK Certificate 2"); + assert_eq!(certificate_date("garbage"), "garbage"); + assert_eq!(certificate_date(""), "Unavailable"); + s.mok = MokCertificates { status: available(""), ..Default::default() }; + assert_eq!(display_value(&s, "mok", &Attribute::default()), "No X.509 certificates in the exposed MOK list"); + s.mok.status = Attribute { state: ReadState::PermissionDenied, ..Default::default() }; + assert!(matches!(&mok_blocks(&s)[0], MokBlock::Message(m) if m == "Permission denied")); + assert!(mok_scope(&s).contains("Dates are UTC.")); + } + + #[test] + fn firmware_memory_records_keep_unknowns_unknown() { + let mut s = snapshot(); + let mut device = FirmwareMemoryDevice { index: 0, values: HashMap::new() }; + for (k, v) in [("SIZE", "17179869184"), ("PRESENT", "1"), ("SPEED_MTS", "8533"), ("LOCATOR", "DIMM A")] { + device.values.insert(k.into(), available(v)); + } + s.memory_devices = FirmwareMemoryInformation { status: available("1"), devices: vec![device.clone()] }; + let blocks = memory_blocks(&s); + assert!(matches!(&blocks[0], MemoryBlock::Heading(h) if h == "Memory device 1")); + let get = |caption: &str| { + blocks.iter().find_map(|b| match b { + MemoryBlock::Field { caption: c, value, .. } if c == caption => Some(value.clone()), + _ => None, + }) + }; + assert_eq!(get("Capacity (total)").as_deref(), Some("16.00 GiB")); + assert_eq!(get("Rated transfer rate").as_deref(), Some("8,533 MT/s")); + assert_eq!(get("Presence").as_deref(), Some("Device reported present")); + assert_eq!(get("Part number").as_deref(), Some("Unavailable")); + device.values.remove("PRESENT"); + assert_eq!(memory_value("PRESENT", &Attribute::default()), "Unknown (no explicit presence flag)"); + let text = display_value(&s, "memory_devices", &Attribute::default()); + assert!(text.contains("Memory device 1 (firmware record 0)\nPresence: Device reported present"), "{text}"); + s.memory_devices = FirmwareMemoryInformation { status: available("0"), devices: vec![] }; + assert!(matches!(&memory_blocks(&s)[0], MemoryBlock::Message { text, .. } if text == "No memory-device records reported")); + s.memory_devices.status = Attribute::default(); + assert!(matches!(&memory_blocks(&s)[0], MemoryBlock::Message { text, .. } if text == "Unavailable")); + assert_eq!(memory_value("SIZE", &available("18446744073709551615")), "18,446,744,073,709,551,615 bytes"); + } + + #[test] + fn copy_all_lists_every_field_in_tab_order_with_the_variable_sections() { + let s = snapshot(); + let text = copy_all(&s); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[0], "Hostname: box"); + assert!(lines.contains(&"Secure Boot: Enabled") && lines.contains(&"Uptime: 2 days, 5 hours, 1 minutes")); + let at = |prefix: &str| lines.iter().position(|l| l.starts_with(prefix)).unwrap_or_else(|| panic!("missing {prefix}")); + assert!(at("System manufacturer") < at("MOK certificates") && at("MOK certificates") < at("CPU model(s)")); + assert!(at("Usable RAM") < at("RAM/Memory: Firmware-reported records")); + assert_eq!(sections().len(), 4); + } +} diff --git a/crates/qldm-core/src/tree.rs b/crates/qldm-core/src/tree.rs new file mode 100644 index 0000000..726bace --- /dev/null +++ b/crates/qldm-core/src/tree.rs @@ -0,0 +1,1007 @@ +//! The device tree shown by the main window, ported from `src/ui/devicemodel.cpp` and +//! `src/ui/devicefilter.cpp` without any toolkit types. +//! +//! [`TreeModel`] keeps the inventory and projects it into one of seven views (by type, +//! connection, driver, resources). Every projection has a single computer root and counts +//! devices, never grouping rows. Nodes refer to devices by path and generation, so a reused +//! path can never inherit an old selection. [`Filter`] reproduces the recursive quick/deep +//! filter: a row stays when it matches or when something below it does. +use crate::i18n::{arg, arg2, tr}; +use crate::label::device_display_name; +use crate::model::{category_label, reconcile, Device}; +use crate::resourceformat::{resource_rows, same_device_resources}; +use crate::search::{SearchMatch, SearchRecord}; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum View { + Type, + Connection, + DevicesByDriver, + DriversByDevice, + DriversByType, + ResourcesByType, + ResourcesByConnection, +} + +impl View { + pub const ALL: [View; 7] = + [View::Type, View::Connection, View::DevicesByDriver, View::DriversByDevice, View::DriversByType, View::ResourcesByType, View::ResourcesByConnection]; + + pub fn id(self) -> &'static str { + match self { + View::Type => "type", + View::Connection => "connection", + View::DevicesByDriver => "devicesByDriver", + View::DriversByDevice => "driversByDevice", + View::DriversByType => "driversByType", + View::ResourcesByType => "resourcesByType", + View::ResourcesByConnection => "resourcesByConnection", + } + } + + pub fn label(self) -> String { + tr(match self { + View::Type => "Devices by type", + View::Connection => "Devices by connection", + View::DevicesByDriver => "Devices by driver", + View::DriversByDevice => "Drivers by device", + View::DriversByType => "Drivers by type", + View::ResourcesByType => "Resources by type", + View::ResourcesByConnection => "Resources by connection", + }) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TreeNode { + pub key: String, + /// Display text, rebuilt with the inventory. + pub label: String, + pub category: String, + /// Record lookup id; empty for synthetic rows (groups, driver rows, resources). + pub path: String, + /// Projection identity: a replaced device gets new nodes. + pub generation: u64, + /// Resource rows target this device but are not device records. + pub owner_path: String, + /// Plain metadata/provenance for tooltips. + pub description: String, + /// An ancestor kept only to show real connection context. + pub context: bool, + pub children: Vec, +} + +impl TreeNode { + /// The node without its children. + fn clone_shallow(&self) -> TreeNode { + TreeNode { children: Vec::new(), ..self.clone() } + } + + pub fn is_resource(&self) -> bool { + !self.owner_path.is_empty() + } +} + +/// Same device metadata (generation excluded), so unchanged live snapshots change nothing. +fn same_metadata(a: &Device, b: &Device) -> bool { + a.path == b.path + && a.parent_path == b.parent_path + && a.subsystem == b.subsystem + && a.sysname == b.sysname + && a.devtype == b.devtype + && a.driver == b.driver + && a.driver_module == b.driver_module + && a.properties == b.properties + && a.name == b.name + && a.name_source == b.name_source + && a.name_translated == b.name_translated + && a.name_candidate == b.name_candidate + && a.represented_by_path == b.represented_by_path + && a.property_sources == b.property_sources + && a.category == b.category + && a.event_instance == b.event_instance + && a.incarnation == b.incarnation + && a.hidden == b.hidden + && a.attributes == b.attributes + && same_device_resources(&a.resources, &b.resources) +} + +/// Builder arena: nodes by index, so parents can be looked up while a view is assembled. +#[derive(Default)] +struct Arena { + nodes: Vec, + children: Vec>, +} + +impl Arena { + fn add(&mut self, parent: Option, key: String, label: String, category: &str, path: &str) -> usize { + let index = self.nodes.len(); + self.nodes.push(TreeNode { key, label, category: category.into(), path: path.into(), ..Default::default() }); + self.children.push(Vec::new()); + if let Some(parent) = parent { + self.children[parent].push(index); + } + index + } + + /// Nests the arena into a tree, sorting siblings by label (case-insensitive) then key. + fn into_tree(self, root: usize) -> TreeNode { + let mut slots: Vec> = self.nodes.into_iter().map(Some).collect(); + fn build(index: usize, slots: &mut [Option], children: &[Vec]) -> TreeNode { + let mut node = slots[index].take().expect("each node is used once"); + node.children = children[index].iter().map(|c| build(*c, slots, children)).collect(); + node.children.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()).then_with(|| a.key.cmp(&b.key))); + node + } + build(root, &mut slots, &self.children) + } +} + +#[derive(Debug)] +pub struct TreeModel { + devices: Vec, + lookup: HashMap, + root: Option, + show_internal: bool, + view: View, + generation: u64, + visible: usize, + hostname: String, +} + +impl Default for TreeModel { + fn default() -> Self { + Self::new(crate::system::machine_hostname()) + } +} + +impl TreeModel { + pub fn new(hostname: impl Into) -> Self { + Self { + devices: Vec::new(), + lookup: HashMap::new(), + root: None, + show_internal: false, + view: View::Type, + generation: 0, + visible: 0, + hostname: hostname.into(), + } + } + + pub fn view(&self) -> View { + self.view + } + + pub fn show_internal(&self) -> bool { + self.show_internal + } + + pub fn visible_count(&self) -> usize { + self.visible + } + + pub fn inventory_count(&self) -> usize { + self.devices.len() + } + + /// The single computer root, once an inventory was set. + pub fn root(&self) -> Option<&TreeNode> { + self.root.as_ref() + } + + pub fn set_view(&mut self, view: View) { + if self.view != view { + self.view = view; + self.rebuild(); + } + } + + pub fn set_show_internal(&mut self, show: bool) { + if show != self.show_internal { + self.show_internal = show; + self.rebuild(); + } + } + + fn computer_label(&self) -> String { + if self.hostname.is_empty() { + tr("This computer") + } else { + self.hostname.clone() + } + } + + /// Installs a new inventory. `removed_paths` (and their descendants) lose their identities; + /// `identity_lost` drops every identity. Returns `false` when nothing observable changed. + pub fn set_inventory(&mut self, mut devices: Vec, removed_paths: &HashSet, identity_lost: bool) -> bool { + let previous: Vec = if identity_lost { + Vec::new() + } else { + self.devices + .iter() + .filter(|d| { + let mut path = d.path.as_str(); + loop { + if removed_paths.contains(path) { + return false; + } + match path.rfind('/') { + Some(slash) => path = &path[..slash], + None => return true, + } + } + }) + .cloned() + .collect() + }; + reconcile(&mut devices, &previous, &mut self.generation); + let mut changed: HashSet = HashSet::new(); + for d in &devices { + match self.lookup.get(&d.path).map(|i| &self.devices[*i]) { + Some(old) if old.generation == d.generation && same_metadata(d, old) => {} + _ => { + changed.insert(d.path.clone()); + } + } + } + // Grouped-record tooltips also change when a member disappears or moves. + let representatives: HashMap<&str, &str> = devices.iter().map(|d| (d.path.as_str(), d.represented_by_path.as_str())).collect(); + for d in &self.devices { + if representatives.get(d.path.as_str()) != Some(&d.represented_by_path.as_str()) { + changed.insert(d.path.clone()); + if !d.represented_by_path.is_empty() { + changed.insert(d.represented_by_path.clone()); + } + } + } + let additions: Vec = + devices.iter().filter(|d| changed.contains(&d.path) && !d.represented_by_path.is_empty()).map(|d| d.represented_by_path.clone()).collect(); + changed.extend(additions); + let label = self.computer_label(); + if changed.is_empty() && self.root.as_ref().is_some_and(|r| r.label == label) { + return false; + } + self.devices = devices; + self.rebuild(); + true + } + + pub fn device(&self, path: &str, generation: u64) -> Option<&Device> { + self.lookup.get(path).map(|i| &self.devices[*i]).filter(|d| d.generation == generation) + } + + /// Current generation of a device path, if present. + pub fn generation_of(&self, path: &str) -> Option { + self.lookup.get(path).map(|i| self.devices[*i].generation) + } + + /// Index path (child numbers from the root) of the first node for a device instance. + pub fn find_device(&self, path: &str, generation: u64) -> Option> { + self.device(path, generation)?; + let root = self.root.as_ref()?; + fn walk(node: &TreeNode, path: &str, trail: &mut Vec) -> bool { + for (i, child) in node.children.iter().enumerate() { + trail.push(i); + if child.path == path || walk(child, path, trail) { + return true; + } + trail.pop(); + } + false + } + let mut trail = Vec::new(); + walk(root, path, &mut trail).then_some(trail) + } + + /// Exact node of a device instance by key (device rows and resource rows alike). + pub fn find_node(&self, key: &str, path: &str, generation: u64) -> Option> { + self.device(path, generation)?; + let root = self.root.as_ref()?; + fn walk(node: &TreeNode, key: &str, path: &str, generation: u64, trail: &mut Vec) -> bool { + for (i, child) in node.children.iter().enumerate() { + trail.push(i); + if (child.key == key && child.generation == generation && (child.path == path || child.owner_path == path)) + || walk(child, key, path, generation, trail) + { + return true; + } + trail.pop(); + } + false + } + let mut trail = Vec::new(); + walk(root, key, path, generation, &mut trail).then_some(trail) + } + + /// Visible devices in tree order, once each, with the label the tree shows for them. + pub fn search_records(&self) -> Vec { + let mut grouped: HashMap<&str, Vec<&str>> = HashMap::new(); + for d in &self.devices { + if !d.represented_by_path.is_empty() { + grouped.entry(d.represented_by_path.as_str()).or_default().push(d.path.as_str()); + } + } + let mut records = Vec::new(); + let mut seen: HashSet<&str> = HashSet::new(); + fn collect<'a>( + node: &'a TreeNode, + model: &'a TreeModel, + grouped: &HashMap<&str, Vec<&str>>, + seen: &mut HashSet<&'a str>, + records: &mut Vec, + ) { + for child in &node.children { + if !child.path.is_empty() && seen.insert(child.path.as_str()) { + let d = &model.devices[model.lookup[&child.path]]; + records.push(SearchRecord { + device: d.clone(), + label: child.label.clone(), + grouped_paths: grouped.get(d.path.as_str()).map(|g| g.join("\n")).unwrap_or_default(), + }); + } + collect(child, model, grouped, seen, records); + } + } + if let Some(root) = &self.root { + collect(root, self, &grouped, &mut seen, &mut records); + } + records + } + + /// Tooltip for a node: provenance for synthetic rows, identity and grouping for devices. + pub fn tooltip(&self, node: &TreeNode) -> String { + if !node.description.is_empty() { + return node.description.clone(); + } + let Some(d) = self.device(if node.path.is_empty() { &node.owner_path } else { &node.path }, node.generation) else { + return node.label.clone(); + }; + let efi_hidden = d.subsystem == "efivarfs" && !self.show_internal; + let mut lines = vec![ + node.label.clone(), + if efi_hidden { d.parent_path.clone() } else { d.path.clone() }, + format!("{}: {}", tr("Subsystem"), d.subsystem), + format!("{}: {}", tr("Raw name source"), d.name_source), + format!("{}: {}", tr("Raw name"), d.name), + format!("{}: {}", tr("Kernel name"), if efi_hidden { &d.name } else { &d.sysname }), + ]; + if d.subsystem == "power_supply" { + for key in ["type", "manufacturer", "model_name"] { + if let Some(a) = d.attributes.get(key).filter(|a| a.state == crate::model::ReadState::Available && !a.value.is_empty()) { + lines.push(format!("{key}: {}", a.value)); + } + } + } + if node.context { + lines.push(tr("Ancestor retained for connection context")); + } + if !d.represented_by_path.is_empty() { + lines.push(format!("{}: {}", tr("Represented by"), d.represented_by_path)); + } + let grouped: Vec<&str> = self.devices.iter().filter(|r| r.represented_by_path == d.path).map(|r| r.path.as_str()).collect(); + if !grouped.is_empty() { + lines.push(format!("{}:\n{}", tr("Grouped records"), grouped.join("\n"))); + } + lines.join("\n") + } + + fn rebuild(&mut self) { + self.lookup = self.devices.iter().enumerate().map(|(i, d)| (d.path.clone(), i)).collect(); + let show = self.show_internal; + let mut members: Vec<&Device> = self.devices.iter().collect(); + members.sort_by(|a, b| a.path.cmp(&b.path)); + let mut labels: HashMap<&str, String> = HashMap::new(); + let mut counts: HashMap = HashMap::new(); + for d in &members { + let label = device_display_name(d, show); + if show || !d.hidden { + *counts.entry(format!("{}:{label}", d.category)).or_default() += 1; + } + labels.insert(d.path.as_str(), label); + } + let mut namespace_numbers: HashMap = HashMap::new(); + for d in &members { + let label = labels[d.path.as_str()].clone(); + if counts.get(&format!("{}:{label}", d.category)).copied().unwrap_or(0) <= 1 { + continue; + } + if d.subsystem == "efivarfs" && !show { + let n = namespace_numbers.entry(label.clone()).or_default(); + *n += 1; + labels.insert(d.path.as_str(), arg2(tr("%1 (namespace %2)"), &label, &n.to_string())); + } else { + labels.insert(d.path.as_str(), device_display_name(d, true)); + } + } + members.sort_by(|a, b| labels[a.path.as_str()].to_lowercase().cmp(&labels[b.path.as_str()].to_lowercase()).then_with(|| a.path.cmp(&b.path))); + + let mut arena = Arena::default(); + let computer = arena.add(None, "computer".into(), self.computer_label(), "", ""); + let view = self.view; + let device_node = |arena: &mut Arena, parent: usize, d: &Device| { + let suffix = if view == View::ResourcesByType { format!(":{}", arena.nodes[parent].key) } else { String::new() }; + let index = arena.add(Some(parent), format!("device:{}{suffix}", d.path), labels[d.path.as_str()].clone(), d.category, &d.path); + arena.nodes[index].generation = d.generation; + index + }; + let driver_key = |d: &Device| if d.driver.is_empty() { "unbound".to_string() } else { format!("{}:{}", d.subsystem, d.driver) }; + let driver_label = |d: &Device| { + if d.driver.is_empty() { + tr("No directly bound driver") + } else { + arg2(tr("%1: %2 (driver)"), &d.subsystem, &d.driver) + } + }; + let mut groups: HashMap = HashMap::new(); + let mut group = |arena: &mut Arena, parent: usize, key: String, label: String, category: &str| -> usize { + if let Some(found) = groups.get(&key) { + return *found; + } + let index = arena.add(Some(parent), key.clone(), label, category, ""); + groups.insert(key, index); + index + }; + let has_resources = |d: &Device| d.resources.as_ref().is_some_and(|r| r.has_information()); + let resource_node = |arena: &mut Arena, parent: usize, d: &Device, row: &crate::resourceformat::ResourceRow| { + let mut label = arg2(tr("%1: %2"), &row.kind, &row.setting); + if !row.details.is_empty() { + label.push_str(&format!(" — {}", row.details)); + } + let key = format!("{}/resource:{}", arena.nodes[parent].key, row.key); + let index = arena.add(Some(parent), key, label.clone(), d.category, ""); + let description = format!("{label}\n{}\n{}", arg(tr("Device: %1"), &labels[d.path.as_str()]), arg(tr("Source: %1"), &row.source)); + let node = &mut arena.nodes[index]; + node.owner_path = d.path.clone(); + node.generation = d.generation; + node.description = description; + }; + let by_resource_type = view == View::ResourcesByType; + let by_resource_connection = view == View::ResourcesByConnection; + if by_resource_type { + for d in &members { + if (!show && d.hidden) || !has_resources(d) { + continue; + } + let mut device_groups: HashMap = HashMap::new(); + for row in resource_rows(d.resources.as_ref().expect("checked")) { + let category = group(&mut arena, computer, format!("resource-type:{}", row.group), row.kind.clone(), "other"); + let item = *device_groups.entry(row.group.clone()).or_insert_with(|| device_node(&mut arena, category, d)); + resource_node(&mut arena, item, d, &row); + } + } + } else if view == View::Connection || by_resource_connection { + // Only real inventory parent links; hidden ancestors provide context. + let mut included: HashSet<&str> = HashSet::new(); + for d in &members { + if (!show && d.hidden) || (by_resource_connection && !has_resources(d)) { + continue; + } + let mut path = d.path.as_str(); + let mut chain: HashSet<&str> = HashSet::new(); + while let Some(index) = self.lookup.get(path) { + if !chain.insert(path) { + break; + } + included.insert(path); + path = self.devices[*index].parent_path.as_str(); + } + } + let mut nodes: HashMap = HashMap::new(); + #[allow(clippy::too_many_arguments)] // Recursive helper over the view builder's local state. + fn ensure( + d: &Device, + model: &TreeModel, + included: &HashSet<&str>, + arena: &mut Arena, + nodes: &mut HashMap, + building: &mut HashSet, + computer: usize, + make: &dyn Fn(&mut Arena, usize, &Device) -> usize, + show: bool, + ) -> usize { + if let Some(found) = nodes.get(&d.path) { + return *found; + } + building.insert(d.path.clone()); + let mut parent = computer; + if included.contains(d.parent_path.as_str()) && !building.contains(&d.parent_path) { + let parent_device = &model.devices[model.lookup[&d.parent_path]]; + parent = ensure(parent_device, model, included, arena, nodes, building, computer, make, show); + } + let item = make(arena, parent, d); + arena.nodes[item].context = !show && d.hidden; + nodes.insert(d.path.clone(), item); + building.remove(&d.path); + item + } + let mut building = HashSet::new(); + for d in &members { + if !included.contains(d.path.as_str()) { + continue; + } + let item = ensure(d, self, &included, &mut arena, &mut nodes, &mut building, computer, &device_node, show); + if by_resource_connection && (show || !d.hidden) && has_resources(d) { + for row in resource_rows(d.resources.as_ref().expect("checked")) { + resource_node(&mut arena, item, d, &row); + } + } + } + } else { + for d in &members { + if !show && d.hidden { + continue; + } + let mut parent = computer; + if view == View::Type || view == View::DriversByType { + parent = group(&mut arena, parent, format!("category:{}", d.category), category_label(d.category), d.category); + } + if view == View::DevicesByDriver || view == View::DriversByType { + let prefix = if view == View::DriversByType { format!("{}:", d.category) } else { String::new() }; + parent = group(&mut arena, parent, format!("driver:{prefix}{}", driver_key(d)), driver_label(d), d.category); + } + let item = device_node(&mut arena, parent, d); + if view != View::DriversByDevice { + continue; + } + let item_key = arena.nodes[item].key.clone(); + let driver = arena.add(Some(item), format!("{item_key}/driver"), driver_label(d), d.category, ""); + if !d.driver.is_empty() { + let label = if d.driver_module.is_empty() { + tr("Owning module / built-in status undetermined") + } else { + arg(tr("%1 (kernel module)"), &d.driver_module) + }; + arena.add(Some(driver), format!("{item_key}/module"), label, d.category, ""); + } + // Explicitly separate ancestor binding from the selected device's binding. + let mut path = d.parent_path.as_str(); + let mut visited: HashSet<&str> = HashSet::from([d.path.as_str()]); + while let Some(index) = self.lookup.get(path) { + if !visited.insert(path) { + break; + } + let ancestor = &self.devices[*index]; + if !ancestor.driver.is_empty() { + let label = arg2(tr("Parent-device driver: %1 — %2"), &driver_label(ancestor), &labels[path]); + let relation = arena.add(Some(item), format!("{item_key}/parent-driver"), label, ancestor.category, ""); + if !ancestor.driver_module.is_empty() { + arena.add( + Some(relation), + format!("{item_key}/parent-module"), + arg(tr("%1 (kernel module)"), &ancestor.driver_module), + ancestor.category, + "", + ); + } + break; + } + path = ancestor.parent_path.as_str(); + } + } + } + let tree = arena.into_tree(computer); + let mut visible: HashSet<&str> = HashSet::new(); + fn count<'a>(node: &'a TreeNode, visible: &mut HashSet<&'a str>) { + for child in &node.children { + if !child.path.is_empty() { + visible.insert(child.path.as_str()); + } + count(child, visible); + } + } + count(&tree, &mut visible); + self.visible = visible.len(); + self.root = Some(tree); + } +} + +/// Quick (display-name) or deep (metadata) filter state, applied to a [`TreeModel`] snapshot. +#[derive(Clone, Debug, Default)] +pub struct Filter { + query: String, + deep: bool, + matches: HashMap, +} + +impl Filter { + pub fn set_query(&mut self, query: &str, deep: bool) { + self.query = query.to_string(); + self.deep = deep; + self.matches.clear(); + } + + pub fn add_matches(&mut self, matches: &[SearchMatch]) { + for m in matches { + self.matches.insert(m.path.clone(), m.clone()); + } + } + + pub fn active(&self) -> bool { + !self.query.is_empty() + } + + pub fn deep(&self) -> bool { + self.deep + } + + fn contains(&self, text: &str) -> bool { + text.to_lowercase().contains(&self.query.to_lowercase()) + } + + fn deep_match(&self, path: &str, generation: u64) -> bool { + self.deep && self.matches.get(path).is_some_and(|m| m.generation == generation) + } + + /// Row-level acceptance, before recursive retention of ancestors. + fn accepts(&self, node: &TreeNode, ancestors: &[&TreeNode]) -> bool { + if !self.active() || node.key == "computer" { + return true; + } + if node.is_resource() && self.contains(&node.label) { + return true; + } + if node.path.is_empty() { + // Keep driver/module/resource children when their reporting device matches. + for owner in ancestors.iter().rev() { + if owner.path.is_empty() { + continue; + } + return self.contains(&owner.label) || self.deep_match(&owner.path, owner.generation); + } + return false; + } + self.contains(&node.label) || self.deep_match(&node.path, node.generation) + } + + /// The tree with only matching rows and the ancestors that lead to them (recursive + /// filtering: a row is kept when it matches or anything below it does). + pub fn apply(&self, root: &TreeNode) -> TreeNode { + fn filtered<'a>(node: &'a TreeNode, stack: &mut Vec<&'a TreeNode>, filter: &Filter) -> Option { + let accepted = filter.accepts(node, stack); + stack.push(node); + let children: Vec = node.children.iter().filter_map(|c| filtered(c, stack, filter)).collect(); + stack.pop(); + (accepted || !children.is_empty()).then(|| TreeNode { children, ..node.clone_shallow() }) + } + let mut stack = Vec::new(); + filtered(root, &mut stack, self).unwrap_or_else(|| root.clone_shallow()) + } + + /// Why a device row matched a deep search (`None` for a name match or an inactive filter). + pub fn match_reason(&self, node: &TreeNode) -> Option { + if !self.active() || !self.deep || self.contains(&node.label) { + return None; + } + self.matches.get(&node.path).filter(|m| m.generation == node.generation).map(|m| m.field.clone()) + } +} + +/// Unique devices shown in a (filtered) tree; grouping, driver and resource rows do not count. +pub fn visible_device_count(root: &TreeNode) -> usize { + fn walk<'a>(node: &'a TreeNode, paths: &mut HashSet<&'a str>) { + for child in &node.children { + if !child.path.is_empty() { + paths.insert(child.path.as_str()); + } + walk(child, paths); + } + } + let mut paths = HashSet::new(); + walk(root, &mut paths); + paths.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ReadState; + use crate::resources::{DeviceResource, DeviceResources, ResourceType}; + use std::sync::Arc; + + fn fixture() -> Device { + let mut d = Device { + path: "/sys/devices/fixture".into(), + incarnation: "fixture:1".into(), + name: "Visible device".into(), + name_source: "udev: ID_MODEL".into(), + category: "other", + subsystem: "pci".into(), + sysname: "fixture".into(), + ..Default::default() + }; + d.properties.insert("ID_SERIAL".into(), "CaseSensitiveSerial".into()); + d + } + fn none() -> HashSet { + HashSet::new() + } + fn model_with(devices: Vec) -> TreeModel { + let mut model = TreeModel::new("testhost"); + model.set_inventory(devices, &none(), false); + model + } + fn gen(model: &TreeModel, path: &str) -> u64 { + model.generation_of(path).expect("device present") + } + fn node<'a>(model: &'a TreeModel, trail: &[usize]) -> &'a TreeNode { + trail.iter().fold(model.root().expect("root"), |n, i| &n.children[*i]) + } + fn filtered(model: &TreeModel, filter: &Filter) -> TreeNode { + filter.apply(model.root().unwrap()) + } + fn count(model: &TreeModel, filter: &Filter) -> usize { + visible_device_count(&filtered(model, filter)) + } + + #[test] + fn search_inputs_exclude_hidden_devices_and_name_search_ignores_metadata() { + let device = fixture(); + let hidden = Device { path: format!("{}/hidden", device.path), hidden: true, ..device.clone() }; + let mut model = model_with(vec![device.clone(), hidden]); + let records = model.search_records(); + assert_eq!(records.len(), 1, "hidden devices are excluded from search inputs"); + let record = &records[0]; + let mut filter = Filter::default(); + filter.set_query("casesensitiveserial", false); + let shown = filtered(&model, &filter); + assert_eq!((visible_device_count(&shown), shown.children.len()), (0, 0), "name search ignores metadata; the computer root stays"); + filter.set_query("casesensitiveserial", true); + filter.add_matches(&[SearchMatch { path: record.device.path.clone(), generation: record.device.generation + 1, field: "ID_SERIAL".into() }]); + assert_eq!(count(&model, &filter), 0, "a stale generation cannot match a reused path"); + filter.add_matches(&[SearchMatch { path: record.device.path.clone(), generation: record.device.generation, field: "ID_SERIAL".into() }]); + let shown = filtered(&model, &filter); + assert_eq!((visible_device_count(&shown), shown.children.len()), (1, 1), "a deep match keeps its category"); + assert_eq!(filter.match_reason(&shown.children[0].children[0]).as_deref(), Some("ID_SERIAL")); + filter.set_query(&record.label.to_uppercase(), false); + assert_eq!(count(&model, &filter), 1, "name search uses the exact displayed label, case-insensitively"); + filter.set_query("", false); + assert_eq!(count(&model, &filter), 1); + model.set_show_internal(true); + assert_eq!((model.search_records().len(), count(&model, &filter)), (2, 2), "internal visibility applies to both modes"); + } + + fn views_fixture() -> (Device, Device, Device) { + let mut pci = fixture(); + pci.driver = "shared".into(); + pci.driver_module = "owner".into(); + let usb = Device { + path: "/sys/devices/usb-fixture".into(), + name: "USB fixture".into(), + subsystem: "usb".into(), + driver: "shared".into(), + driver_module: "owner".into(), + ..pci.clone() + }; + let child = Device { + path: format!("{}/child", pci.path), + parent_path: pci.path.clone(), + name: "Child fixture".into(), + driver: String::new(), + driver_module: String::new(), + ..pci.clone() + }; + (pci, usb, child) + } + + #[test] + fn connection_view_keeps_real_ancestry_and_hidden_context() { + let (pci, usb, child) = views_fixture(); + let context = Device { hidden: true, ..pci.clone() }; + let mut model = model_with(vec![context, child.clone(), usb]); + model.set_view(View::Connection); + assert_eq!(model.visible_count(), 3, "hidden ancestor kept as context"); + let trail = model.find_device(&child.path, gen(&model, &child.path)).unwrap(); + assert_eq!(node(&model, &trail[..trail.len() - 1]).path, pci.path, "the actual parent record"); + let context_node = node(&model, &trail[..trail.len() - 1]); + assert!(context_node.context && model.tooltip(context_node).contains("Ancestor retained for connection context")); + let mut filter = Filter::default(); + filter.set_query("Child fixture", false); + assert_eq!(count(&model, &filter), 2, "matching device keeps its ancestry"); + } + + #[test] + fn driver_projections_stay_bus_qualified_and_never_inflate_counts() { + let (pci, usb, child) = views_fixture(); + let mut model = model_with(vec![pci.clone(), child.clone(), usb.clone()]); + let mut filter = Filter::default(); + model.set_view(View::DevicesByDriver); + assert_eq!(model.root().unwrap().children.len(), 3, "same driver name on two buses is distinct from the unbound group"); + assert_eq!((model.visible_count(), count(&model, &filter)), (3, 3)); + model.set_view(View::DriversByDevice); + let trail = model.find_device(&child.path, gen(&model, &child.path)).unwrap(); + let child_node = node(&model, &trail); + assert_eq!(child_node.children.len(), 2, "unbound child has a separate parent-driver relationship"); + assert!(child_node.children.iter().all(|c| c.path.is_empty()), "synthetic rows cannot retarget Properties"); + assert_eq!((model.search_records().len(), count(&model, &filter)), (3, 3), "module rows are ignored"); + model.set_view(View::DriversByType); + let computer = model.root().unwrap(); + assert!(computer.children.len() == 1 && computer.children[0].children.len() == 3, "bus-qualified groups nest within category"); + assert!(model.find_device(&child.path, gen(&model, &child.path)).is_some(), "instance selection survives view changes"); + + let unknown = Device { driver_module: String::new(), ..pci.clone() }; + model.set_inventory(vec![unknown, child, usb], &none(), false); + model.set_view(View::DriversByDevice); + let trail = model.find_device(&pci.path, gen(&model, &pci.path)).unwrap(); + let driver_row = &node(&model, &trail).children[0]; + assert!(driver_row.children[0].label.contains("undetermined"), "a missing module link proves nothing"); + filter.set_query("Visible device", false); + let shown = filtered(&model, &filter); + fn locate<'a>(node: &'a TreeNode, path: &str) -> Option<&'a TreeNode> { + node.children.iter().find_map(|c| if c.path == path { Some(c) } else { locate(c, path) }) + } + let kept = locate(&shown, &pci.path).expect("the matching device is kept"); + assert!(kept.children.len() == 1 && kept.children[0].children.len() == 1, "filtering keeps the driver and module-status rows"); + } + + #[test] + fn every_projection_has_one_computer_root_and_counts_devices_only() { + let (pci, usb, child) = views_fixture(); + let mut model = model_with(vec![pci, child, usb]); + for view in [View::Type, View::Connection, View::DevicesByDriver, View::DriversByDevice, View::DriversByType] { + model.set_view(view); + let root = model.root().unwrap(); + assert!(root.key == "computer" && root.path.is_empty() && root.label == "testhost"); + assert_eq!((model.visible_count(), model.search_records().len()), (3, 3), "{view:?}"); + } + model.set_view(View::Type); + assert_eq!(model.visible_count(), 3); + assert_eq!(TreeModel::new("").computer_label(), "This computer"); + } + + #[test] + fn live_updates_keep_instances_and_replace_removed_ones() { + let (pci, usb, child) = views_fixture(); + let mut model = model_with(vec![pci.clone(), child.clone(), usb.clone()]); + let pci_generation = gen(&model, &pci.path); + assert!(!model.set_inventory(vec![pci.clone(), child.clone(), usb.clone()], &none(), false), "unchanged snapshots change nothing"); + let renamed = Device { name: "ZZ renamed fixture".into(), ..pci.clone() }; + assert!(model.set_inventory(vec![renamed.clone(), child.clone(), usb.clone()], &none(), false)); + assert_eq!(gen(&model, &pci.path), pci_generation, "a rename keeps the instance"); + assert!(model.search_records().iter().any(|r| r.label.contains("ZZ renamed"))); + // Remove/add at the same path, even with identical inode hints, replaces the parent + // and its descendants. + let old_child = gen(&model, &child.path); + let removed: HashSet = [pci.path.clone()].into(); + model.set_inventory(vec![renamed.clone(), child.clone(), usb.clone()], &removed, false); + assert_ne!(gen(&model, &pci.path), pci_generation); + assert_ne!(gen(&model, &child.path), old_child); + assert!(model.device(&pci.path, pci_generation).is_none()); + // Event loss invalidates everything. + let usb_generation = gen(&model, &usb.path); + model.set_inventory(vec![renamed.clone(), child.clone(), usb.clone()], &none(), true); + assert_ne!(gen(&model, &usb.path), usb_generation); + for view in [View::Type, View::Connection, View::DevicesByDriver, View::DriversByDevice, View::DriversByType] { + model.set_view(view); + let stable = gen(&model, &usb.path); + model.set_inventory(vec![renamed.clone(), usb.clone()], &none(), false); + assert!(gen(&model, &usb.path) == stable && model.visible_count() == 2, "removal keeps unrelated rows in {view:?}"); + model.set_inventory(vec![renamed.clone(), child.clone(), usb.clone()], &none(), false); + assert!(gen(&model, &usb.path) == stable && model.visible_count() == 3, "addition keeps unrelated rows in {view:?}"); + } + } + + fn io_resources(source: &str, start: u64) -> Arc { + let item = |kind, start, end, mode: &str, pci, index, flags, suffix: &str| DeviceResource { + kind, + start, + end, + mode: mode.into(), + pci, + index, + flags, + source: format!("{source}/{suffix}"), + ..Default::default() + }; + Arc::new(DeviceResources { + items: vec![ + item(ResourceType::Irq, start, start, "msix", false, -1, 0, &format!("msi_irqs/{start}")), + item(ResourceType::Memory, 0x8000_0000, 0x8000_0fff, "", true, 0, 0x0010_2200, "resource"), + ], + ..Default::default() + }) + } + + #[test] + fn resource_views_group_by_type_deduplicate_devices_and_follow_ancestry() { + let (pci_base, usb, child_base) = views_fixture(); + let bus = Device { path: "/sys/devices/pci-root".into(), name: "Real parent context".into(), hidden: true, resources: None, ..pci_base.clone() }; + let pci = Device { resources: Some(io_resources(&pci_base.path, 121)), parent_path: bus.path.clone(), ..pci_base.clone() }; + let child = Device { resources: Some(io_resources(&child_base.path, 121)), ..child_base.clone() }; + let mut model = model_with(vec![bus.clone(), pci.clone(), child.clone(), usb.clone()]); + model.set_view(View::ResourcesByType); + assert_eq!(model.root().unwrap().children.len(), 2, "memory and IRQ groups"); + assert_eq!((model.visible_count(), model.search_records().len()), (2, 2), "devices deduplicated across type memberships"); + let pci_generation = gen(&model, &pci.path); + let trail = model.find_device(&pci.path, pci_generation).unwrap(); + let resource = &node(&model, &trail).children[0]; + assert!(resource.is_resource() && resource.path.is_empty()); + assert!(resource.owner_path == pci.path && resource.generation == pci_generation, "instance-scoped owner, not a device record"); + assert!(resource.description.contains("Source:") && resource.label.contains("MSI-X") || resource.label.contains("BAR 0")); + let key = resource.key.clone(); + assert!(model.find_node(&key, &pci.path, pci_generation).is_some(), "exact node restored by generation"); + + // Equivalent owned snapshots rebuild nothing; a changed assignment keeps the device. + let clone = Device { resources: Some(Arc::new((**pci.resources.as_ref().unwrap()).clone())), ..pci.clone() }; + assert!(!model.set_inventory(vec![bus.clone(), clone, child.clone(), usb.clone()], &none(), false)); + let reassigned = Device { resources: Some(io_resources(&pci.path, 122)), ..pci.clone() }; + assert!(model.set_inventory(vec![bus.clone(), reassigned, child.clone(), usb.clone()], &none(), false)); + assert_eq!(gen(&model, &pci.path), pci_generation, "resource-only changes keep the reporting device"); + assert!(model.find_node(&key, &pci.path, pci_generation).is_none(), "the old assignment row is gone"); + model.set_inventory(vec![bus.clone(), pci.clone(), child.clone(), usb.clone()], &none(), false); + + let mut filter = Filter::default(); + filter.set_query("121", false); + assert_eq!(count(&model, &filter), 2, "resource settings are found and both shared owners kept"); + filter.set_query("", false); + + model.set_view(View::ResourcesByConnection); + let trail = model.find_device(&pci.path, gen(&model, &pci.path)).unwrap(); + assert_eq!(node(&model, &trail[..trail.len() - 1]).path, bus.path, "real hidden ancestry"); + assert!(model.visible_count() == 3 && model.search_records().len() == 3, "unrelated no-resource devices are excluded"); + let child_trail = model.find_device(&child.path, gen(&model, &child.path)).unwrap(); + assert_eq!(&child_trail[..child_trail.len() - 1], trail.as_slice(), "children follow recorded parents"); + assert_eq!(node(&model, &child_trail).children.len(), 2, "direct resource rows under the device"); + filter.set_query("121", false); + assert_eq!(count(&model, &filter), 3, "resource filtering keeps connection-context ancestors"); + } + + #[test] + fn equal_labels_are_disambiguated_and_efi_namespaces_numbered() { + let a = Device { path: "/sys/devices/a".into(), sysname: "a0".into(), ..fixture() }; + let b = Device { path: "/sys/devices/b".into(), sysname: "b0".into(), ..fixture() }; + let model = model_with(vec![a, b]); + let labels: Vec<&str> = model.root().unwrap().children[0].children.iter().map(|c| c.label.as_str()).collect(); + assert_eq!(labels, ["Visible device [a0]", "Visible device [b0]"]); + + let efi = |path: &str, guid: &str| { + let mut d = Device { + path: path.into(), + subsystem: "efivarfs".into(), + category: "efi", + name: "SecureBoot".into(), + sysname: path.rsplit('/').next().unwrap().into(), + hidden: true, + incarnation: "1:1".into(), + ..Default::default() + }; + d.properties.insert("EFI_VENDOR_GUID".into(), guid.into()); + d + }; + let mut model = TreeModel::new("h"); + model.set_show_internal(true); + model.set_inventory( + vec![efi("/sys/firmware/efi/efivars/SecureBoot-1", "guid1"), efi("/sys/firmware/efi/efivars/SecureBoot-2", "guid2")], + &none(), + false, + ); + let labels: Vec<&str> = model.root().unwrap().children[0].children.iter().map(|c| c.label.as_str()).collect(); + assert_eq!(labels, ["Secure Boot [guid1]", "Secure Boot [guid2]"], "internal view shows the vendor GUID"); + model.set_show_internal(false); + assert_eq!(model.visible_count(), 0, "firmware records follow the internal toggle"); + } + + #[test] + fn tooltips_describe_identity_and_grouping() { + let wifi = Device { path: "/sys/devices/pci/wifi".into(), sysname: "wifi".into(), ..fixture() }; + let net = Device { + path: format!("{}/net/wlo1", wifi.path), + subsystem: "net".into(), + sysname: "wlo1".into(), + name: "wlo1".into(), + represented_by_path: wifi.path.clone(), + hidden: true, + category: "network", + ..fixture() + }; + let model = model_with(vec![wifi.clone(), net.clone()]); + let trail = model.find_device(&wifi.path, gen(&model, &wifi.path)).unwrap(); + let tooltip = model.tooltip(node(&model, &trail)); + assert!(tooltip.contains("Subsystem: pci") && tooltip.contains("Raw name source: udev: ID_MODEL"), "{tooltip}"); + assert!(tooltip.contains("Grouped records:") && tooltip.contains(&net.path), "{tooltip}"); + let root = model.root().unwrap(); + assert_eq!(model.tooltip(root), "testhost", "synthetic rows fall back to their label"); + let _ = ReadState::Available; + } +} diff --git a/crates/qldm-core/src/v4l2_sys.rs b/crates/qldm-core/src/v4l2_sys.rs new file mode 100644 index 0000000..3c98ff2 --- /dev/null +++ b/crates/qldm-core/src/v4l2_sys.rs @@ -0,0 +1,95 @@ +//! V4L2 structures generated with `bindgen /usr/include/linux/videodev2.h` (allowlist: the +//! capability, format, frame-size and frame-interval records and their enum constants). +//! Regenerate with the same allowlist if the kernel ABI ever grows; layouts are checked in tests. +#![allow(non_camel_case_types, dead_code)] + +pub const V4L2_CAP_VIDEO_CAPTURE: u32 = 1; +pub const V4L2_CAP_VIDEO_CAPTURE_MPLANE: u32 = 4096; +pub const V4L2_CAP_VIDEO_M2M_MPLANE: u32 = 16384; +pub const V4L2_CAP_VIDEO_M2M: u32 = 32768; +pub const V4L2_CAP_DEVICE_CAPS: u32 = 2147483648; +pub type __u8 = ::core::ffi::c_uchar; +pub type __u32 = ::core::ffi::c_uint; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct v4l2_fract { + pub numerator: __u32, + pub denominator: __u32, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct v4l2_capability { + pub driver: [__u8; 16usize], + pub card: [__u8; 32usize], + pub bus_info: [__u8; 32usize], + pub version: __u32, + pub capabilities: __u32, + pub device_caps: __u32, + pub reserved: [__u32; 3usize], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct v4l2_fmtdesc { + pub index: __u32, + pub type_: __u32, + pub flags: __u32, + pub description: [__u8; 32usize], + pub pixelformat: __u32, + pub mbus_code: __u32, + pub reserved: [__u32; 3usize], +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct v4l2_frmsize_discrete { + pub width: __u32, + pub height: __u32, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct v4l2_frmsize_stepwise { + pub min_width: __u32, + pub max_width: __u32, + pub step_width: __u32, + pub min_height: __u32, + pub max_height: __u32, + pub step_height: __u32, +} +#[repr(C)] +#[derive(Copy, Clone)] +pub struct v4l2_frmsizeenum { + pub index: __u32, + pub pixel_format: __u32, + pub type_: __u32, + pub __bindgen_anon_1: v4l2_frmsizeenum__bindgen_ty_1, + pub reserved: [__u32; 2usize], +} +#[repr(C)] +#[derive(Copy, Clone)] +pub union v4l2_frmsizeenum__bindgen_ty_1 { + pub discrete: v4l2_frmsize_discrete, + pub stepwise: v4l2_frmsize_stepwise, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct v4l2_frmival_stepwise { + pub min: v4l2_fract, + pub max: v4l2_fract, + pub step: v4l2_fract, +} +#[repr(C)] +#[derive(Copy, Clone)] +pub struct v4l2_frmivalenum { + pub index: __u32, + pub pixel_format: __u32, + pub width: __u32, + pub height: __u32, + pub type_: __u32, + pub __bindgen_anon_1: v4l2_frmivalenum__bindgen_ty_1, + pub reserved: [__u32; 2usize], +} +#[repr(C)] +#[derive(Copy, Clone)] +pub union v4l2_frmivalenum__bindgen_ty_1 { + pub discrete: v4l2_fract, + pub stepwise: v4l2_frmival_stepwise, +} diff --git a/crates/qldm-core/tests/camera_helper.rs b/crates/qldm-core/tests/camera_helper.rs new file mode 100644 index 0000000..33fee26 --- /dev/null +++ b/crates/qldm-core/tests/camera_helper.rs @@ -0,0 +1,85 @@ +//! Runs the real helper binary against this machine's video nodes. +use qldm_core::camera::collect_camera_properties; +use qldm_core::dirfd::Dir; +use qldm_core::model::ReadState; +use std::path::Path; + +#[test] +fn helper_reports_each_video_node_or_a_precise_failure() { + let helper = Path::new(env!("CARGO_BIN_EXE_qldm-camera-helper")); + let Ok(entries) = std::fs::read_dir("/sys/class/video4linux") else { return }; + let mut checked = 0; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with("video") { + continue; + } + let sysfs = std::fs::canonicalize(entry.path()).unwrap().to_string_lossy().into_owned(); + let node = format!("/dev/{name}"); + let dir = Dir::open(&sysfs).unwrap(); + let found = collect_camera_properties(&dir, &sysfs, &node, helper, None); + checked += 1; + match found.state.state { + ReadState::Available => { + assert!(found.report.is_object(), "{node}"); + if found.applicable { + let modes = found.report["modes"].as_array().expect("modes array"); + assert!(!modes.is_empty(), "{node}: a capture camera lists at least one mode"); + assert!(modes.iter().all(|m| m["format"].as_u64().is_some() && m["queue"].is_string()), "{node}"); + } else { + assert_eq!(found.report["applicable"], false, "{node}"); + } + } + // No access to the node, or it is not a capture device: explicit, never a hang. + other => assert!( + matches!(other, ReadState::PermissionDenied | ReadState::Unsupported | ReadState::Removed | ReadState::Error), + "{node}: {:?}", + found.state + ), + } + } + eprintln!("checked {checked} video nodes"); +} + +#[test] +fn a_helper_that_never_answers_is_killed_within_the_budget() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("hang"); + std::fs::write(&script, "#!/bin/sh\nexec sleep 30\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let dir = Dir::open(tmp.path()).unwrap(); + let started = std::time::Instant::now(); + let found = retry_text_busy(|| collect_camera_properties(&dir, &tmp.path().to_string_lossy(), "/dev/video0", &script, None)); + assert_eq!(found.state.error, libc::ETIMEDOUT); + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + // A cancelled wait is also prompt and leaves no stuck helper behind. + let cancel = std::sync::atomic::AtomicBool::new(true); + let again = collect_camera_properties(&dir, &tmp.path().to_string_lossy(), "/dev/video0", &script, Some(&cancel)); + assert!(matches!(again.state.error, libc::ECANCELED | libc::EAGAIN)); +} + +#[test] +fn a_helper_with_bad_output_is_a_protocol_error() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("noise"); + std::fs::write(&script, "#!/bin/sh\necho 'not json'\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let dir = Dir::open(tmp.path()).unwrap(); + let found = retry_text_busy(|| collect_camera_properties(&dir, &tmp.path().to_string_lossy(), "/dev/video0", &script, None)); + assert_eq!(found.state.error, libc::EPROTO); +} + +/// Executing a script right after writing it can fail with ETXTBSY when a parallel test forks while +/// the write handle is still open; that is a test-harness race, not a product error, so retry. +fn retry_text_busy(mut run: impl FnMut() -> qldm_core::camera::CameraProperties) -> qldm_core::camera::CameraProperties { + for _ in 0..20 { + let found = run(); + if found.state.error != libc::ETXTBSY { + return found; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + run() +} diff --git a/crates/qldm-core/tests/translation.rs b/crates/qldm-core/tests/translation.rs new file mode 100644 index 0000000..6780d58 --- /dev/null +++ b/crates/qldm-core/tests/translation.rs @@ -0,0 +1,42 @@ +//! A compiled catalogue in the bound text domain translates `tr`; untranslated text stays English. +use std::process::Command; + +#[test] +fn catalogue_translates_known_strings_only() { + let locale = String::from_utf8(Command::new("locale").arg("-a").output().expect("locale -a").stdout).unwrap(); + let Some(utf8) = locale.lines().find(|l| l.to_lowercase().ends_with("utf8") && !l.starts_with("C.") && *l != "C" && *l != "POSIX") else { + println!("skip: no non-C UTF-8 locale installed, so gettext cannot select a language"); + return; + }; + let dir = std::env::temp_dir().join(format!("qldm-i18n-{}", std::process::id())); + let lang = dir.join("xx/LC_MESSAGES"); + std::fs::create_dir_all(&lang).unwrap(); + let po = dir.join("xx.po"); + std::fs::write(&po, "msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n\nmsgid \"Hostname\"\nmsgstr \"Nom d'hôte\"\n").unwrap(); + let status = Command::new("msgfmt").arg(&po).arg("-o").arg(lang.join("qlinuxdevicemanager.mo")).status(); + if !status.is_ok_and(|s| s.success()) { + println!("skip: msgfmt is not installed"); + return; + } + // gettext reads the environment at setlocale time, so run the probe in a fresh process. + let exe = std::env::current_exe().unwrap(); + let out = Command::new(exe) + .args(["--exact", "probe", "--nocapture", "--ignored"]) + .env("QLDM_LOCALE_DIR", &dir) + .env("LC_ALL", utf8) + .env("LANGUAGE", "xx") + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("hostname=Nom d'hôte"), "{text}"); + assert!(text.contains("other=Uptime"), "{text}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +#[ignore] +fn probe() { + qldm_core::i18n::init(); + println!("hostname={}", qldm_core::i18n::tr("Hostname")); + println!("other={}", qldm_core::i18n::tr("Uptime")); +} diff --git a/crates/qldm-gtk/Cargo.toml b/crates/qldm-gtk/Cargo.toml new file mode 100644 index 0000000..18dbd52 --- /dev/null +++ b/crates/qldm-gtk/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "qldm-gtk" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" + +[dependencies] +qldm-core = { path = "../qldm-core" } +gtk = { package = "gtk4", version = "0.9", features = ["v4_12"] } +async-channel = "2" +libc = "0.2" + +[[test]] +name = "ui_smoke" +harness = false diff --git a/crates/qldm-gtk/src/lib.rs b/crates/qldm-gtk/src/lib.rs new file mode 100644 index 0000000..0d0415e --- /dev/null +++ b/crates/qldm-gtk/src/lib.rs @@ -0,0 +1,7 @@ +//! GTK4 front end of QLinuxDeviceManager. The window logic lives here (not in `main.rs`) so +//! it can be driven by the UI smoke test. +pub mod properties; +pub mod row; +pub mod settings; +pub mod system_dialog; +pub mod window; diff --git a/crates/qldm-gtk/src/main.rs b/crates/qldm-gtk/src/main.rs new file mode 100644 index 0000000..05fa594 --- /dev/null +++ b/crates/qldm-gtk/src/main.rs @@ -0,0 +1,19 @@ +use gtk::prelude::*; +use gtk::{gio, glib}; +use qldm_gtk::window::Ui; + +const APP_ID: &str = "io.github.birdie-github.QLinuxDeviceManager"; + +fn main() -> glib::ExitCode { + qldm_core::i18n::init(); + let app = gtk::Application::builder().application_id(APP_ID).flags(gio::ApplicationFlags::default()).build(); + app.connect_activate(|app| { + // A second launch just raises the existing window. + if let Some(existing) = app.active_window() { + existing.present(); + return; + } + Ui::build(app); + }); + app.run() +} diff --git a/crates/qldm-gtk/src/properties/mod.rs b/crates/qldm-gtk/src/properties/mod.rs new file mode 100644 index 0000000..4de9aa9 --- /dev/null +++ b/crates/qldm-gtk/src/properties/mod.rs @@ -0,0 +1,764 @@ +//! The Properties window, ported from `src/ui/propertiesdialog.cpp`. +//! +//! A tabbed read-only report of one device instance: General and Driver facts, the specialised +//! hardware tabs (Network, Display, Camera, USB, PCIe, Battery), Details with advanced values, +//! Resources, Storage with volumes and cached health, and live udev Events. What each tab says +//! is built by `qldm_core` (`propertytext`, `report`, `storage_presentation`, `events_view`); +//! this module only presents it. The dialog is bound to one device instance (path, generation) +//! and never retargets itself: when the instance disappears it becomes a read-only snapshot. +pub(crate) mod widgets; + +use gtk::glib; +use gtk::prelude::*; +use qldm_core::events::{DeviceEvent, DeviceEventsSnapshot}; +use qldm_core::events_view::{event_row, event_text, events_copy_text, events_unchanged, update_events}; +use qldm_core::i18n::{arg, arg2, tr}; +use qldm_core::label::device_display_name; +use qldm_core::model::{Device, ReadState}; +use qldm_core::properties::DeviceProperties; +use qldm_core::propertytext::{details_property_status, is_details_property, property_entries, property_read_value, PropertyEntry}; +use qldm_core::report::{self, Report}; +use qldm_core::resourceformat::resource_rows; +use qldm_core::storage_presentation::{storage_fields_text, storage_presentation, storage_rows_text, StoragePresentation}; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use widgets::{add_field, copy_button, copy_to_clipboard, field_grid, groups_box, notice, plain_label, scrolled, storage_fields_grid, CellRow, TextTable}; + +struct State { + snapshot: DeviceProperties, + removed: bool, + entries: Vec, + storage: StoragePresentation, + displayed_events: Vec, + event_scope: String, + // Selections that survive a rebuild. + page: String, + detail_id: String, + volume_id: String, + health_id: String, +} + +pub struct PropertiesDialog { + window: gtk::Window, + banner: gtk::Label, + notebook: gtk::Notebook, + reload: gtk::Button, + device: Device, + state: RefCell, + advanced: Cell, + // The Events page keeps its widgets across rebuilds so live updates do not disturb it. + events_page: gtk::Box, + events_notice: gtk::Label, + events_table: TextTable, + events_selection: gtk::SelectionModel, + events_details: gtk::TextView, + on_reload: Box, +} + +fn text_view() -> gtk::TextView { + gtk::TextView::builder().editable(false).cursor_visible(false).wrap_mode(gtk::WrapMode::WordChar).left_margin(6).right_margin(6).top_margin(6).build() +} + +fn button_row(buttons: &[>k::Button]) -> gtk::Box { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + row.set_margin_start(8); + row.set_margin_end(8); + row.set_margin_top(6); + row.set_margin_bottom(6); + for button in buttons { + row.append(*button); + } + row +} + +fn page_with_notice(notice_text: &str, tip: &str, body: &impl IsA, copy: >k::Button) -> gtk::Box { + let page = gtk::Box::new(gtk::Orientation::Vertical, 0); + page.append(¬ice(notice_text, tip)); + page.append(&scrolled(body)); + page.append(&button_row(&[copy])); + page +} + +fn report_page(report: &Report, notice_text: &str, tip: &str, copy_label: &str) -> gtk::Box { + let body = groups_box(&report.groups); + for table in &report.tables { + let frame = gtk::Frame::new(Some(&table.title)); + let (view, _) = TextTable::new(&table.headers, false, &[]); + view.set_rows(table.rows.iter().zip(&table.sources).map(|(r, s)| CellRow::new(r.clone(), s)).collect()); + let height = (table.rows.len() as i32 * 40).clamp(160, 420); + let inner = gtk::ScrolledWindow::builder().child(&view.view).min_content_height(height).hexpand(true).build(); + frame.set_child(Some(&inner)); + body.append(&frame); + } + let text = report.copy_text.clone(); + page_with_notice(notice_text, tip, &body, ©_button(copy_label, move || text.clone())) +} + +impl PropertiesDialog { + pub fn new(parent: &impl IsA, device: Device, on_reload: Box) -> Rc { + let window = gtk::Window::builder() + .transient_for(parent) + .title(arg(tr("%1 — Properties"), &device_display_name(&device, false))) + .default_width(680) + .default_height(560) + .build(); + let banner = plain_label(&tr("Loading properties…")); + banner.set_margin_start(8); + banner.set_margin_end(8); + banner.set_margin_top(6); + let notebook = gtk::Notebook::new(); + notebook.set_vexpand(true); + notebook.set_scrollable(true); + let reload = gtk::Button::with_label(&tr("Reload properties")); + let close = gtk::Button::with_label(&tr("Close")); + let bottom = gtk::Box::new(gtk::Orientation::Horizontal, 6); + bottom.set_halign(gtk::Align::End); + bottom.set_margin_top(6); + bottom.set_margin_bottom(6); + bottom.set_margin_end(8); + bottom.append(&reload); + bottom.append(&close); + let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); + layout.append(&banner); + layout.append(¬ebook); + layout.append(&bottom); + window.set_child(Some(&layout)); + + // The long-lived Events page. + let events_page = gtk::Box::new(gtk::Orientation::Vertical, 0); + let events_notice = notice(&tr("ℹ️ Live udev events since the application started"), ""); + events_page.append(&events_notice); + let (events_table, events_selection) = TextTable::new(&[tr("Observed time"), tr("Event type"), tr("Description")], true, &[]); + events_page.append(&scrolled(&events_table.view)); + let events_details = text_view(); + events_page.append(>k::ScrolledWindow::builder().child(&events_details).min_content_height(120).build()); + + let this = Rc::new(Self { + window, + banner, + notebook, + reload, + state: RefCell::new(State { + snapshot: DeviceProperties { device: device.clone(), ..Default::default() }, + removed: false, + entries: Vec::new(), + storage: StoragePresentation::default(), + displayed_events: Vec::new(), + event_scope: String::new(), + page: String::new(), + detail_id: String::new(), + volume_id: String::new(), + health_id: String::new(), + }), + device, + advanced: Cell::new(false), + events_page, + events_notice, + events_table, + events_selection, + events_details, + on_reload, + }); + let weak = Rc::downgrade(&this); + this.reload.connect_clicked({ + let weak = weak.clone(); + move |_| { + if let Some(d) = weak.upgrade() { + (d.on_reload)(); + } + } + }); + let window = this.window.clone(); + close.connect_clicked(move |_| window.close()); + this.events_selection.connect_selection_changed({ + let weak = weak.clone(); + move |_, _, _| { + if let Some(d) = weak.upgrade() { + d.show_event(); + } + } + }); + // Events page buttons. + let copy_selection = gtk::Button::with_label(&tr("Copy selection")); + let copy_all = gtk::Button::with_label(&tr("Copy all events")); + copy_selection.connect_clicked({ + let weak = weak.clone(); + move |b| { + if let Some(d) = weak.upgrade() { + copy_to_clipboard(b, &d.events_copy(true)); + } + } + }); + copy_all.connect_clicked({ + let weak = weak.clone(); + move |b| { + if let Some(d) = weak.upgrade() { + copy_to_clipboard(b, &d.events_copy(false)); + } + } + }); + this.events_page.append(&button_row(&[©_selection, ©_all])); + this.rebuild(); + this + } + + pub fn window(&self) -> >k::Window { + &self.window + } + + pub fn device(&self) -> &Device { + &self.device + } + + pub fn is_removed(&self) -> bool { + self.state.borrow().removed + } + + /// Titles of the tabs currently shown. + pub fn tab_titles(&self) -> Vec { + (0..self.notebook.n_pages()) + .filter_map(|n| self.notebook.nth_page(Some(n))) + .filter_map(|page| self.notebook.tab_label_text(&page).map(|t| t.to_string())) + .collect() + } + + /// Whether the first property read is still outstanding. + pub fn is_loading(&self) -> bool { + self.banner.is_visible() && self.banner.text() == tr("Loading properties…") + } + + pub fn banner_text(&self) -> String { + if self.banner.is_visible() { + self.banner.text().to_string() + } else { + String::new() + } + } + + /// Selects a tab by title (test hook). + pub fn show_tab(&self, title: &str) -> bool { + for n in 0..self.notebook.n_pages() { + let matches = self.notebook.nth_page(Some(n)).and_then(|p| self.notebook.tab_label_text(&p)).is_some_and(|t| t == title); + if matches { + self.notebook.set_current_page(Some(n)); + return true; + } + } + false + } + + pub fn present(&self) { + self.window.present(); + } + + pub fn set_busy(&self, busy: bool) { + let removed = self.is_removed(); + self.reload.set_sensitive(!busy && !removed); + if busy && !removed { + self.banner.set_text(&tr("Loading properties…")); + self.banner.set_visible(true); + } + } + + /// Accepts a finished property read for this exact instance. + pub fn accept_result(self: &Rc, result: DeviceProperties) { + if self.is_removed() || result.device.path != self.device.path || result.device.generation != self.device.generation { + return; + } + self.set_busy(false); + if result.state == ReadState::Removed { + self.mark_removed(); + return; + } + if result.state != ReadState::Available { + self.banner.set_visible(true); + self.banner.set_text(&arg( + tr("Properties could not be refreshed: %1. Showing the previous snapshot."), + &property_read_value(&qldm_core::model::Attribute { state: result.state, value: String::new(), error: result.error }), + )); + return; + } + self.state.borrow_mut().snapshot = result; + self.banner.set_text(""); + self.banner.set_visible(false); + self.rebuild(); + } + + /// Called with the current inventory record of this device (or `None`): a replaced or missing + /// instance turns the dialog into a read-only snapshot. An inventory refresh never silently + /// retargets it or reloads it. + pub fn reconcile_instance(self: &Rc, current: Option<&Device>) { + if self.is_removed() { + return; + } + if current.is_none_or(|c| c.generation != self.device.generation || c.incarnation != self.device.incarnation) { + self.mark_removed(); + } + } + + pub fn mark_removed(self: &Rc) { + { + let mut state = self.state.borrow_mut(); + state.removed = true; + state.event_scope.push('\n'); + state.event_scope.push_str(&tr("Removed/replaced device: retained events are a read-only instance snapshot.")); + self.events_notice.set_tooltip_text(Some(&state.event_scope)); + } + self.reload.set_sensitive(false); + self.banner.set_visible(true); + self.banner.set_text(&tr("Device removed or replaced. This dialog is a read-only snapshot; close it and reopen Properties for the current device.")); + self.rebuild(); + } + + // ---- pages ---- + + fn rebuild(self: &Rc) { + let (previous_page, entries, storage) = { + let mut state = self.state.borrow_mut(); + state.entries = property_entries(&state.snapshot, state.removed); + state.storage = storage_presentation(&state.snapshot.storage, &self.device.path); + (state.page.clone(), state.entries.clone(), state.storage.clone()) + }; + let page_label = |n: u32| self.notebook.nth_page(Some(n)).and_then(|p| self.notebook.tab_label_text(&p)).map(|t| t.to_string()).unwrap_or_default(); + let current = self.notebook.current_page().map(page_label).unwrap_or_default(); + let remember = if current.is_empty() { previous_page } else { current }; + while self.notebook.n_pages() > 0 { + self.notebook.remove_page(Some(0)); + } + let snapshot = self.state.borrow().snapshot.clone(); + let add = |title: String, page: >k::Box| { + self.notebook.append_page(page, Some(>k::Label::new(Some(&title)))); + }; + add(tr("General"), &self.form_page(&entries, 0)); + add(tr("Driver"), &self.form_page(&entries, 1)); + let tip_net = tr("Addresses belong to the application's network namespace. Reload properties to refresh. Hardware MAC is the permanent address reported by the driver. An overridden MAC is the current address when it differs from the permanent address, or Linux reports a random, inherited or explicitly set address."); + if let Some(r) = report::network_report(&snapshot) { + add(tr("Network"), &report_page(&r, &tr("ℹ️ Network interface snapshot"), &tip_net, &tr("Copy network properties"))); + } + if let Some(r) = report::display_report(&snapshot) { + let tip = tr("Monitor information is reported by this connector’s cached EDID and kernel mode list; it does not guarantee capabilities. Active settings are shown separately below. Resolution and refresh rate describe the configured physical mode. Desktop color depth is not the panel or link depth. Wayland scale derived from logical size may include rounding; integer scale is a fallback. Reload properties to refresh. Unmatched or ambiguous connector names remain unavailable."); + add(tr("Display"), &report_page(&r, &tr("ℹ️ Monitor hardware and display configuration snapshot"), &tip, &tr("Copy display properties"))); + } + if let Some(r) = report::camera_report(&snapshot) { + let tip = tr("Queries only: no capture, format changes or measured frame rates. Reload properties to refresh. Rows preserve the format, size and frame interval relationship. Size ranges do not imply a common rate list."); + add(tr("Camera"), &report_page(&r, &tr("ℹ️ Driver-reported camera capabilities snapshot"), &tip, &tr("Copy camera properties"))); + } + if let Some(r) = report::usb_report(&snapshot) { + let tip = tr("Power information is declared by the active USB configuration, not measured consumption. USB version and speed are also available as raw values on Details. Reload properties to refresh."); + add(tr("USB"), &report_page(&r, &tr("ℹ️ USB configuration snapshot"), &tip, &tr("Copy USB properties"))); + } + if let Some(r) = report::pcie_report(&snapshot) { + let tip = tr("Current link readings can reflect power management and topology; a lower speed is not a fault verdict. Device maxima do not establish the capacity of the upstream path. Reading these sysfs attributes may temporarily runtime-resume the device. Reload properties to refresh."); + add(tr("PCIe"), &report_page(&r, &tr("ℹ️ PCIe link snapshot for this device"), &tip, &tr("Copy PCIe properties"))); + } + if let Some(r) = report::battery_report(&snapshot) { + let tip = tr("Readings are reported by the kernel and battery firmware. Capacity retention is an estimate from compatible full/design values, not a complete health assessment. Energy and charge units remain separate. Reload properties to refresh; no background monitoring or battery settings are changed."); + add(tr("Battery"), &report_page(&r, &tr("ℹ️ Battery snapshot for this device"), &tip, &tr("Copy Battery properties"))); + } + add(tr("Details"), &self.details_page(&entries)); + add(tr("Events"), &self.events_page); + if snapshot.resources.has_information() { + add(tr("Resources"), &self.resources_page(&snapshot)); + } + if snapshot.storage.applicable { + self.storage_pages(&storage, &add); + } + // Restore the tab the user was on. + for n in 0..self.notebook.n_pages() { + if !remember.is_empty() && page_label(n) == remember { + self.notebook.set_current_page(Some(n)); + } + } + self.state.borrow_mut().page = remember; + } + + fn form_page(&self, entries: &[PropertyEntry], tab: i32) -> gtk::Box { + let grid = field_grid(); + for (row, entry) in entries.iter().filter(|e| e.tab == tab).enumerate() { + add_field(&grid, row as i32, &entry.label, &entry.value, &entry.source); + } + let page = gtk::Box::new(gtk::Orientation::Vertical, 0); + page.append(&scrolled(&grid)); + page + } + + fn details_page(self: &Rc, entries: &[PropertyEntry]) -> gtk::Box { + let shown: Vec<&PropertyEntry> = entries.iter().filter(|e| is_details_property(e, &self.device) && (!e.advanced || self.advanced.get())).collect(); + // Readable values first, then those with a status such as "unavailable". + let (available, other): (Vec<_>, Vec<_>) = shown.iter().copied().partition(|e| details_property_status(e).is_empty()); + let ordered: Vec<&PropertyEntry> = available.into_iter().chain(other).collect(); + let labels: Vec = ordered + .iter() + .map(|e| { + let status = details_property_status(e); + if status.is_empty() { + e.label.clone() + } else { + arg2(tr("%1 (%2)"), &e.label, &status) + } + }) + .collect(); + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let dropdown = gtk::DropDown::from_strings(&label_refs); + dropdown.set_hexpand(true); + let ids: Vec = ordered.iter().map(|e| e.id.clone()).collect(); + let previous = self.state.borrow().detail_id.clone(); + if let Some(index) = ids.iter().position(|id| *id == previous) { + dropdown.set_selected(index as u32); + } + let value = text_view(); + let source = plain_label(""); + let owned: Vec = ordered.iter().map(|e| (*e).clone()).collect(); + let show = { + let (value, source, owned) = (value.clone(), source.clone(), owned.clone()); + move |index: u32| match owned.get(index as usize) { + Some(entry) => { + value.set_monospace(entry.id == "efi/hex"); + value.set_wrap_mode(if entry.id == "efi/hex" { gtk::WrapMode::None } else { gtk::WrapMode::WordChar }); + value.buffer().set_text(&entry.value); + source.set_text(&if entry.source.is_empty() { String::new() } else { arg(tr("Source: %1"), &entry.source) }); + } + None => { + value.buffer().set_text(""); + source.set_text(""); + } + } + }; + show(dropdown.selected()); + let advanced = gtk::CheckButton::with_mnemonic(&tr("Show _advanced properties")); + advanced.set_active(self.advanced.get()); + + let page = gtk::Box::new(gtk::Orientation::Vertical, 6); + page.set_margin_start(8); + page.set_margin_end(8); + page.set_margin_top(8); + let property_label = gtk::Label::with_mnemonic(&tr("_Property:")); + property_label.set_xalign(0.0); + property_label.set_mnemonic_widget(Some(&dropdown)); + page.append(&property_label); + page.append(&dropdown); + page.append(>k::ScrolledWindow::builder().child(&value).vexpand(true).build()); + page.append(&source); + page.append(&advanced); + let copy_value = copy_button(&tr("Copy value"), { + let (value, dropdown) = (value.clone(), dropdown.clone()); + move || { + let _ = &dropdown; + let buffer = value.buffer(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() + } + }); + let copy_all = copy_button(&tr("Copy all values"), { + let owned = owned.clone(); + move || { + owned + .iter() + .map(|e| { + let mut text = format!("{}:\n{}", e.label, e.value); + if !e.source.is_empty() { + text += &format!("\n{}", arg(tr("Source: %1"), &e.source)); + } + text + }) + .collect::>() + .join("\n\n") + } + }); + page.append(&button_row(&[©_value, ©_all])); + self.wire_details(&advanced, &dropdown, ids, show); + page + } + + /// Shows the chosen property, remembers it across rebuilds, and rebuilds when "advanced" is + /// toggled. + fn wire_details(self: &Rc, advanced: >k::CheckButton, dropdown: >k::DropDown, ids: Vec, show: impl Fn(u32) + 'static) { + let weak = Rc::downgrade(self); + advanced.connect_toggled({ + let weak = weak.clone(); + move |check| { + if let Some(dialog) = weak.upgrade() { + dialog.advanced.set(check.is_active()); + dialog.rebuild(); + } + } + }); + dropdown.connect_selected_notify(move |d| { + show(d.selected()); + if let (Some(dialog), Some(id)) = (weak.upgrade(), ids.get(d.selected() as usize)) { + dialog.state.borrow_mut().detail_id = id.clone(); + } + }); + } + + fn resources_page(&self, snapshot: &DeviceProperties) -> gtk::Box { + let (table, selection) = TextTable::new(&[tr("Resource type"), tr("Setting"), tr("Details")], true, &[]); + let rows = resource_rows(&snapshot.resources); + table.set_rows(rows.iter().map(|r| CellRow::new(vec![r.kind.clone(), r.setting.clone(), r.details.clone()], &r.source)).collect()); + let mut all: Vec = Vec::new(); + if !snapshot.resources.pnp_state.is_empty() { + let state = if snapshot.resources.pnp_state == "active" { tr("Active") } else { tr("Disabled") }; + all.push(arg(tr("PnP device state: %1"), &state)); + } + all.extend(rows.iter().map(|r| format!("{}\t{}\t{}\n{}", r.kind, r.setting, r.details, arg(tr("Source: %1"), &r.source)))); + let all_text = all.join("\n\n"); + let page = gtk::Box::new(gtk::Orientation::Vertical, 0); + page.append(&scrolled(&table.view)); + let copy_selection = gtk::Button::with_label(&tr("Copy selection")); + copy_selection.set_sensitive(false); + let copy_all = copy_button(&tr("Copy all resources"), move || all_text.clone()); + copy_selection.connect_clicked({ + let (selection, rows) = (selection.clone(), rows.clone()); + move |b| { + let mut text = Vec::new(); + for (i, r) in rows.iter().enumerate() { + if selection.is_selected(i as u32) { + text.push(format!("{}\t{}\t{}\n{}", r.kind, r.setting, r.details, arg(tr("Source: %1"), &r.source))); + } + } + copy_to_clipboard(b, &text.join("\n\n")); + } + }); + selection.connect_selection_changed({ + let (copy_selection, selection) = (copy_selection.clone(), selection.clone()); + move |_, _, _| copy_selection.set_sensitive(selection.selection().size() > 0) + }); + page.append(&button_row(&[©_selection, ©_all])); + page + } + + fn storage_pages(self: &Rc, presentation: &StoragePresentation, add: &dyn Fn(String, >k::Box)) { + let scope = tr("Read-only metadata snapshot. Each entity is a separate layer; capacities are not added. Kernel and UDisks2 block records are merged only by their exact device-number mapping. Source paths remain in tooltips and copied text. Deep search includes native metadata only."); + // Overview. + let overview = presentation.clone(); + let page = page_with_notice( + &tr("ℹ️ Storage snapshot for this device"), + &format!("{scope}\n\n{}", presentation.notes), + &storage_fields_grid(&presentation.overview), + ©_button(&tr("Copy storage overview"), move || format!("{}\n\n{}", storage_fields_text(&overview.overview), overview.notes)), + ); + add(tr("Storage"), &page); + + // Volumes: a table with the selected volume's fields below it. + let (table, selection) = TextTable::new(&[tr("Device / label"), tr("Layer"), tr("Capacity"), tr("Content"), tr("Mount points")], false, &[]); + table.set_rows( + presentation + .volumes + .iter() + .map(|v| CellRow::new(vec![v.name.clone(), v.kind.clone(), v.capacity.clone(), v.filesystem.clone(), v.mounts.clone()], &v.source)) + .collect(), + ); + let details = gtk::Box::new(gtk::Orientation::Vertical, 0); + let volumes = presentation.volumes.clone(); + let show_volume = { + let (details, volumes) = (details.clone(), volumes.clone()); + move |index: u32| { + while let Some(child) = details.first_child() { + details.remove(&child); + } + match volumes.get(index as usize) { + Some(v) => details.append(&storage_fields_grid(&v.fields)), + None => details.append(&plain_label(&tr("No related volume metadata available."))), + } + } + }; + let previous = self.state.borrow().volume_id.clone(); + let mut initial = volumes.iter().position(|v| v.id == previous).or_else(|| volumes.iter().position(|v| v.id == self.device.path)).unwrap_or(0); + if volumes.is_empty() { + initial = usize::MAX; + } + if initial != usize::MAX { + selection.select_item(initial as u32, true); + } + show_volume(if initial == usize::MAX { u32::MAX } else { initial as u32 }); + let weak = Rc::downgrade(self); + selection.connect_selection_changed({ + let (selection, volumes, show_volume) = (selection.clone(), volumes.clone(), show_volume.clone()); + move |_, _, _| { + let single = selection.clone().downcast::().ok(); + let index = single.map_or(u32::MAX, |s| s.selected()); + show_volume(index); + if let (Some(v), Some(dialog)) = (volumes.get(index as usize), weak.upgrade()) { + dialog.state.borrow_mut().volume_id = v.id.clone(); + } + } + }); + let volumes_page = gtk::Box::new(gtk::Orientation::Vertical, 0); + volumes_page.append(¬ice( + &tr("ℹ️ Related volumes and mounts"), + &format!("{scope}\n{}\n\n{}", tr("Related volumes may span several drives. Mount points cover only the application mount namespace. No filesystem is probed, mounted or unlocked."), presentation.notes), + )); + volumes_page.append(>k::ScrolledWindow::builder().child(&table.view).min_content_height(160).vexpand(true).build()); + volumes_page.append(>k::ScrolledWindow::builder().child(&details).min_content_height(160).vexpand(true).build()); + let copy_selected = copy_button(&tr("Copy selected volume"), { + let (selection, volumes) = (selection.clone(), volumes.clone()); + move || { + let index = selection.clone().downcast::().map_or(u32::MAX, |s| s.selected()); + volumes.get(index as usize).map(|v| storage_rows_text(std::slice::from_ref(v))).unwrap_or_default() + } + }); + let copy_all = copy_button(&tr("Copy all volumes"), { + let volumes = volumes.clone(); + move || storage_rows_text(&volumes) + }); + volumes_page.append(&button_row(&[©_selected, ©_all])); + add(tr("Volumes"), &volumes_page); + + // Health: pick a drive, then show its groups and ATA table. + let health = presentation.health.clone(); + let names: Vec<&str> = health.iter().map(|h| h.name.as_str()).collect(); + let drive = gtk::DropDown::from_strings(&names); + drive.set_hexpand(true); + let previous = self.state.borrow().health_id.clone(); + let initial = health + .iter() + .position(|h| h.id == previous) + .or_else(|| health.iter().position(|h| !presentation.preferred_health_drive.is_empty() && h.id == presentation.preferred_health_drive)) + .unwrap_or(0); + drive.set_selected(initial as u32); + let groups = gtk::Box::new(gtk::Orientation::Vertical, 8); + groups.set_margin_start(8); + groups.set_margin_end(8); + let show_health = { + let (groups, health) = (groups.clone(), health.clone()); + move |index: u32| { + while let Some(child) = groups.first_child() { + groups.remove(&child); + } + let Some(entry) = health.get(index as usize) else { + groups.append(&plain_label(&tr("Cached health unavailable."))); + return; + }; + let mut sections: Vec<(String, Vec)> = Vec::new(); + if !entry.fields.is_empty() { + sections.push((tr("Health"), entry.fields.clone())); + } + sections.extend(entry.health_groups.iter().map(|g| (g.title.clone(), g.fields.clone()))); + for (title, fields) in sections { + let frame = gtk::Frame::new(Some(&title)); + frame.set_child(Some(&storage_fields_grid(&fields))); + groups.append(&frame); + } + if !entry.ata_attributes.is_empty() { + let frame = gtk::Frame::new(Some(&tr("ATA SMART attributes"))); + let headers = [tr("ID"), tr("Name"), tr("Current"), tr("Worst"), tr("Threshold"), tr("Interpreted value"), tr("Flags")]; + let (table, _) = TextTable::new(&headers, true, &[0, 2, 3, 4, 6]); + table.set_rows( + entry + .ata_attributes + .iter() + .map(|a| { + CellRow::new( + vec![ + a.id.clone(), + a.name.clone(), + a.current.clone(), + a.worst.clone(), + a.threshold.clone(), + a.interpreted.clone(), + a.flags.clone(), + ], + &a.source, + ) + }) + .collect(), + ); + let height = (entry.ata_attributes.len() as i32 * 34).clamp(120, 420); + frame.set_child(Some(>k::ScrolledWindow::builder().child(&table.view).min_content_height(height).build())); + groups.append(&frame); + } + } + }; + show_health(initial as u32); + let weak = Rc::downgrade(self); + drive.connect_selected_notify({ + let (health, show_health) = (health.clone(), show_health.clone()); + move |d| { + show_health(d.selected()); + if let (Some(h), Some(dialog)) = (health.get(d.selected() as usize), weak.upgrade()) { + dialog.state.borrow_mut().health_id = h.id.clone(); + } + } + }); + let health_page = gtk::Box::new(gtk::Orientation::Vertical, 4); + health_page.append(¬ice( + &tr("ℹ️ Cached drive health"), + &format!( + "{}\n\n{}", + tr("Cached UDisks2 health only, separate from device operational status. Reload does not request SMART updates or self-tests. Missing evidence is unavailable; no reported warning does not guarantee health. Related drives are shown separately."), + presentation.notes + ), + )); + let drive_label = gtk::Label::with_mnemonic(&tr("_Drive:")); + drive_label.set_xalign(0.0); + drive_label.set_margin_start(8); + drive_label.set_mnemonic_widget(Some(&drive)); + health_page.append(&drive_label); + drive.set_margin_start(8); + drive.set_margin_end(8); + health_page.append(&drive); + health_page.append(&scrolled(&groups)); + let copy_selected = copy_button(&tr("Copy selected drive health"), { + let (drive, health) = (drive.clone(), health.clone()); + move || health.get(drive.selected() as usize).map(|h| storage_rows_text(std::slice::from_ref(h))).unwrap_or_default() + }); + let copy_all = copy_button(&tr("Copy all drive health"), move || storage_rows_text(&health)); + health_page.append(&button_row(&[©_selected, ©_all])); + add(tr("Health"), &health_page); + } + + // ---- events ---- + + /// Refreshes the Events tab from the monitor's history (called on every inventory snapshot). + pub fn update_events(&self, history: &DeviceEventsSnapshot, monitor_note: &str) { + let (removed, displayed) = { + let state = self.state.borrow(); + (state.removed, state.displayed_events.clone()) + }; + let update = update_events(history, monitor_note, &self.device, removed, &displayed); + self.state.borrow_mut().event_scope = update.scope.clone(); + self.events_notice.set_tooltip_text(Some(&update.scope)); + if events_unchanged(&update.events, &displayed) { + return; // Unrelated events must not reset selection or scroll. + } + let selected = self.selected_event_rows().first().and_then(|i| displayed.get(*i)).cloned(); + let restore = + selected.as_ref().and_then(|s| update.events.iter().position(|e| e.sequence == s.sequence && e.elapsed_ms == s.elapsed_ms && e.action == s.action)); + let rows: Vec = update.events.iter().map(|e| CellRow::new(event_row(e).to_vec(), "")).collect(); + self.events_table.set_rows(rows); + self.state.borrow_mut().displayed_events = update.events; + let count = self.state.borrow().displayed_events.len(); + if let Some(row) = restore.or((count > 0).then_some(0)) { + self.events_selection.select_item(row as u32, true); + } + self.show_event(); + } + + fn selected_event_rows(&self) -> Vec { + let count = self.state.borrow().displayed_events.len(); + (0..count).filter(|i| self.events_selection.is_selected(*i as u32)).collect() + } + + fn show_event(&self) { + let rows = self.selected_event_rows(); + let text = rows.first().and_then(|i| self.state.borrow().displayed_events.get(*i).map(event_text)).unwrap_or_default(); + self.events_details.buffer().set_text(&text); + } + + fn events_copy(&self, selected_only: bool) -> String { + let state = self.state.borrow(); + let picked: Vec<&DeviceEvent> = if selected_only { + self.selected_event_rows().iter().filter_map(|i| state.displayed_events.get(*i)).collect() + } else { + state.displayed_events.iter().collect() + }; + events_copy_text(&state.event_scope, &picked) + } +} + +/// Glue for callers that hold the dialog only weakly. +pub fn close_on_destroy(dialog: &Rc, callback: impl Fn() + 'static) { + dialog.window().connect_close_request(move |_| { + callback(); + glib::Propagation::Proceed + }); +} diff --git a/crates/qldm-gtk/src/properties/widgets.rs b/crates/qldm-gtk/src/properties/widgets.rs new file mode 100644 index 0000000..b1ccfc6 --- /dev/null +++ b/crates/qldm-gtk/src/properties/widgets.rs @@ -0,0 +1,202 @@ +//! Small building blocks shared by the Properties tabs: selectable wrapped labels, grouped +//! caption/value grids with source tooltips, a plain text table, and copy buttons. +use gtk::glib; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use qldm_core::report::ReportGroup; +use qldm_core::storage_presentation::StorageDisplayField; +use std::cell::RefCell; + +/// A selectable, wrapping plain-text label. +pub fn plain_label(text: &str) -> gtk::Label { + let label = gtk::Label::new(Some(text)); + label.set_wrap(true); + label.set_wrap_mode(gtk::pango::WrapMode::WordChar); + label.set_xalign(0.0); + label.set_yalign(0.0); + label.set_selectable(true); + label.set_hexpand(true); + label.set_natural_wrap_mode(gtk::NaturalWrapMode::Word); + label +} + +fn tooltip(source: &str) -> Option<&str> { + (!source.is_empty()).then_some(source) +} + +/// One captioned row; the source is the tooltip of both cells. +pub fn add_field(grid: >k::Grid, row: i32, caption: &str, value: &str, source: &str) { + let name = gtk::Label::new(Some(&format!("{caption}:"))); + name.set_xalign(0.0); + name.set_yalign(0.0); + name.set_wrap(true); + name.add_css_class("dim-label"); + let text = plain_label(value); + for widget in [name.upcast_ref::(), text.upcast_ref()] { + widget.set_tooltip_text(tooltip(source)); + } + grid.attach(&name, 0, row, 1, 1); + grid.attach(&text, 1, row, 1, 1); +} + +pub fn field_grid() -> gtk::Grid { + let grid = gtk::Grid::new(); + grid.set_column_spacing(12); + grid.set_row_spacing(6); + grid.set_margin_top(6); + grid.set_margin_bottom(6); + grid.set_margin_start(8); + grid.set_margin_end(8); + grid +} + +/// Report groups as titled frames, in one scrollable column. +pub fn groups_box(groups: &[ReportGroup]) -> gtk::Box { + let column = gtk::Box::new(gtk::Orientation::Vertical, 8); + column.set_margin_top(8); + column.set_margin_bottom(8); + column.set_margin_start(8); + column.set_margin_end(8); + for group in groups { + let frame = gtk::Frame::new(Some(&group.title)); + let grid = field_grid(); + for (row, field) in group.fields.iter().enumerate() { + add_field(&grid, row as i32, &field.caption, &field.value, &field.source); + } + frame.set_child(Some(&grid)); + column.append(&frame); + } + column +} + +/// Storage-style fields in a plain grid. +pub fn storage_fields_grid(fields: &[StorageDisplayField]) -> gtk::Grid { + let grid = field_grid(); + for (row, field) in fields.iter().enumerate() { + add_field(&grid, row as i32, &field.label, &field.value, &field.source); + } + grid +} + +pub fn scrolled(child: &impl IsA) -> gtk::ScrolledWindow { + gtk::ScrolledWindow::builder().child(child).hexpand(true).vexpand(true).build() +} + +pub fn copy_to_clipboard(widget: &impl IsA, text: &str) { + widget.display().clipboard().set_text(text); +} + +pub fn copy_button(label: &str, text: impl Fn() -> String + 'static) -> gtk::Button { + let button = gtk::Button::with_label(label); + button.connect_clicked(move |b| copy_to_clipboard(b, &text())); + button +} + +pub fn notice(text: &str, tip: &str) -> gtk::Label { + let label = plain_label(text); + label.set_margin_start(8); + label.set_margin_end(8); + label.set_margin_top(6); + label.set_tooltip_text(tooltip(tip)); + label +} + +mod cells_imp { + use super::*; + + #[derive(Default)] + pub struct CellRow { + pub cells: RefCell>, + pub tooltip: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for CellRow { + const NAME: &'static str = "QldmCellRow"; + type Type = super::CellRow; + } + + impl ObjectImpl for CellRow {} +} + +glib::wrapper! { + /// One table row of text cells. + pub struct CellRow(ObjectSubclass); +} + +impl CellRow { + pub fn new(cells: Vec, tooltip: &str) -> Self { + let row: Self = glib::Object::new(); + *row.imp().cells.borrow_mut() = cells; + *row.imp().tooltip.borrow_mut() = tooltip.to_string(); + row + } + + pub fn cell(&self, column: usize) -> String { + self.imp().cells.borrow().get(column).cloned().unwrap_or_default() + } + + pub fn cells(&self) -> Vec { + self.imp().cells.borrow().clone() + } + + pub fn tooltip(&self) -> String { + self.imp().tooltip.borrow().clone() + } +} + +/// A read-only text table with resizable columns. +pub struct TextTable { + pub view: gtk::ColumnView, + pub store: gtk::gio::ListStore, +} + +impl TextTable { + /// `right_aligned` lists columns whose text is right aligned (numbers). + pub fn new(headers: &[String], multi: bool, right_aligned: &[usize]) -> (Self, gtk::SelectionModel) { + let store = gtk::gio::ListStore::new::(); + let selection: gtk::SelectionModel = if multi { + gtk::MultiSelection::new(Some(store.clone())).upcast() + } else { + let single = gtk::SingleSelection::new(Some(store.clone())); + single.set_autoselect(false); + single.set_can_unselect(true); + single.upcast() + }; + let view = gtk::ColumnView::new(Some(selection.clone())); + view.set_show_row_separators(true); + view.set_reorderable(false); + for (column, header) in headers.iter().enumerate() { + let factory = gtk::SignalListItemFactory::new(); + let right = right_aligned.contains(&column); + factory.connect_setup(move |_, item| { + let item = item.downcast_ref::().expect("ListItem"); + let label = gtk::Label::new(None); + label.set_xalign(if right { 1.0 } else { 0.0 }); + label.set_yalign(0.0); + label.set_wrap(true); + label.set_wrap_mode(gtk::pango::WrapMode::WordChar); + label.set_margin_start(4); + label.set_margin_end(4); + item.set_child(Some(&label)); + }); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().expect("ListItem"); + let (Some(row), Some(label)) = (item.item().and_downcast::(), item.child().and_downcast::()) else { return }; + label.set_text(&row.cell(column)); + let tip = row.tooltip(); + label.set_tooltip_text(tooltip(&tip)); + }); + let col = gtk::ColumnViewColumn::new(Some(header), Some(factory)); + col.set_resizable(true); + col.set_expand(column + 1 == headers.len()); + view.append_column(&col); + } + (Self { view, store }, selection) + } + + pub fn set_rows(&self, rows: Vec) { + self.store.remove_all(); + self.store.extend_from_slice(&rows); + } +} diff --git a/crates/qldm-gtk/src/row.rs b/crates/qldm-gtk/src/row.rs new file mode 100644 index 0000000..4478347 --- /dev/null +++ b/crates/qldm-gtk/src/row.rs @@ -0,0 +1,89 @@ +//! GObject row used as the item type of the device tree model. A row mirrors one +//! [`qldm_core::tree::TreeNode`]: the node identity (key, path, generation) is what selection and +//! expansion are restored by, never a row position. +use gtk::glib; +use gtk::subclass::prelude::*; +use qldm_core::tree::TreeNode; +use std::cell::{Cell, RefCell}; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct Row { + pub label: RefCell, + pub icon: RefCell, + pub key: RefCell, + pub path: RefCell, + pub owner_path: RefCell, + pub tooltip: RefCell, + pub generation: Cell, + pub context: Cell, + pub children: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for Row { + const NAME: &'static str = "QldmRow"; + type Type = super::Row; + } + + impl ObjectImpl for Row {} +} + +glib::wrapper! { + pub struct Row(ObjectSubclass); +} + +impl Row { + /// Builds the row (and, recursively, its children) for a tree node; `tooltip` renders the + /// tooltip text of a node. + pub fn from_node(node: &TreeNode, tooltip: &dyn Fn(&TreeNode) -> String) -> Self { + let row: Self = glib::Object::new(); + let imp = row.imp(); + *imp.label.borrow_mut() = node.label.clone(); + *imp.icon.borrow_mut() = if node.key == "computer" { "computer".into() } else { qldm_core::model::category(&node.category).icon.into() }; + *imp.key.borrow_mut() = node.key.clone(); + *imp.path.borrow_mut() = node.path.clone(); + *imp.owner_path.borrow_mut() = node.owner_path.clone(); + *imp.tooltip.borrow_mut() = tooltip(node); + imp.generation.set(node.generation); + imp.context.set(node.context); + *imp.children.borrow_mut() = node.children.iter().map(|c| Row::from_node(c, tooltip)).collect(); + row + } + + pub fn label(&self) -> String { + self.imp().label.borrow().clone() + } + pub fn icon(&self) -> String { + self.imp().icon.borrow().clone() + } + pub fn key(&self) -> String { + self.imp().key.borrow().clone() + } + pub fn path(&self) -> String { + self.imp().path.borrow().clone() + } + pub fn owner_path(&self) -> String { + self.imp().owner_path.borrow().clone() + } + pub fn tooltip(&self) -> String { + self.imp().tooltip.borrow().clone() + } + pub fn generation(&self) -> u64 { + self.imp().generation.get() + } + pub fn is_context(&self) -> bool { + self.imp().context.get() + } + pub fn children(&self) -> Vec { + self.imp().children.borrow().clone() + } + + /// The device this row acts on (a resource row acts on its owner), with its generation. + pub fn target(&self) -> Option<(String, u64)> { + let path = if self.path().is_empty() { self.owner_path() } else { self.path() }; + (!path.is_empty()).then(|| (path, self.generation())) + } +} diff --git a/crates/qldm-gtk/src/settings.rs b/crates/qldm-gtk/src/settings.rs new file mode 100644 index 0000000..13d1e91 --- /dev/null +++ b/crates/qldm-gtk/src/settings.rs @@ -0,0 +1,81 @@ +//! Window settings, kept in `$XDG_CONFIG_HOME/QLinuxDeviceManager/settings.conf` as plain +//! `key = value` lines (no schema installation needed, so the binary also runs from a build +//! tree). Only the window geometry persists: the tree always starts on "Devices by type" with +//! default visibility and only the computer expanded. A missing, unreadable or corrupt file is +//! ignored and defaults apply; saving failures are silent. +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WindowSettings { + pub width: i32, + pub height: i32, + pub maximized: bool, +} + +impl Default for WindowSettings { + fn default() -> Self { + Self { width: 850, height: 650, maximized: false } + } +} + +fn config_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; + Some(base.join("QLinuxDeviceManager").join("settings.conf")) +} + +pub fn parse(text: &str) -> WindowSettings { + let mut settings = WindowSettings::default(); + for line in text.lines() { + let Some((key, value)) = line.split_once('=') else { continue }; + let (key, value) = (key.trim(), value.trim()); + match key { + "window.width" => { + if let Some(n) = value.parse::().ok().filter(|n| (200..=16384).contains(n)) { + settings.width = n; + } + } + "window.height" => { + if let Some(n) = value.parse::().ok().filter(|n| (200..=16384).contains(n)) { + settings.height = n; + } + } + "window.maximized" => settings.maximized = value == "true", + _ => {} + } + } + settings +} + +pub fn serialize(settings: &WindowSettings) -> String { + format!("window.width = {}\nwindow.height = {}\nwindow.maximized = {}\n", settings.width, settings.height, settings.maximized) +} + +pub fn load() -> WindowSettings { + config_path().and_then(|p| std::fs::read_to_string(p).ok()).map(|t| parse(&t)).unwrap_or_default() +} + +pub fn save(settings: &WindowSettings) { + let Some(path) = config_path() else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, serialize(settings)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_round_trip_and_ignore_garbage() { + let saved = WindowSettings { width: 1024, height: 768, maximized: true }; + assert_eq!(parse(&serialize(&saved)), saved); + assert_eq!(parse(""), WindowSettings::default()); + assert_eq!(parse("\u{0}\u{1}garbage\n=\nwindow.width = lots\nwindow.height = 5\n"), WindowSettings::default()); + assert_eq!(parse("window.width = 900\nunknown = 1\n").width, 900); + assert_eq!(parse("window.width = 99999999\n").width, 850, "absurd sizes are ignored"); + } +} diff --git a/crates/qldm-gtk/src/system_dialog.rs b/crates/qldm-gtk/src/system_dialog.rs new file mode 100644 index 0000000..44e4b6b --- /dev/null +++ b/crates/qldm-gtk/src/system_dialog.rs @@ -0,0 +1,194 @@ +//! The System Information window, ported from `src/ui/systemdialog.cpp`. What each field says +//! comes from `qldm_core::system_report`; this module lays it out in four tabs (Operating System, +//! Hardware with the MOK certificates, Processor, RAM/Memory with the firmware records), with +//! source tooltips, Copy all and Refresh. +use crate::properties::widgets::{add_field, copy_to_clipboard, field_grid, plain_label, scrolled}; +use gtk::prelude::*; +use qldm_core::i18n::tr; +use qldm_core::system::SystemProperties; +use qldm_core::system_report::{copy_all, display_value, field_source, memory_blocks, mok_blocks, mok_scope, sections, MemoryBlock, MokBlock}; +use std::cell::RefCell; +use std::rc::Rc; + +pub struct SystemDialog { + window: gtk::Window, + notebook: gtk::Notebook, + refresh: gtk::Button, + copy: gtk::Button, + snapshot: RefCell>, + /// Value label text by field key, for the test hooks. + texts: RefCell>, +} + +fn heading(text: &str) -> gtk::Label { + let label = plain_label(text); + label.add_css_class("heading"); + label.set_margin_top(6); + label +} + +impl SystemDialog { + pub fn new(parent: &impl IsA, on_refresh: Box) -> Rc { + let window = gtk::Window::builder().transient_for(parent).title(tr("System Information")).default_width(680).default_height(650).build(); + let notebook = gtk::Notebook::new(); + notebook.set_scrollable(true); + notebook.set_vexpand(true); + let copy = gtk::Button::with_label(&tr("Copy all")); + copy.set_sensitive(false); + let refresh = gtk::Button::with_label(&tr("Refresh")); + let close = gtk::Button::with_label(&tr("Close")); + let buttons = gtk::Box::new(gtk::Orientation::Horizontal, 6); + buttons.set_halign(gtk::Align::End); + buttons.set_margin_top(6); + buttons.set_margin_bottom(6); + buttons.set_margin_end(8); + for b in [©, &refresh, &close] { + buttons.append(b); + } + let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); + layout.append(¬ebook); + layout.append(&buttons); + window.set_child(Some(&layout)); + let this = Rc::new(Self { + window: window.clone(), + notebook, + refresh: refresh.clone(), + copy: copy.clone(), + snapshot: RefCell::new(None), + texts: RefCell::new(Vec::new()), + }); + refresh.connect_clicked(move |_| on_refresh()); + close.connect_clicked(move |_| window.close()); + let weak = Rc::downgrade(&this); + copy.connect_clicked(move |b| { + if let Some(dialog) = weak.upgrade() { + if let Some(snapshot) = dialog.snapshot.borrow().as_ref() { + copy_to_clipboard(b, ©_all(snapshot)); + } + } + }); + this.rebuild(); + this + } + + pub fn window(&self) -> >k::Window { + &self.window + } + + pub fn present(&self) { + self.window.present(); + } + + pub fn set_busy(&self, busy: bool) { + self.refresh.set_sensitive(!busy); + self.refresh.set_label(&if busy { tr("Refreshing…") } else { tr("Refresh") }); + } + + pub fn accept_result(&self, result: SystemProperties) { + *self.snapshot.borrow_mut() = Some(result); + self.rebuild(); + self.set_busy(false); + self.copy.set_sensitive(true); + } + + pub fn is_loaded(&self) -> bool { + self.snapshot.borrow().is_some() + } + + pub fn tab_titles(&self) -> Vec { + (0..self.notebook.n_pages()) + .filter_map(|n| self.notebook.nth_page(Some(n))) + .filter_map(|p| self.notebook.tab_label_text(&p).map(|t| t.to_string())) + .collect() + } + + /// The text shown for a field key (test hook). + pub fn field_text(&self, key: &str) -> Option { + self.texts.borrow().iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) + } + + fn rebuild(&self) { + let current = self.notebook.current_page(); + while self.notebook.n_pages() > 0 { + self.notebook.remove_page(Some(0)); + } + self.texts.borrow_mut().clear(); + let snapshot = self.snapshot.borrow(); + for section in sections() { + let page = gtk::Box::new(gtk::Orientation::Vertical, 0); + let grid = field_grid(); + let mut row = 0; + for field in §ion.fields { + let (text, source) = match snapshot.as_ref() { + Some(s) => (display_value(s, field.key, &s.values.get(field.key).cloned().unwrap_or_default()), field_source(s, field.key)), + None => (tr("Not collected"), String::new()), + }; + add_field(&grid, row, &field.caption, &text, &source); + self.texts.borrow_mut().push((field.key.to_string(), text)); + row += 1; + if field.key == "os" { + grid.attach(>k::Separator::new(gtk::Orientation::Horizontal), 0, row, 2, 1); + row += 1; + } + } + page.append(&grid); + if let Some(s) = snapshot.as_ref() { + if section.fields.first().is_some_and(|f| f.key == "manufacturer") { + self.append_mok(&page, s); + } + if section.fields.first().is_some_and(|f| f.key == "physical_memory") { + self.append_memory(&page, s); + } + } + self.notebook.append_page(&scrolled(&page), Some(>k::Label::new(Some(§ion.title)))); + } + if let Some(n) = current { + self.notebook.set_current_page(Some(n)); + } + } + + fn append_mok(&self, page: >k::Box, snapshot: &SystemProperties) { + let scope = mok_scope(snapshot); + for block in mok_blocks(snapshot) { + match block { + MokBlock::Message(text) => { + let label = plain_label(&text); + label.set_tooltip_text(Some(&scope)); + label.set_margin_start(8); + page.append(&label); + self.texts.borrow_mut().push(("mok".into(), text)); + } + MokBlock::Certificate { title, fields } => { + let frame = gtk::Frame::new(Some(&title)); + frame.set_margin_start(8); + frame.set_margin_end(8); + let grid = field_grid(); + for (row, (caption, text, details)) in fields.iter().enumerate() { + add_field(&grid, row as i32, caption.trim_end_matches(':'), text, &format!("{scope}\n\n{details}")); + } + frame.set_child(Some(&grid)); + page.append(&frame); + self.texts.borrow_mut().push(("mok".into(), fields.iter().map(|f| f.1.clone()).collect::>().join(" | "))); + } + } + } + } + + fn append_memory(&self, page: >k::Box, snapshot: &SystemProperties) { + let grid = field_grid(); + for (row, block) in memory_blocks(snapshot).into_iter().enumerate() { + let row = row as i32; + match block { + MemoryBlock::Message { text, tooltip } => { + let label = plain_label(&text); + label.set_tooltip_text((!tooltip.is_empty()).then_some(tooltip.as_str())); + grid.attach(&label, 0, row, 2, 1); + self.texts.borrow_mut().push(("memory_devices".into(), text)); + } + MemoryBlock::Heading(text) => grid.attach(&heading(&text), 0, row, 2, 1), + MemoryBlock::Field { caption, value, tooltip } => add_field(&grid, row, &caption, &value, &tooltip), + } + } + page.append(&grid); + } +} diff --git a/crates/qldm-gtk/src/window.rs b/crates/qldm-gtk/src/window.rs new file mode 100644 index 0000000..344e02f --- /dev/null +++ b/crates/qldm-gtk/src/window.rs @@ -0,0 +1,1368 @@ +//! The main window: menus, toolbar, filter bar, device tree and status line, ported from +//! `src/ui/mainwindow.cpp`. The tree content comes from [`qldm_core::tree`]; this module owns +//! only presentation and the glue to the background workers. +//! +//! Rules carried over from the C++ window: +//! * selection and expansion are restored by node key, device path and generation, so a replaced +//! device is never silently replaced by a neighbour; +//! * refreshes that change nothing leave the tree untouched; +//! * a deep search is cancelled and restarted whenever the inventory or filter changes; +//! * closing waits (without blocking the UI) for the enumeration worker to stop. +use crate::properties::PropertiesDialog; +use crate::row::Row; +use crate::settings::{self, WindowSettings}; +use crate::system_dialog::SystemDialog; +use gtk::prelude::*; +use gtk::{gio, glib}; +use qldm_core::events::DeviceEventsSnapshot; +use qldm_core::i18n::{arg, arg2, tr}; +use qldm_core::model::Inventory; +use qldm_core::monitor::Session; +use qldm_core::properties::collect_device_properties; +use qldm_core::search::{DeepSearchWorker, SearchBatch}; +use qldm_core::system::collect_system_properties; +use qldm_core::tree::{visible_device_count, Filter, TreeModel, TreeNode, View}; +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use std::rc::{Rc, Weak}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// What was selected in the unfiltered tree, by identity rather than position. +#[derive(Clone, Default)] +struct Selected { + key: String, + path: String, + generation: u64, +} + +struct State { + model: TreeModel, + filter: Filter, + /// The (filtered) tree currently shown. + shown: Option, + /// `:` of nodes the user expanded. + expanded: HashSet, + selected: Selected, + scan_note: String, + busy: bool, + requested: u64, + initializing: bool, + closing: bool, + events: DeviceEventsSnapshot, + search_revision: u64, + search_request: u64, + search_pending: bool, + search_busy: bool, + search_done: usize, + search_total: usize, + search_unavailable: usize, + search_cancel: Arc, + search_worker: Option, + filter_timer: Option, +} + +/// Bookkeeping of the single open Properties window and its serialized property reads. +#[derive(Default)] +struct PropsState { + dialog: Option>, + request: u64, + busy: bool, + pending: Option, + cancel: Option>, +} + +/// The one System Information window, retained (even when closed) so results never target a +/// destroyed window, plus its single in-flight read. +#[derive(Default)] +struct SystemState { + dialog: Option>, + busy: bool, + cancel: Option>, +} + +enum SearchEvent { + Batch(SearchBatch), + Finished(DeepSearchWorker), +} + +pub struct Ui { + window: gtk::ApplicationWindow, + list: gtk::ListView, + store: gio::ListStore, + tree_model: gtk::TreeListModel, + selection: gtk::SingleSelection, + filter_bar: gtk::Box, + filter_entry: gtk::SearchEntry, + deep_check: gtk::CheckButton, + status: gtk::Label, + progress: gtk::ProgressBar, + properties_action: gio::SimpleAction, + filter_action: gio::SimpleAction, + internal_action: gio::SimpleAction, + view_action: gio::SimpleAction, + session: RefCell>, + state: RefCell, + props: RefCell, + system: RefCell, + /// Set while the window itself changes selection or expansion, so handlers stay quiet. + quiet: Cell, +} + +thread_local! { + /// The live window state. Callbacks only hold weak references, so something must own it for + /// as long as the window exists; it is released when the window is destroyed. + static LIVE: RefCell>> = const { RefCell::new(Vec::new()) }; +} + +type Handler = Box)>; + +fn action(name: &str) -> String { + format!("win.{name}") +} + +fn menu_item(label: &str, name: &str) -> gio::MenuItem { + gio::MenuItem::new(Some(label), Some(&action(name))) +} + +fn build_menu() -> gio::Menu { + let bar = gio::Menu::new(); + let file = gio::Menu::new(); + file.append_item(&menu_item(&tr("System _Information"), "system-info")); + let quit = gio::Menu::new(); + quit.append_item(&menu_item(&tr("_Quit"), "quit")); + file.append_section(None, &quit); + bar.append_submenu(Some(&tr("_File")), &file); + + let act = gio::Menu::new(); + act.append_item(&menu_item(&tr("_Refresh"), "refresh")); + act.append_item(&menu_item(&tr("_Properties"), "properties")); + bar.append_submenu(Some(&tr("_Action")), &act); + + let view = gio::Menu::new(); + let views = gio::Menu::new(); + for v in View::ALL { + let item = gio::MenuItem::new(Some(&v.label()), None); + item.set_action_and_target_value(Some(&action("view")), Some(&v.id().to_variant())); + views.append_item(&item); + } + view.append_section(None, &views); + let toggles = gio::Menu::new(); + toggles.append_item(&menu_item(&tr("_Filter"), "filter")); + toggles.append_item(&menu_item(&tr("Show _all devices"), "show-all")); + view.append_section(None, &toggles); + let expand = gio::Menu::new(); + expand.append_item(&menu_item(&tr("Expand all"), "expand-all")); + expand.append_item(&menu_item(&tr("Collapse all"), "collapse-all")); + view.append_section(None, &expand); + bar.append_submenu(Some(&tr("_View")), &view); + + let help = gio::Menu::new(); + help.append_item(&menu_item(&tr("_About"), "about")); + bar.append_submenu(Some(&tr("_Help")), &help); + bar +} + +fn trimmed_text(entry: >k::SearchEntry) -> String { + entry.text().trim().to_string() +} + +impl Ui { + pub fn build(app: >k::Application) -> Rc { + let saved = settings::load(); + let store = gio::ListStore::new::(); + let tree_model = gtk::TreeListModel::new(store.clone(), false, false, |item| { + let row = item.downcast_ref::()?; + let children = row.children(); + if children.is_empty() { + return None; + } + let children_store = gio::ListStore::new::(); + children_store.extend_from_slice(&children); + Some(children_store.upcast()) + }); + let selection = gtk::SingleSelection::new(Some(tree_model.clone())); + selection.set_autoselect(false); + selection.set_can_unselect(true); + + let factory = gtk::SignalListItemFactory::new(); + let list = gtk::ListView::new(Some(selection.clone()), Some(factory.clone())); + list.set_vexpand(true); + list.set_single_click_activate(false); + + let filter_entry = gtk::SearchEntry::builder().hexpand(true).placeholder_text(tr("Device name")).build(); + let deep_check = gtk::CheckButton::with_label(&tr("Deep search")); + deep_check.set_tooltip_text(Some(&tr( + "Search device identifiers, generic properties, resources and native storage metadata. Specialized hardware-tab information is not fully indexed. Refresh updates cached metadata.", + ))); + let filter_label = gtk::Label::with_mnemonic(&tr("_Filter:")); + filter_label.set_mnemonic_widget(Some(&filter_entry)); + let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 6); + filter_bar.set_margin_top(6); + filter_bar.set_margin_bottom(6); + filter_bar.set_margin_start(6); + filter_bar.set_margin_end(6); + filter_bar.append(&filter_label); + filter_bar.append(&filter_entry); + filter_bar.append(&deep_check); + filter_bar.set_visible(false); + + let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 2); + toolbar.set_margin_start(4); + toolbar.set_margin_end(4); + for (icon, name, tip) in [ + ("dialog-information-symbolic", "system-info", tr("Show System Information")), + ("view-refresh-symbolic", "refresh", tr("Re-enumerate existing kernel devices; does not rescan buses.")), + ("document-properties-symbolic", "properties", tr("Properties")), + ] { + let button = gtk::Button::from_icon_name(icon); + button.set_action_name(Some(&action(name))); + button.set_tooltip_text(Some(&tip)); + button.add_css_class("flat"); + toolbar.append(&button); + } + + let status = gtk::Label::builder() + .xalign(0.0) + .hexpand(true) + .ellipsize(gtk::pango::EllipsizeMode::End) + .margin_start(6) + .margin_end(6) + .margin_top(3) + .margin_bottom(3) + .build(); + let progress = gtk::ProgressBar::new(); + progress.set_valign(gtk::Align::Center); + progress.set_size_request(120, -1); + progress.set_margin_end(6); + let status_bar = gtk::Box::new(gtk::Orientation::Horizontal, 0); + status_bar.append(&status); + status_bar.append(&progress); + + let menu_bar = gtk::PopoverMenuBar::from_model(Some(&build_menu())); + let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); + layout.append(&menu_bar); + layout.append(&toolbar); + layout.append(>k::Separator::new(gtk::Orientation::Horizontal)); + layout.append(&filter_bar); + layout.append(>k::ScrolledWindow::builder().child(&list).vexpand(true).build()); + layout.append(>k::Separator::new(gtk::Orientation::Horizontal)); + layout.append(&status_bar); + + let mut title = tr("QLinuxDeviceManager"); + // SAFETY: geteuid has no preconditions. + if unsafe { libc::geteuid() } == 0 { + title.push_str(&tr(" — Administrative mode")); + } + let window = + gtk::ApplicationWindow::builder().application(app).title(title).default_width(saved.width).default_height(saved.height).child(&layout).build(); + if saved.maximized { + window.maximize(); + } + + let properties_action = gio::SimpleAction::new("properties", None); + properties_action.set_enabled(false); + let filter_action = gio::SimpleAction::new_stateful("filter", None, &false.to_variant()); + let internal_action = gio::SimpleAction::new_stateful("show-all", None, &false.to_variant()); + let view_action = gio::SimpleAction::new_stateful("view", Some(glib::VariantTy::STRING), &View::Type.id().to_variant()); + + let state = State { + model: TreeModel::default(), + filter: Filter::default(), + shown: None, + expanded: View::ALL.iter().map(|v| format!("{}:computer", v.id())).collect(), + selected: Selected::default(), + scan_note: String::new(), + busy: false, + requested: 0, + initializing: true, + closing: false, + events: DeviceEventsSnapshot::default(), + search_revision: 1, + search_request: 0, + search_pending: false, + search_busy: false, + search_done: 0, + search_total: 0, + search_unavailable: 0, + search_cancel: Arc::new(AtomicBool::new(false)), + search_worker: Some(DeepSearchWorker::new()), + filter_timer: None, + }; + let ui = Rc::new(Ui { + window, + list, + store, + tree_model, + selection, + filter_bar, + filter_entry, + deep_check, + status, + progress, + properties_action, + filter_action, + internal_action, + view_action, + session: RefCell::new(None), + state: RefCell::new(state), + props: RefCell::new(PropsState::default()), + system: RefCell::new(SystemState::default()), + quiet: Cell::new(false), + }); + LIVE.with(|live| live.borrow_mut().push(ui.clone())); + let released = Rc::downgrade(&ui); + ui.window.connect_destroy(move |_| { + if let Some(ui) = released.upgrade() { + LIVE.with(|live| live.borrow_mut().retain(|u| !Rc::ptr_eq(u, &ui))); + } + }); + ui.wire_factory(&factory); + ui.wire_actions(app); + ui.wire_signals(); + ui.start_session(); + ui.update_status(); + ui.window.present(); + ui + } + + fn weak(self: &Rc) -> Weak { + Rc::downgrade(self) + } + + // ---- rows ---- + + fn wire_factory(self: &Rc, factory: >k::SignalListItemFactory) { + let weak = self.weak(); + factory.connect_setup(move |_, item| { + let item = item.downcast_ref::().expect("ListItem"); + let content = gtk::Box::new(gtk::Orientation::Horizontal, 6); + content.append(>k::Image::new()); + let label = gtk::Label::new(None); + label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + content.append(&label); + let expander = gtk::TreeExpander::new(); + expander.set_child(Some(&content)); + // Right click: select the row under the pointer and offer Properties. + let gesture = gtk::GestureClick::builder().button(3).build(); + let (weak_ui, weak_item) = (weak.clone(), item.downgrade()); + gesture.connect_pressed(move |gesture, _, x, y| { + let (Some(ui), Some(item)) = (weak_ui.upgrade(), weak_item.upgrade()) else { return }; + let Some(widget) = gesture.widget() else { return }; + ui.context_menu(item.position(), &widget, x, y); + }); + expander.add_controller(gesture); + item.set_child(Some(&expander)); + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().expect("ListItem"); + let Some(list_row) = item.item().and_downcast::() else { return }; + let Some(row) = list_row.item().and_downcast::() else { return }; + let expander = item.child().and_downcast::().expect("TreeExpander"); + expander.set_list_row(Some(&list_row)); + expander.set_tooltip_text(Some(&row.tooltip())); + let content = expander.child().and_downcast::().expect("Box"); + if let Some(image) = content.first_child().and_downcast::() { + let theme = gtk::IconTheme::for_display(&image.display()); + let icon = row.icon(); + image.set_icon_name(Some(if theme.has_icon(&icon) { &icon } else { "applications-system-symbolic" })); + } + if let Some(label) = content.last_child().and_downcast::() { + label.set_text(&row.label()); + // Ancestors kept only for connection context read as secondary. + if row.is_context() { + label.add_css_class("dim-label"); + } else { + label.remove_css_class("dim-label"); + } + } + }); + } + + fn context_menu(self: &Rc, position: u32, anchor: >k::Widget, x: f64, y: f64) { + let Some(row) = self.row_at(position) else { return }; + if !Self::has_properties(&row) { + return; + } + self.quiet.set(true); + self.selection.set_selected(position); + self.quiet.set(false); + self.update_status(); + let menu = gio::Menu::new(); + menu.append_item(&menu_item(&tr("_Properties"), "properties")); + let popover = gtk::PopoverMenu::from_model(Some(&menu)); + popover.set_parent(anchor); + popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover.set_has_arrow(false); + popover.connect_closed(|p| { + let p = p.clone(); + glib::idle_add_local_once(move || p.unparent()); + }); + popover.popup(); + } + + fn row_at(&self, position: u32) -> Option { + self.tree_model.row(position)?.item().and_downcast::() + } + + fn selected_row(&self) -> Option { + let position = self.selection.selected(); + (position != gtk::INVALID_LIST_POSITION).then(|| self.row_at(position)).flatten() + } + + fn has_properties(row: &Row) -> bool { + row.key() == "computer" || row.target().is_some() + } + + // ---- actions and signals ---- + + fn wire_actions(self: &Rc, app: >k::Application) { + let window = &self.window; + let simple = |name: &str, handler: Handler| { + let a = gio::SimpleAction::new(name, None); + let weak = self.weak(); + a.connect_activate(move |_, _| { + if let Some(ui) = weak.upgrade() { + handler(&ui); + } + }); + window.add_action(&a); + }; + simple("refresh", Box::new(|ui| ui.refresh())); + simple("system-info", Box::new(|ui| ui.open_system_information())); + simple("quit", Box::new(|ui| ui.window.close())); + simple("expand-all", Box::new(|ui| ui.set_all_expanded(true))); + simple("collapse-all", Box::new(|ui| ui.set_all_expanded(false))); + simple("about", Box::new(|ui| ui.show_about())); + window.add_action(&self.properties_action); + let weak = self.weak(); + self.properties_action.connect_activate(move |_, _| { + if let Some(ui) = weak.upgrade() { + ui.open_properties(); + } + }); + + window.add_action(&self.filter_action); + let weak = self.weak(); + self.filter_action.connect_activate(move |a, _| { + let on = a.state().and_then(|s| s.get::()).unwrap_or(false); + a.change_state(&(!on).to_variant()); + }); + let weak2 = self.weak(); + self.filter_action.connect_change_state(move |a, value| { + let Some(ui) = weak2.upgrade() else { return }; + let show = value.and_then(|v| v.get::()).unwrap_or(false); + a.set_state(&show.to_variant()); + ui.filter_bar.set_visible(show); + if show { + ui.filter_entry.grab_focus(); + ui.filter_entry.select_region(0, -1); + } else { + ui.quiet.set(true); + ui.filter_entry.set_text(""); + ui.quiet.set(false); + ui.apply_filter(); + ui.list.grab_focus(); + } + }); + let _ = weak; + + window.add_action(&self.internal_action); + self.internal_action.connect_activate(|a, _| { + let on = a.state().and_then(|s| s.get::()).unwrap_or(false); + a.change_state(&(!on).to_variant()); + }); + let weak = self.weak(); + self.internal_action.connect_change_state(move |a, value| { + let Some(ui) = weak.upgrade() else { return }; + let show = value.and_then(|v| v.get::()).unwrap_or(false); + a.set_state(&show.to_variant()); + ui.remember_tree(); + ui.cancel_search(); + ui.state.borrow_mut().model.set_show_internal(show); + ui.state.borrow_mut().search_revision += 1; + ui.apply_filter(); + }); + + window.add_action(&self.view_action); + let weak = self.weak(); + self.view_action.connect_activate(move |a, parameter| { + let Some(ui) = weak.upgrade() else { return }; + let Some(id) = parameter.and_then(|p| p.get::()) else { return }; + let Some(view) = View::ALL.iter().copied().find(|v| v.id() == id) else { return }; + a.set_state(&id.to_variant()); + ui.remember_tree(); + ui.cancel_search(); + ui.state.borrow_mut().model.set_view(view); + ui.state.borrow_mut().search_revision += 1; + ui.apply_filter(); + }); + + app.set_accels_for_action(&action("refresh"), &["F5"]); + app.set_accels_for_action(&action("filter"), &["f"]); + app.set_accels_for_action(&action("system-info"), &["i"]); + app.set_accels_for_action(&action("quit"), &["q"]); + } + + fn wire_signals(self: &Rc) { + let weak = self.weak(); + self.selection.connect_selection_changed(move |_, _, _| { + if let Some(ui) = weak.upgrade() { + if !ui.quiet.get() { + ui.update_status(); + } + } + }); + // Double click (or a keyboard activation not handled below): branches toggle, leaves open + // properties. + let weak = self.weak(); + self.list.connect_activate(move |_, position| { + let Some(ui) = weak.upgrade() else { return }; + let Some(list_row) = ui.tree_model.row(position) else { return }; + if list_row.is_expandable() { + list_row.set_expanded(!list_row.is_expanded()); + } else if ui.properties_action.is_enabled() { + ui.open_properties(); + } + }); + // Enter always opens properties, even on a branch. + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = self.weak(); + keys.connect_key_pressed(move |_, key, _, state| { + let Some(ui) = weak.upgrade() else { return glib::Propagation::Proceed }; + use gtk::gdk::ModifierType as M; + let plain = !state.intersects(M::CONTROL_MASK | M::ALT_MASK | M::SHIFT_MASK | M::SUPER_MASK | M::META_MASK); + if plain && matches!(key, gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter) && ui.list.has_focus() { + if ui.properties_action.is_enabled() { + ui.open_properties(); + } + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::Escape && ui.filter_bar.is_visible() { + ui.filter_action.change_state(&false.to_variant()); + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + self.window.add_controller(keys); + + let weak = self.weak(); + self.filter_entry.connect_search_changed(move |_| { + let Some(ui) = weak.upgrade() else { return }; + if ui.quiet.get() { + return; + } + ui.cancel_search(); + let previous = ui.state.borrow_mut().filter_timer.take(); + if let Some(source) = previous { + source.remove(); + } + let weak = ui.weak(); + let source = glib::timeout_add_local_once(Duration::from_millis(150), move || { + if let Some(ui) = weak.upgrade() { + ui.state.borrow_mut().filter_timer = None; + ui.apply_filter(); + } + }); + ui.state.borrow_mut().filter_timer = Some(source); + }); + let weak = self.weak(); + self.filter_entry.connect_stop_search(move |_| { + if let Some(ui) = weak.upgrade() { + ui.filter_action.change_state(&false.to_variant()); + } + }); + let weak = self.weak(); + self.deep_check.connect_toggled(move |check| { + let Some(ui) = weak.upgrade() else { return }; + ui.filter_entry.set_placeholder_text(Some(&if check.is_active() { tr("All device metadata") } else { tr("Device name") })); + ui.apply_filter(); + }); + let weak = self.weak(); + self.window.connect_close_request(move |_| match weak.upgrade() { + Some(ui) => ui.close_requested(), + None => glib::Propagation::Proceed, + }); + let weak = self.weak(); + glib::timeout_add_local(Duration::from_millis(100), move || match weak.upgrade() { + Some(ui) if ui.state.borrow().initializing && ui.progress.is_visible() => { + ui.progress.pulse(); + glib::ControlFlow::Continue + } + _ => glib::ControlFlow::Break, + }); + } + + // ---- enumeration session ---- + + fn start_session(self: &Rc) { + let (sender, receiver) = async_channel::unbounded::(); + let session = Session::start(move |inventory| { + let _ = sender.send_blocking(inventory); + }); + *self.session.borrow_mut() = Some(session); + let weak = self.weak(); + glib::spawn_future_local(async move { + while let Ok(inventory) = receiver.recv().await { + let Some(ui) = weak.upgrade() else { break }; + ui.accept_inventory(inventory); + } + }); + } + + fn refresh(self: &Rc) { + if self.state.borrow().closing { + return; + } + let requested = self.session.borrow().as_ref().map(|s| s.request_refresh()); + if let Some(requested) = requested { + let mut state = self.state.borrow_mut(); + state.busy = true; + state.requested = requested; + } + self.update_status(); + } + + fn accept_inventory(self: &Rc, inventory: Inventory) { + if let Some(session) = self.session.borrow().as_ref() { + session.acknowledge(); + } + let (manual, closing) = { + let mut state = self.state.borrow_mut(); + if state.closing { + return; + } + state.initializing = false; + state.events = inventory.events.clone(); + let manual = state.busy && inventory.request >= state.requested; + state.busy = inventory.request < state.requested; + (manual, state.closing) + }; + let _ = closing; + self.progress.set_visible(false); // A failed initial scan is a completed attempt too. + // Update before marking a removed dialog so its final removal observation stays in the + // read-only snapshot. Future instances cannot enter that tab. + let dialog = self.props.borrow().dialog.clone(); + if let Some(dialog) = &dialog { + dialog.update_events(&inventory.events, &inventory.monitor_note); + } + if !inventory.error.is_empty() { + if inventory.identity_lost || !inventory.removed_paths.is_empty() { + self.state.borrow_mut().model.set_inventory(Vec::new(), &HashSet::new(), true); + if let Some(dialog) = &dialog { + dialog.reconcile_instance(None); + } + self.invalidate_properties_requests(); + } + self.apply_filter(); + self.status.set_text(&inventory.error); + return; + } + self.remember_tree(); + let changed = self.state.borrow_mut().model.set_inventory(inventory.devices, &inventory.removed_paths, inventory.identity_lost); + if changed || manual { + self.cancel_search(); + self.state.borrow_mut().search_revision += 1; + } + if let Some(dialog) = &dialog { + let current = self.state.borrow().model.device(&dialog.device().path, dialog.device().generation).cloned(); + dialog.reconcile_instance(current.as_ref()); + if dialog.is_removed() { + self.invalidate_properties_requests(); + } + } + let mut note = inventory.monitor_note; + if inventory.skipped > 0 { + if !note.is_empty() { + note.push_str(" — "); + } + note.push_str(&arg(tr("%1 records unavailable or removed during enumeration"), &inventory.skipped.to_string())); + } + self.state.borrow_mut().scan_note = note; + if changed || manual { + self.apply_filter(); + } else { + let start = { + let state = self.state.borrow(); + state.search_pending && !state.search_busy && !state.busy + }; + if start { + self.start_search(); + } + self.update_status(); + } + } + + // ---- tree state ---- + + fn view_prefix(&self) -> String { + format!("{}:", self.state.borrow().model.view().id()) + } + + /// Records expansion and selection of the unfiltered tree before it is rebuilt. + fn remember_tree(&self) { + if self.state.borrow().filter.active() { + return; // Preserve the unfiltered expansion and selection. + } + let prefix = self.view_prefix(); + let mut expanded = Vec::new(); + for position in 0..self.tree_model.n_items() { + let Some(list_row) = self.tree_model.row(position) else { continue }; + let Some(row) = list_row.item().and_downcast::() else { continue }; + if list_row.is_expandable() { + expanded.push((format!("{prefix}{}", row.key()), list_row.is_expanded())); + } + } + let selected = self.selected_row().map(|row| { + let (path, generation) = row.target().unwrap_or_default(); + Selected { key: row.key(), path, generation } + }); + let mut state = self.state.borrow_mut(); + for (id, is_expanded) in expanded { + if is_expanded { + state.expanded.insert(id); + } else { + state.expanded.remove(&id); + } + } + state.selected = selected.unwrap_or_default(); + } + + /// Shows the (filtered) tree and restores expansion and selection by identity. + fn rebuild_view(&self) { + let (shown, expand_all, expanded, selected, prefix) = { + let mut state = self.state.borrow_mut(); + let Some(root) = state.model.root().cloned() else { return }; + let shown = if state.filter.active() { state.filter.apply(&root) } else { root }; + state.shown = Some(shown.clone()); + let prefix = format!("{}:", state.model.view().id()); + (shown, state.filter.active(), state.expanded.clone(), state.selected.clone(), prefix) + }; + self.quiet.set(true); + let tooltip = |node: &TreeNode| self.state.borrow().model.tooltip(node); + let row = Row::from_node(&shown, &tooltip); + self.store.remove_all(); + self.store.append(&row); + // Expanding a row inserts its children after it, so one forward pass reaches them all. + let mut position = 0; + while position < self.tree_model.n_items() { + if let Some(list_row) = self.tree_model.row(position) { + if let Some(row) = list_row.item().and_downcast::() { + if list_row.is_expandable() && (expand_all || expanded.contains(&format!("{prefix}{}", row.key()))) { + list_row.set_expanded(true); + } + } + } + position += 1; + } + self.restore_selection(&shown, &selected); + self.quiet.set(false); + self.update_status(); + } + + fn restore_selection(&self, shown: &TreeNode, selected: &Selected) { + if selected.key.is_empty() && selected.path.is_empty() { + self.selection.set_selected(gtk::INVALID_LIST_POSITION); + return; + } + // Prefer the exact node; fall back to the device's first node (a view change may have + // removed the specific row), and never select a replacement or neighbour. + let current = self.state.borrow().model.generation_of(&selected.path); + let alive = selected.path.is_empty() || current == Some(selected.generation); + let mut trail: Option> = None; + fn find(node: &TreeNode, exact: &dyn Fn(&TreeNode) -> bool, ancestors: &mut Vec) -> Option> { + for child in &node.children { + if exact(child) { + return Some(ancestors.clone()); + } + ancestors.push(child.key.clone()); + let found = find(child, exact, ancestors); + ancestors.pop(); + if found.is_some() { + return found; + } + } + None + } + let target = selected.clone(); + if selected.key == "computer" { + trail = Some(Vec::new()); + } else if alive { + let exact = |n: &TreeNode| n.key == target.key && n.generation == target.generation && (n.path == target.path || n.owner_path == target.path); + let device = |n: &TreeNode| !target.path.is_empty() && n.path == target.path && n.generation == target.generation; + trail = find(shown, &exact, &mut Vec::new()).or_else(|| find(shown, &device, &mut Vec::new())); + } + let Some(ancestors) = trail else { + self.selection.set_selected(gtk::INVALID_LIST_POSITION); + return; + }; + // Make the path to the selection visible, then locate it. + let mut position = 0; + while position < self.tree_model.n_items() { + if let Some(list_row) = self.tree_model.row(position) { + if let Some(row) = list_row.item().and_downcast::() { + if ancestors.contains(&row.key()) && list_row.is_expandable() { + list_row.set_expanded(true); + } + } + } + position += 1; + } + for position in 0..self.tree_model.n_items() { + let Some(row) = self.row_at(position) else { continue }; + let is_target = if selected.key == "computer" { + row.key() == "computer" + } else { + row.key() == selected.key && row.generation() == selected.generation && row.target().is_some_and(|(p, _)| p == selected.path) + || (!row.path().is_empty() && row.path() == selected.path && row.generation() == selected.generation && !self.exact_exists(selected)) + }; + if is_target { + if self.selection.selected() != position { + self.selection.set_selected(position); + self.list.scroll_to(position, gtk::ListScrollFlags::NONE, None); + } + return; + } + } + self.selection.set_selected(gtk::INVALID_LIST_POSITION); + } + + /// Whether the exact node (not just the device) of a remembered selection still exists. + fn exact_exists(&self, selected: &Selected) -> bool { + (0..self.tree_model.n_items()) + .filter_map(|p| self.row_at(p)) + .any(|r| r.key() == selected.key && r.generation() == selected.generation && r.target().is_some_and(|(p, _)| p == selected.path)) + } + + fn set_all_expanded(&self, expand: bool) { + self.quiet.set(true); + if expand { + let mut position = 0; + while position < self.tree_model.n_items() { + if let Some(list_row) = self.tree_model.row(position) { + if list_row.is_expandable() { + list_row.set_expanded(true); + } + } + position += 1; + } + } else { + for position in (0..self.tree_model.n_items()).rev() { + if let Some(list_row) = self.tree_model.row(position) { + if list_row.is_expandable() { + list_row.set_expanded(false); + } + } + } + } + self.quiet.set(false); + self.update_status(); + } + + fn update_status(&self) { + let (closing, initializing, busy) = { + let state = self.state.borrow(); + (state.closing, state.initializing, state.busy) + }; + if closing { + return; + } + let selected = self.selected_row(); + self.properties_action.set_enabled(selected.as_ref().is_some_and(Self::has_properties)); + if initializing || busy { + self.status.set_text(&if initializing { tr("Initializing…") } else { tr("Refreshing devices…") }); + return; + } + let state = self.state.borrow(); + let shown = state.shown.as_ref().map_or(0, visible_device_count); + let mut message = arg2(tr("%1 shown / %2 discovered"), &shown.to_string(), &state.model.inventory_count().to_string()); + if state.filter.active() && self.deep_check.is_active() { + message += &arg2(tr(" — Deep search: %1/%2"), &state.search_done.to_string(), &state.search_total.to_string()); + if state.search_unavailable > 0 { + message += &arg(tr("; %1 property reads unavailable"), &state.search_unavailable.to_string()); + } + let node = selected.as_ref().and_then(|row| { + let path = row.path(); + state.shown.as_ref().and_then(|root| find_node(root, &row.key(), &path, row.generation())) + }); + if let Some(reason) = node.and_then(|n| state.filter.match_reason(n)) { + message += &arg(tr(" — Matched: %1"), &reason); + } + } + if !state.scan_note.is_empty() { + message += &format!(" — {}", state.scan_note); + } + if let Some(row) = &selected { + if !row.path().is_empty() { + message += &format!(" — {}", row.path()); + } + } + self.status.set_text(&message); + } + + // ---- filter and deep search ---- + + fn cancel_search(&self) { + let mut state = self.state.borrow_mut(); + state.search_request += 1; // Reject queued batches from every obsolete request. + state.search_pending = false; + state.search_cancel.store(true, Ordering::Relaxed); + } + + fn apply_filter(self: &Rc) { + let timer = self.state.borrow_mut().filter_timer.take(); + if let Some(source) = timer { + source.remove(); + } + self.cancel_search(); + if self.state.borrow().closing { + return; + } + let query = trimmed_text(&self.filter_entry); + let deep = self.deep_check.is_active(); + if !self.state.borrow().filter.active() && !query.is_empty() { + self.remember_tree(); + } + { + let mut state = self.state.borrow_mut(); + state.filter.set_query(&query, deep); + state.search_done = 0; + state.search_total = 0; + state.search_unavailable = 0; + } + self.rebuild_view(); + if !query.is_empty() && deep { + let start = { + let mut state = self.state.borrow_mut(); + state.search_total = state.model.visible_count(); + state.search_pending = true; + !state.search_busy && !state.busy + }; + if start { + self.start_search(); + } + } + self.update_status(); + } + + fn start_search(self: &Rc) { + let (records, query, revision, request, cancel, mut worker) = { + let mut state = self.state.borrow_mut(); + if state.closing || state.busy || !state.search_pending || state.filter_timer.is_some() { + return; + } + let Some(worker) = state.search_worker.take() else { return }; + state.search_pending = false; + state.search_busy = true; + state.search_cancel = Arc::new(AtomicBool::new(false)); + (state.model.search_records(), trimmed_text(&self.filter_entry), state.search_revision, state.search_request, state.search_cancel.clone(), worker) + }; + let (sender, receiver) = async_channel::unbounded::(); + std::thread::spawn(move || { + let batches = sender.clone(); + worker.run(&records, &query, revision, request, &cancel, move |batch| { + let _ = batches.send_blocking(SearchEvent::Batch(batch)); + }); + let _ = sender.send_blocking(SearchEvent::Finished(worker)); + }); + let weak = self.weak(); + glib::spawn_future_local(async move { + while let Ok(event) = receiver.recv().await { + let Some(ui) = weak.upgrade() else { break }; + match event { + SearchEvent::Batch(batch) => ui.accept_search_batch(batch), + SearchEvent::Finished(worker) => { + ui.search_finished(worker); + break; + } + } + } + }); + } + + fn accept_search_batch(&self, batch: SearchBatch) { + { + let mut state = self.state.borrow_mut(); + if state.closing || batch.request != state.search_request || !state.filter.active() || !self.deep_check.is_active() { + return; + } + state.filter.add_matches(&batch.matches); + state.search_done = batch.done; + state.search_total = batch.total; + state.search_unavailable = batch.unavailable; + } + self.rebuild_view(); + } + + fn search_finished(self: &Rc, worker: DeepSearchWorker) { + let (closing, pending) = { + let mut state = self.state.borrow_mut(); + state.search_worker = Some(worker); + state.search_busy = false; + (state.closing, state.search_pending) + }; + if closing { + self.window.close(); + } else if pending { + self.start_search(); + } + } + + // ---- dialogs (placeholders until the properties/system dialog works) ---- + + fn open_properties(self: &Rc) { + if self.state.borrow().closing { + return; + } + let Some(row) = self.selected_row() else { return }; + if row.key() == "computer" { + self.open_system_information(); + return; + } + let Some((path, generation)) = row.target() else { return }; + let Some(device) = self.state.borrow().model.device(&path, generation).cloned() else { return }; + let existing = self.props.borrow().dialog.clone(); + if let Some(existing) = existing { + if existing.device().path == device.path && existing.device().generation == device.generation { + existing.present(); + return; + } + existing.window().close(); + } + let weak = self.weak(); + let dialog = PropertiesDialog::new( + &self.window, + device, + Box::new(move || { + if let Some(ui) = weak.upgrade() { + ui.request_properties(); + } + }), + ); + { + let state = self.state.borrow(); + dialog.update_events(&state.events, &state.scan_note); + } + let (weak, closed) = (self.weak(), Rc::downgrade(&dialog)); + dialog.window().connect_close_request(move |_| { + if let (Some(ui), Some(closed)) = (weak.upgrade(), closed.upgrade()) { + let mut props = ui.props.borrow_mut(); + if props.dialog.as_ref().is_some_and(|d| Rc::ptr_eq(d, &closed)) { + props.dialog = None; + props.pending = None; + props.request += 1; + if let Some(cancel) = &props.cancel { + cancel.store(true, Ordering::Relaxed); + } + } + } + glib::Propagation::Proceed + }); + self.props.borrow_mut().dialog = Some(dialog.clone()); + dialog.present(); // Modeless: tree Refresh stays available while properties are open. + self.request_properties(); + } + + /// Reads the open dialog's device. Reads are serialized: a request made while one runs + /// cancels it and only the latest pending request survives. + fn request_properties(self: &Rc) { + let dialog = { + let state = self.state.borrow(); + let props = self.props.borrow(); + match &props.dialog { + Some(d) if !state.closing && !d.is_removed() => d.clone(), + _ => return, + } + }; + let busy = { + let mut props = self.props.borrow_mut(); + props.pending = Some(dialog.device().clone()); + props.request += 1; + if props.busy { + if let Some(cancel) = &props.cancel { + cancel.store(true, Ordering::Relaxed); + } + } + props.busy + }; + dialog.set_busy(true); + if !busy { + self.start_properties_read(); + } + } + + fn start_properties_read(self: &Rc) { + let (device, request, cancel) = { + let mut props = self.props.borrow_mut(); + let Some(device) = props.pending.take() else { return }; + props.busy = true; + let cancel = Arc::new(AtomicBool::new(false)); + props.cancel = Some(cancel.clone()); + (device, props.request, cancel) + }; + let (sender, receiver) = async_channel::bounded(1); + std::thread::spawn(move || { + let mut result = collect_device_properties(&device, true, Some(&cancel)); + result.request = request; + let _ = sender.send_blocking(result); + }); + let weak = self.weak(); + glib::spawn_future_local(async move { + let Ok(result) = receiver.recv().await else { return }; + let Some(ui) = weak.upgrade() else { return }; + ui.accept_properties(result); + }); + } + + fn accept_properties(self: &Rc, result: qldm_core::properties::DeviceProperties) { + let (dialog, pending, current, closing) = { + let mut props = self.props.borrow_mut(); + props.busy = false; + (props.dialog.clone(), props.pending.is_some(), props.request, self.state.borrow().closing) + }; + if closing { + self.window.close(); + return; + } + let Some(dialog) = dialog else { + self.props.borrow_mut().pending = None; + return; + }; + if pending && !dialog.is_removed() { + self.start_properties_read(); + return; + } + self.props.borrow_mut().pending = None; + if result.request == current { + dialog.accept_result(result); + } + } + + /// Drops queued and in-flight property reads (the device they were for is gone). + fn invalidate_properties_requests(&self) { + let mut props = self.props.borrow_mut(); + props.pending = None; + props.request += 1; + if let Some(cancel) = &props.cancel { + cancel.store(true, Ordering::Relaxed); + } + } + + fn open_system_information(self: &Rc) { + if self.state.borrow().closing { + return; + } + let existing = self.system.borrow().dialog.clone(); + let dialog = match existing { + Some(dialog) => dialog, + None => { + let weak = self.weak(); + let dialog = SystemDialog::new( + &self.window, + Box::new(move || { + if let Some(ui) = weak.upgrade() { + ui.request_system_information(); + } + }), + ); + // Closing hides it; the dialog is reused with its last snapshot. + dialog.window().set_hide_on_close(true); + self.system.borrow_mut().dialog = Some(dialog.clone()); + self.request_system_information(); + dialog + } + }; + dialog.present(); + } + + fn request_system_information(self: &Rc) { + let dialog = { + let state = self.state.borrow(); + let mut system = self.system.borrow_mut(); + let Some(dialog) = system.dialog.clone() else { return }; + if state.closing || system.busy { + return; + } + system.busy = true; // One request at a time, including its queued delivery. + system.cancel = Some(Arc::new(AtomicBool::new(false))); + dialog + }; + dialog.set_busy(true); + let cancel = self.system.borrow().cancel.clone().expect("just set"); + let (sender, receiver) = async_channel::bounded(1); + std::thread::spawn(move || { + let _ = sender.send_blocking(collect_system_properties(Some(&cancel))); + }); + let weak = self.weak(); + glib::spawn_future_local(async move { + let Ok(result) = receiver.recv().await else { return }; + let Some(ui) = weak.upgrade() else { return }; + ui.system.borrow_mut().busy = false; + if ui.state.borrow().closing { + ui.window.close(); + return; + } + let dialog = ui.system.borrow().dialog.clone(); + if let Some(dialog) = dialog { + dialog.accept_result(result); + } + }); + } + + pub fn system_dialog(&self) -> Option> { + self.system.borrow().dialog.clone() + } + + fn show_about(&self) { + let about = gtk::AboutDialog::builder() + .transient_for(&self.window) + .modal(true) + .program_name(tr("QLinuxDeviceManager")) + .version(qldm_core::app::VERSION) + .website(qldm_core::app::REPOSITORY) + .comments(tr("A read-only Linux hardware viewer.\nCategories are application groupings, not native Linux device classes.\nDevice presence does not establish hardware health.")) + .license_type(gtk::License::Gpl30) + .build(); + about.present(); + } + + // ---- hooks for the UI smoke test ---- + + /// Text of every row currently listed (expanded rows only), with its depth. + pub fn listed_rows(&self) -> Vec<(u32, String)> { + (0..self.tree_model.n_items()) + .filter_map(|p| self.tree_model.row(p)) + .filter_map(|r| r.item().and_downcast::().map(|row| (r.depth(), row.label()))) + .collect() + } + + pub fn status_text(&self) -> String { + self.status.text().to_string() + } + + pub fn is_initializing(&self) -> bool { + self.state.borrow().initializing + } + + pub fn is_busy(&self) -> bool { + let state = self.state.borrow(); + state.busy || state.initializing + } + + /// Types into the filter bar as a user would (the 150 ms debounce still applies). + pub fn set_filter(&self, text: &str, deep: bool) { + self.filter_action.change_state(&true.to_variant()); + self.deep_check.set_active(deep); + self.filter_entry.set_text(text); + } + + pub fn clear_filter(&self) { + self.filter_action.change_state(&false.to_variant()); + } + + pub fn expand_all(&self) { + self.set_all_expanded(true); + } + + pub fn collapse_all(&self) { + self.set_all_expanded(false); + } + + pub fn activate_view(&self, id: &str) { + self.view_action.activate(Some(&id.to_variant())); + } + + pub fn set_show_all(&self, show: bool) { + self.internal_action.change_state(&show.to_variant()); + } + + /// Selects the first listed row whose label contains `text`; returns whether one was found. + pub fn select_label(&self, text: &str) -> bool { + for position in 0..self.tree_model.n_items() { + if self.row_at(position).is_some_and(|r| r.label().contains(text)) { + self.selection.set_selected(position); + self.update_status(); + return true; + } + } + false + } + + pub fn select_root(&self) { + self.selection.set_selected(0); + self.update_status(); + } + + pub fn properties_enabled(&self) -> bool { + self.properties_action.is_enabled() + } + + /// Opens Properties for the selected row, as Enter or the Properties action would. + pub fn open_selected_properties(self: &Rc) { + self.open_properties(); + } + + pub fn properties_dialog(&self) -> Option> { + self.props.borrow().dialog.clone() + } + + pub fn request_refresh(self: &Rc) { + self.refresh(); + } + + pub fn window(&self) -> >k::ApplicationWindow { + &self.window + } + + // ---- shutdown ---- + + fn close_requested(self: &Rc) -> glib::Propagation { + { + let mut state = self.state.borrow_mut(); + if state.closing && self.session.borrow().is_none() { + return glib::Propagation::Proceed; + } + if state.closing { + return glib::Propagation::Stop; // Already waiting for the worker. + } + state.closing = true; + state.search_cancel.store(true, Ordering::Relaxed); + state.search_pending = false; + } + if let Some(cancel) = &self.system.borrow().cancel { + cancel.store(true, Ordering::Relaxed); + } + let open_dialog = self.props.borrow().dialog.clone(); + if let Some(dialog) = open_dialog { + dialog.window().close(); + } + settings::save(&WindowSettings { width: self.window.default_width(), height: self.window.default_height(), maximized: self.window.is_maximized() }); + self.progress.set_visible(false); + let session = self.session.borrow_mut().take(); + let Some(mut session) = session else { return glib::Propagation::Proceed }; + // A kernel metadata read already in progress cannot be cancelled, so the worker is + // joined off the UI thread and the window closes when it is done. + session.request_stop(); + self.window.set_sensitive(false); + self.status.set_text(&tr("Waiting for device metadata reads to finish…")); + let (sender, receiver) = async_channel::bounded::<()>(1); + std::thread::spawn(move || { + session.join(); + let _ = sender.send_blocking(()); + }); + let weak = self.weak(); + glib::spawn_future_local(async move { + let _ = receiver.recv().await; + if let Some(ui) = weak.upgrade() { + ui.window.destroy(); + } + }); + glib::Propagation::Stop + } +} + +fn find_node<'a>(node: &'a TreeNode, key: &str, path: &str, generation: u64) -> Option<&'a TreeNode> { + node.children.iter().find_map(|c| if c.key == key && c.path == path && c.generation == generation { Some(c) } else { find_node(c, key, path, generation) }) +} diff --git a/crates/qldm-gtk/tests/ui_smoke.rs b/crates/qldm-gtk/tests/ui_smoke.rs new file mode 100644 index 0000000..5c78ce9 --- /dev/null +++ b/crates/qldm-gtk/tests/ui_smoke.rs @@ -0,0 +1,212 @@ +//! Drives the real window against this machine's devices. GTK needs the main thread, hence no +//! libtest harness. Skips (exit 0) when no display is available. +use gtk::glib; +use gtk::prelude::*; +use qldm_gtk::window::Ui; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +fn pump_until(what: &str, limit: Duration, mut done: impl FnMut() -> bool) { + let started = Instant::now(); + let context = glib::MainContext::default(); + while !done() { + assert!(started.elapsed() < limit, "timed out waiting for {what}"); + context.iteration(false); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn settle(ms: u64) { + let started = Instant::now(); + let context = glib::MainContext::default(); + while started.elapsed() < Duration::from_millis(ms) { + context.iteration(false); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn check(ok: bool, message: &str) { + if !ok { + eprintln!("FAIL: {message}"); + std::process::exit(1); + } + println!("ok: {message}"); +} + +fn main() { + if gtk::init().is_err() { + println!("skipped: no display"); + return; + } + // Never touch the user's real settings file. + let config = std::env::temp_dir().join(format!("qldm-ui-smoke-{}", std::process::id())); + std::env::set_var("XDG_CONFIG_HOME", &config); + let app = + gtk::Application::builder().application_id("io.github.birdie-github.QLinuxDeviceManager.UiSmoke").flags(gtk::gio::ApplicationFlags::NON_UNIQUE).build(); + app.register(gtk::gio::Cancellable::NONE).expect("register"); + let ui: Rc = Ui::build(&app); + + pump_until("the first inventory", Duration::from_secs(60), || !ui.is_initializing()); + let rows = ui.listed_rows(); + check( + rows[0].0 == 0 && rows.len() > 1 && rows[1..].iter().all(|(d, _)| *d == 1), + "the tree starts with the computer expanded and every category collapsed", + ); + check(!rows[0].1.is_empty(), "the computer root has a caption"); + + ui.expand_all(); + let rows = ui.listed_rows(); + for (depth, label) in rows.iter().take(40) { + println!("{}{}", " ".repeat(*depth as usize), label); + } + let devices: Vec<&String> = rows.iter().filter(|(d, _)| *d >= 2).map(|(_, l)| l).collect(); + check(devices.len() > 5, "categories expand into devices"); + check( + devices.iter().all(|l| !(l.len() == 12 && l.chars().nth(4) == Some(':') && l.chars().nth(7) == Some(':'))), + "no device is shown as a bare bus address such as 0000:03:00.0", + ); + check(rows.iter().any(|(d, l)| *d == 1 && (l.contains("Network") || l.contains("Display") || l.contains("Disk"))), "category captions are readable"); + println!("status: {}", ui.status_text()); + check(ui.status_text().contains("shown /") && ui.status_text().contains("discovered"), "the status line counts devices"); + + // Quick filter by displayed name. + let sample = devices.iter().find(|l| l.len() > 6).map(|l| l.to_string()).expect("a named device"); + let needle: String = sample.chars().take(6).collect(); + ui.set_filter(&needle, false); + settle(500); + let filtered = ui.listed_rows(); + check(filtered.iter().any(|(_, l)| *l == sample), "filtering by name keeps the matching device"); + check(filtered.len() < rows.len(), "filtering removes non-matching rows"); + ui.clear_filter(); + settle(300); + ui.expand_all(); + check(ui.listed_rows().len() >= rows.len() / 2, "clearing the filter restores the tree"); + + // Deep search finds a device by metadata that is not in its label. + ui.set_filter("PCI_CLASS", true); + settle(200); + pump_until("deep search progress", Duration::from_secs(60), || ui.status_text().contains("Deep search")); + pump_until("deep search to finish", Duration::from_secs(120), || { + let status = ui.status_text(); + status + .split("Deep search: ") + .nth(1) + .and_then(|t| t.split(|c: char| !c.is_ascii_digit() && c != '/').next()) + .is_some_and(|p| p.split_once('/').is_some_and(|(done, total)| done == total && total != "0")) + }); + check(ui.listed_rows().len() > 1, "deep search lists matching devices by metadata"); + ui.clear_filter(); + settle(300); + + // Every projection keeps one computer root. + for view in ["connection", "devicesByDriver", "driversByDevice", "driversByType", "resourcesByType", "resourcesByConnection", "type"] { + ui.activate_view(view); + settle(150); + ui.expand_all(); + let rows = ui.listed_rows(); + check(rows.iter().filter(|(d, _)| *d == 0).count() == 1 && rows.len() > 1, &format!("view {view} has one computer root and content")); + } + ui.collapse_all(); + check(ui.listed_rows().len() == 1, "collapse all leaves the root"); + + // Selecting a device enables Properties; the root opens system information. + ui.expand_all(); + check(ui.select_label(&sample), "a device row can be selected"); + check(ui.properties_enabled(), "Properties is enabled for a device"); + check(ui.status_text().contains("/sys/"), "the status line shows the selected device path"); + + // Properties opens a real tabbed window per device class. + for (needle, tabs) in [ + ("Wireless Network Adapter", &["General", "Driver", "Network", "PCIe", "Details", "Events"][..]), + ("UVC WebCam: USB2.0 HD [video0]", &["General", "Driver", "Camera", "Details", "Events"][..]), + ("WD_BLACK SN770", &["General", "Details", "Events", "Storage", "Volumes", "Health"][..]), + ("card1-eDP-1", &["General", "Display", "Details", "Events"][..]), + ("AC power adapter", &["General", "Driver", "Details", "Events"][..]), + ] { + ui.expand_all(); + if !ui.select_label(needle) { + println!("skip: no row {needle:?} on this machine"); + continue; + } + ui.open_selected_properties(); + let dialog = ui.properties_dialog().expect("the dialog is open"); + pump_until("properties to load", Duration::from_secs(60), || !dialog.is_loading()); + settle(200); + let titles = dialog.tab_titles(); + println!("{needle}: {titles:?}"); + check(tabs.iter().all(|t| titles.iter().any(|x| x == t)), &format!("{needle}: tabs {tabs:?} are present")); + check(dialog.banner_text().is_empty(), &format!("{needle}: properties loaded without a warning banner ({})", dialog.banner_text())); + check(dialog.show_tab("Details") && dialog.show_tab("Events"), "tabs can be switched"); + dialog.window().close(); + settle(100); + check(ui.properties_dialog().is_none(), "closing the window releases the dialog"); + } + // Opening Properties twice for the same instance reuses the window. + ui.expand_all(); + ui.select_label(&sample); + ui.open_selected_properties(); + let first = ui.properties_dialog().expect("dialog"); + ui.open_selected_properties(); + check(std::rc::Rc::ptr_eq(&first, &ui.properties_dialog().expect("dialog")), "reopening Properties for the same instance reuses the window"); + pump_until("properties to load", Duration::from_secs(60), || !first.is_loading()); + // A refresh with the dialog open does not reload or retarget it. + ui.request_refresh(); + pump_until("the refresh to finish", Duration::from_secs(60), || !ui.is_busy()); + settle(200); + check(!first.is_removed(), "an unchanged inventory leaves the dialog live"); + first.window().close(); + settle(100); + + // The computer root opens System Information with real collected values. + ui.select_root(); + ui.open_selected_properties(); + let system = ui.system_dialog().expect("the system dialog exists"); + pump_until("system information to load", Duration::from_secs(60), || system.is_loaded()); + settle(200); + let titles = system.tab_titles(); + println!("system tabs: {titles:?}"); + check(titles == ["Operating System", "Hardware", "Processor", "RAM/Memory"], "System Information has its four tabs"); + for key in ["os", "kernel", "cpu", "memory", "boot", "secure_boot", "uptime", "logical"] { + let text = system.field_text(key).unwrap_or_default(); + println!(" {key}: {}", text.replace('\n', " | ")); + check(!text.is_empty() && text != "Not collected", &format!("system field {key} is collected")); + } + check(system.field_text("kernel").unwrap_or_default().starts_with("Linux "), "the kernel version is reported"); + check(system.field_text("memory").unwrap_or_default().contains("GiB"), "memory is shown in GiB"); + system.window().close(); + settle(100); + ui.select_root(); + ui.open_selected_properties(); + check(std::rc::Rc::ptr_eq(&system, &ui.system_dialog().expect("dialog")), "reopening System Information reuses the retained window and snapshot"); + + // Show all devices adds internal records. + ui.expand_all(); + check(ui.select_label(&sample), "the device row can be selected again"); + let before = ui.status_text(); + ui.set_show_all(true); + settle(300); + let after = ui.status_text(); + println!("before: {before}\nafter: {after}"); + check(before != after, "show all devices changes the shown count"); + let selected_path = before.rsplit(" — ").next().unwrap_or_default().to_string(); + check(selected_path.starts_with("/sys/") && after.ends_with(&selected_path), "the selection survives the rebuild by path and generation"); + ui.set_show_all(false); + + // A manual refresh settles back to idle. + ui.request_refresh(); + pump_until("the refresh to finish", Duration::from_secs(60), || !ui.is_busy()); + settle(200); + check(ui.status_text().contains("shown /"), "refresh returns to the idle status"); + + // Closing joins the worker without blocking the UI and destroys the window. + let started = Instant::now(); + ui.window().close(); + let window = ui.window().clone(); + drop(ui); + pump_until("the window to be destroyed", Duration::from_secs(20), || !window.is_visible()); + println!("closed in {:?}", started.elapsed()); + check(true, "closing destroys the window"); + let saved = std::fs::read_to_string(config.join("QLinuxDeviceManager/settings.conf")).unwrap_or_default(); + check(saved.contains("window.width") && saved.contains("window.height"), "closing saved the window size"); + let _ = std::fs::remove_dir_all(&config); +} diff --git a/doc/architecture.md b/doc/architecture.md index 86825c0..d017175 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -264,7 +264,7 @@ The existing virtual/internal visibility setting applies before both searches. Deep search includes generic General, Driver and Details text (advanced entries as well), provenance, inventory properties/attributes, grouped-record paths, EFI hex/ASCII and resource types, ranges, flags and IRQs. The Properties text -formatter is shared and uses Qt Core only. Known numeric identifiers and +formatter is shared and has no toolkit dependency. Known numeric identifiers and resource numbers also get hexadecimal and decimal aliases; arbitrary product names and serial strings are not parsed as numbers. Only metadata already exposed by this application is collected; search does not walk arbitrary sysfs @@ -275,7 +275,7 @@ discarded. Specialized hardware tabs are not fully indexed; camera capabilities, desktop display configuration and UDisks2 health are excluded. Search results are observations, not live device state. -A single additional serial QThread collects metadata using the same pinned, +A single additional serial worker thread collects metadata using the same pinned, instance-checked collector as Properties, even for unopened dialogs. A 150 ms input debounce coalesces typing. Owned batches report matches and progress; request IDs reject obsolete batches, and each match must still have the source @@ -448,7 +448,6 @@ exercise synthetic observations; they do not establish live hardware coverage. API references: - https://www.freedesktop.org/software/systemd/man/latest/udev_monitor_receive_device.html -- https://doc.qt.io/qt-6/qabstractitemmodel.html#beginMoveRows ## Resource projections @@ -459,7 +458,7 @@ access moves to the model. Resource changes participate in metadata comparison, so live/F5/periodic snapshots update assignment rows even if a device label and generation remain unchanged. Properties retains its independently timed collection. -resourceformat is a Qt Core-only formatter shared by the Resources tab and both +resourceformat is a toolkit-free formatter shared by the Resources tab and both resource projections. It preserves the existing translation context and tab text, exposes untranslated group IDs, and supplies stable row keys based on source/type/ slot/range/mode with duplicate suffixes. Presentation never claims exclusive diff --git a/doc/implementation.md b/doc/implementation.md index 525e4a3..a9205bd 100644 --- a/doc/implementation.md +++ b/doc/implementation.md @@ -4,7 +4,8 @@ This document covers data sources, collection limits, interpretation rules, optional dependencies and building from source. For an introduction, download instructions and everyday use, see the [README](../README.md). -The application uses C++17 and Qt 6 Widgets, with libudev for device discovery, +The application is written in Rust with GTK4 (a `qldm-core` library that knows nothing about the +toolkit, and a `qldm-gtk` front end), with libudev for device discovery, sysfs and procfs for local kernel metadata, and optional UDisks2 storage metadata. Related reference: [architecture and refresh semantics](architecture.md). @@ -18,7 +19,7 @@ Related reference: [architecture and refresh semantics](architecture.md). - [System information](#system-information) - [Live device events](#live-device-events) - [UEFI firmware variables](#uefi-firmware-variables) -- [Linux AppImage packaging](#linux-appimage-packaging) +- [Packaging](#packaging) - [Build and installation](#build-and-installation) - [Permissions and settings](#permissions-and-settings) @@ -255,8 +256,8 @@ selected connector's cached `edid` and `modes` sysfs files. Optional **libdisplay-info >= 0.1.1** decodes reported manufacturer/model, product code, serial, image dimensions and digital input type/color depth. No EDID decoder is bundled: without this library, decoding is explicitly unsupported while kernel -mode names and desktop settings remain available. Configure with -`-DDISPLAY_INFO=OFF` to disable it; CMake reports whether decoding is enabled. +mode names and desktop settings remain available. The library is loaded at +run time; build `qldm-core` without its `display-info` feature to omit the loader. Reads run only in the properties worker on demand, never force connector detection, change settings or poll in the background. EDID is limited to 32 KiB and mode @@ -290,7 +291,7 @@ Wayland output protocols do not expose it, so it remains unavailable. Output sca is the X11 RandR transform (framebuffer pixels per output pixel, excluding desktop DPI) or the Wayland rotated mode/logical-size ratio. The latter is marked derived because logical dimensions may be rounded. Without logical-size information, -Wayland reports its integer buffer scale explicitly. Qt application scaling is +Wayland reports its integer buffer scale explicitly. Toolkit application scaling is excluded; arbitrary X11 transforms are shown as custom instead of inventing a single scale factor. @@ -387,7 +388,7 @@ and subvolume mounts can be distinguished. No observed mount is not proof that a volume is unused elsewhere. No filesystem probing, mounting or unlocking occurs. Optional UDisks2 support supplements native metadata when the service is already -running and accessible. Missing services, denied access and builds without QtDBus +running and accessible. Missing services, denied access and builds without the `udisks2` feature retain native storage viewing. The UDisks2 removable-drive hint is distinct from the kernel's removable-media flag. @@ -587,87 +588,60 @@ certificates (owner, issuer and expiration in UTC). This is certificate metadata not a check of the running kernel's signer or certificate trust. Reading MOK data may require permissions; unavailable data is shown explicitly. The kernel's MOK table is preferred, with efivarfs as a fallback. Oversized or split fallback lists -are reported as errors rather than displaying a partial list. Qt's TLS backend -must be installed to decode X.509 certificates. +are reported as errors rather than displaying a partial list. X.509 certificates are decoded in-process +(no network access, no TLS library needed). -## Linux AppImage packaging +## Packaging -The [Linux AppImage workflow](https://github.com/birdie-github/QLinuxDeviceManager/actions/workflows/ci.yml) -packages an x86_64 AppImage on pushes, pull requests and manual runs. Download the -artifact from a successful run and extract the ZIP, then launch it: - -```sh -chmod +x QLinuxDeviceManager-*.AppImage -./QLinuxDeviceManager-*.AppImage -``` - -The package includes Qt 6.8, X11/Wayland platform plugins, application icons and -translations, and enables libkmod, QtDBus and libdisplay-info monitor decoding. It is built on Ubuntu 24.04; -older distributions are not guaranteed to be compatible. The host still provides -the Linux kernel, device metadata and optional UDisks2 service. - -If FUSE is unavailable, launch with `--appimage-extract-and-run`. Workflow artifacts -are retained for 14 days; they are not automatically published as release assets. +Installation layout, translations, CI, and Flatpak/AppImage notes are in +[packaging/build-rust.md](../packaging/build-rust.md). A C++ parity record of the port is in +[parity-checklist.md](parity-checklist.md). ## Build and installation Requirements: -- Linux and a C++17 compiler. -- CMake **3.19 or newer** and pkg-config. -- Qt **6.8 or newer**, including Widgets and Network, and libudev development headers. - Qt Network is used locally to decode MOK certificates; this feature makes no network requests. -- libv4l2 development headers/library (Fedora: **libv4l-devel**; Debian/Ubuntu: **libv4l-dev**). +- Linux, a stable Rust toolchain (edition 2021) and pkg-config. +- GTK **4.12 or newer** development files and libudev development headers. +- `clang`/`libclang` (bindgen generates the V4L2 bindings at build time) and libv4l2 headers. - XCB RandR and Wayland client **1.20 or newer** development headers (runtime libraries are required only for the corresponding Display backend). -- Optional QtDBus **6.8 or newer** for cached UDisks2 storage metadata. -- Optional libkmod **30 or newer** for installed-module metadata and fallback names. -- Optional libdisplay-info **0.1.1 or newer** for monitor EDID decoding. -- Optional Qt Linguist tools **6.8 or newer** to build translation catalogs. +- Optional at run time (loaded with `dlopen`, so not needed to build): libkmod **30 or newer** + for installed-module metadata and fallback names; libdisplay-info **0.1.1 or newer** for + monitor EDID decoding. +- Optional UDisks2 service on the system bus for cached storage metadata (Cargo feature + `udisks2`, on by default). +- Optional GNU gettext (`msgfmt`, `xgettext`) to build and update translation catalogues. Fedora packages: ```sh -# Optional monitor EDID decoding: libdisplay-info-devel -sudo dnf install gcc-c++ cmake make pkgconf-pkg-config qt6-qtbase-devel systemd-devel libv4l-devel libxcb-devel wayland-devel -# Optional: -sudo dnf install qt6-qttools-devel kmod-devel libdisplay-info-devel +sudo dnf install rust cargo clang-devel pkgconf-pkg-config gtk4-devel systemd-devel libv4l-devel libxcb-devel wayland-devel gettext ``` Debian/Ubuntu packages: ```sh -# Optional monitor EDID decoding: libdisplay-info-dev (>= 0.1.1) -sudo apt install g++ cmake make pkg-config qt6-base-dev libudev-dev libv4l-dev libxcb-randr0-dev libwayland-dev -# Optional: -sudo apt install qt6-tools-dev qt6-tools-dev-tools libkmod-dev libdisplay-info-dev +sudo apt install cargo clang libclang-dev pkg-config libgtk-4-dev libudev-dev libv4l-dev libxcb-randr0-dev libwayland-dev gettext ``` -Check that your distribution's Qt packages meet the minimum version; older -releases may require a newer Qt installation. - Build and run from a source checkout: ```sh -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -cmake --build build -j -./build/qlinuxdevicemanager +cargo build --release +./target/release/qldm-gtk ``` -Install the executable, desktop entry, application icons and available translation catalogs: +Install the executable, camera helper, desktop entry, icons and compiled translation catalogues: ```sh -sudo cmake --install build --prefix /usr/local +sudo make install PREFIX=/usr/local ``` -Use `-DKMOD=OFF` to omit libkmod or `-DUDISKS2=OFF` for a native-only storage build. -Basic hardware viewing requires neither QtDBus nor UDisks2. The interface uses -system-theme icons with Qt fallbacks and standard Qt X11/Wayland platform selection. -The application window and About dialog use embedded application icons, available -without installation. Installation supplies PNG sizes from 16 to 512 pixels and -a scalable SVG in the standard `hicolor` icon theme for desktop launchers. -The supplied interface is English; Qt's own dialogs use installed Qt translations -when available. +Build with `--no-default-features` on `qldm-core` to omit the UDisks2 client (native-only +storage). Basic hardware viewing needs neither UDisks2 nor libkmod. The application window uses the +system icon theme and GTK's standard X11/Wayland backend selection. The supplied interface is +English; the gettext hook translates it when a catalogue for the user's locale is installed. ## Permissions and settings @@ -676,7 +650,8 @@ as unavailable or permission denied; no passwords or administrative privileges are required for ordinary viewing. The application does not upload hardware information or run a separate diagnostic daemon. -Settings use the normal Qt configuration location under `QLinuxDeviceManager`. +Settings are stored in `settings.conf` under `$XDG_CONFIG_HOME/QLinuxDeviceManager` (default +`~/.config/QLinuxDeviceManager`); settings from the earlier Qt version are not migrated. If launched as root, the window identifies administrative mode and settings use `/root/.config`, `/root/.local/share` and `/root/.cache`. Root launch requires an already-authorized display/session environment; the application does not change diff --git a/doc/parity-checklist.md b/doc/parity-checklist.md new file mode 100644 index 0000000..7a33027 --- /dev/null +++ b/doc/parity-checklist.md @@ -0,0 +1,51 @@ +# C++ → Rust parity checklist + +Every C++ test file in `tests/` and every documented behavior area of `doc/implementation.md`, +mapped to the Rust test or manual check that now covers it. `[x]` = covered by an automated Rust +test that passes; `[~]` = covered only by a live-machine test or the GTK smoke test, or not +verified visually (the reason is given). Nothing is marked `[x]` on the strength of code reading. + +## C++ test files + +| C++ file (assertions) | Behavior | Rust coverage | +|---|---|---| +| `device-rules.cpp` (67) | visibility rules, categories, PCI class decoding | [x] `model.rs` tests (`keyboard_and_audio_functions_stay_visible`, `input_endpoint_is_hidden`, `virtual_device_keeps_category_but_hides`, `partition_is_hidden`, `pci_class_decoding`, `category_table_matches_cpp_order`) | +| | generations, selection identity, reconcile | [x] `model.rs::reconcile_keeps_generation_only_for_the_same_instance`, `tree.rs::live_updates_keep_instances_and_replace_removed_ones` | +| | device naming (CPU, input, USB, PNP, codec, ACPI, faux, platform, root hub, hwdb) | [x] `naming.rs` (10 tests) | +| | labels (battery, mains, UCSI port, I2C/touchpad/mouse, ALSA jack/mic, EFI) | [x] `label.rs` (7 tests) | +| | NVMe/bridge pairing, ALSA grouping, USB network naming | [x] `presentation.rs` (7 tests) | +| `device-search.cpp` (82) | raw/status/errno search text, numeric aliases, EFI byte views, storage and mounts | [x] `search.rs` (6 tests), `propertytext.rs::storage_values_explain_themselves` | +| | name vs deep search, hidden exclusion, stale generations, clearing | [x] `tree.rs::search_inputs_exclude_hidden_devices_and_name_search_ignores_metadata`, `search.rs::deep_search_batches_caches_by_generation_and_cancels` | +| | connection/driver/resource projections, bus-qualified drivers, root and counts | [x] `tree.rs` (`connection_view_*`, `driver_projections_*`, `every_projection_*`, `resource_views_*`) | +| | storage presentation, health, ATA copy text, Details exclusion | [x] `storage_presentation.rs` (6 tests), `propertytext.rs::block_devices_keep_storage_identity_out_of_details` | +| `device-events.cpp` (14) | event identities, stale removal, bounds, canonical paths | [x] `events.rs` (4 tests), `monitorstate.rs::hints_are_bounded_and_canonical` | +| `device-resources.cpp` (20) | PCI/PnP resource parsing, IRQ rules, malformed input | [x] `resources.rs` (6 tests), `resourceformat.rs` (3 tests) | +| `directory-scan.cpp` (13) | bounded, cancellable, name-filtered directory listing | [x] `sysfs.rs::directory_names_follow_cpp_fixture`, `dirfd.rs::reads_are_anchored_and_classified` | +| `monitor-state.cpp` (8) | coalescing, loss, untrusted snapshot, exclusion | [x] `monitorstate.rs` (3 tests), `monitor.rs::session_publishes_one_snapshot_at_a_time_and_stops_promptly` | +| `system-properties.cpp` (60) | os-release quoting, cpu lists, instruction sets, ReBAR, memory slots, caches, SecureBoot, MOK | [x] `system.rs` (7 tests), `secureboot.rs` (7 tests), `cpuinfo.rs` (3 tests) | + +## Documented behavior (doc/implementation.md sections) + +| Section | Status | +|---|---| +| Application behavior: tree views by type / connection / driver / resources, filter bar, deep search, hidden devices toggle, refresh, status line, settings | [x] model logic in `tree.rs`; [~] window wiring driven by `qldm-gtk/tests/ui_smoke.rs` on a real session (rows, expand, select, show-all, refresh, close ≈ 45 ms, saved size); keyboard accelerators, context menu, Enter-vs-double-click and theme/layout **not verified visually** | +| Hardware and driver views (names, grouping, connectors) | [x] `naming.rs`, `label.rs`, `presentation.rs`; [~] live names checked against this machine (GPU, NVMe, webcam) | +| Device properties: General, Driver, Details, Resources | [x] `propertytext.rs` (9 tests), `properties.rs` (8), `resourceformat.rs`; [~] all tabs open in smoke test for Wi-Fi, webcam, NVMe, eDP, AC adapter | +| Network / Ethernet | [x] `network.rs`, `ethernet.rs` (netlink framing and parsers), `report.rs`; [~] live ethtool answered on this machine | +| Display / EDID / Wayland / X11 | [x] `display*.rs`, `monitor_edid.rs`, `report.rs`; [~] live X11 and Wayland probes run here | +| Camera | [x] `camera.rs`, `tests/camera_helper.rs` (hang killed, bad output, per-node); [~] live webcam answered | +| USB, PCIe, Battery tabs | [x] wording and decoding in `report.rs`, `properties.rs`; USB power tab has no live device test | +| Storage, volumes, health | [x] `storage.rs`, `storage_presentation.rs`; [~] live UDisks2 NVMe health read; volume/health selection behaviour in the GTK page **not driven by a test** | +| System information (all tabs) | [x] `system.rs`, `system_report.rs`; [~] smoke test reads real values; Copy all text is unit tested, the button wiring is not | +| Live device events tab | [x] `events.rs`, `events_view.rs`; the live update path into an open dialog is **not exercised by a test** | +| UEFI variables | [x] `efivars.rs`, `properties.rs::efi_variable_bytes_*`, `label.rs::efi_variables_use_readable_labels` | +| Permissions and settings | [x] `settings.rs` round trip; settings now live in `settings.conf` under the same config directory (the Qt `QLinuxDeviceManager.conf` is not read or migrated — behavior change) | +| Translations | [x] gettext hook and catalogue test; English only | +| Packaging | [~] `make install` staged and inspected; the CI workflow, Flatpak and AppImage recipes were **never run** | + +## Known gaps accepted at cutover + +1. No human has looked at the GTK windows: layout, spacing, theming, tooltips, clipboard buttons. +2. Qt-era settings are not migrated. +3. Details drop-down has no readable/unavailable separator; tables are GTK column views. +4. `tr(variable)` strings are not extracted into the `.pot`. diff --git a/packaging/build-rust.md b/packaging/build-rust.md new file mode 100644 index 0000000..e7ce3d0 --- /dev/null +++ b/packaging/build-rust.md @@ -0,0 +1,82 @@ +# Building and packaging + +## Build and install + +Build dependencies (Debian/Ubuntu names): `build-essential pkg-config libgtk-4-dev libudev-dev +libkmod-dev libdisplay-info-dev libv4l-dev libwayland-dev libxcb-randr0-dev clang libclang-dev +gettext`, plus a stable Rust toolchain. `libkmod` and `libdisplay-info` are loaded with `dlopen` at +run time and `libudisks2` is reached over D-Bus, so each is optional on the target machine. + +```sh +make # release build of the whole workspace +make check # cargo test --workspace +sudo make install PREFIX=/usr # or: make install DESTDIR=$PWD/pkg PREFIX=/usr +``` + +`make install` puts these in place: + +| File | Destination | +|---|---| +| GUI (`qldm-gtk`) | `$PREFIX/bin/qlinuxdevicemanager` | +| `qldm-camera-helper` | `$PREFIX/bin/` — **must stay next to the GUI binary**, which looks for it there | +| Desktop entry (id `io.github.birdie-github.QLinuxDeviceManager`, unchanged) | `share/applications/` | +| hicolor icons 16–512 px and the scalable SVG | `share/icons/hicolor/…/apps/` | +| `project.json` | `share/QLinuxDeviceManager/` | +| Message catalogues (`po/.po` → `.mo`) | `share/locale//LC_MESSAGES/qlinuxdevicemanager.mo` | + +The catalogue directory defaults to `/usr/share/locale`; build with `QLDM_LOCALE_DIR=` (the +Makefile does this from `LOCALEDIR`) to bake in another one, or set it at run time to test an +uninstalled tree. Name, version and repository come from `project.json` through +`crates/qldm-core/build.rs` (`qldm_core::app`), as CMake did. + +## Translations + +English is the source language: each `tr("…")` literal is its own msgid, translated with glibc +`dgettext` (no extra crate or library). `po/update-pot.sh` regenerates `po/qlinuxdevicemanager.pot` +with `xgettext`; a translator copies it to `po/.po`, and `make install` compiles every +`po/*.po`. Only string literals passed directly to `tr` are extracted, so keep the literal at the +call site. Placeholders are `%1`, `%2`, … as in the earlier C++ version. No translations beyond English +are shipped yet. + +## Continuous integration + +`.github/workflows/rust.yml` runs `cargo fmt --check`, `cargo clippy --workspace --all-targets +-D warnings`, the unit and integration tests, the GTK smoke test under `xvfb`, `desktop-file-validate`, +a check that the `.pot` is current, and a staged `make install`. The earlier Qt AppImage workflow was removed with the C++ code. + +## Flatpak (manifest sketch, not built or tested) + +Use the GNOME runtime (`org.gnome.Platform`, `org.gnome.Sdk` with the rust-stable extension). The +application reads `/sys`, `/proc`, `/dev` and the system bus, so: + +```yaml +app-id: io.github.birdie-github.QLinuxDeviceManager +runtime: org.gnome.Platform +runtime-version: '48' +sdk: org.gnome.Sdk +sdk-extensions: [org.freedesktop.Sdk.Extension.rust-stable] +command: qlinuxdevicemanager +finish-args: + - --share=ipc + - --socket=wayland + - --socket=fallback-x11 + - --device=all # /dev/video*, /dev/dri for camera and display queries + - --system-talk-name=org.freedesktop.UDisks2 + - --filesystem=host-os:ro # /usr/lib kernel modules and the host's libkmod data +modules: + - name: qlinuxdevicemanager + buildsystem: simple + build-commands: [make install PREFIX=/app] +``` + +Inside the sandbox `/sys` and `/proc` are those of the host kernel but processes, mounts and +network namespaces are the sandbox's, and `libkmod`/`libdisplay-info` must be bundled. Treat this +as a starting point: it has not been built. + +## AppImage (replaces the Qt recipe) + +Stage with `make install PREFIX=/usr DESTDIR=$PWD/AppDir`, then run `linuxdeploy` with +`--appdir AppDir --desktop-file … --icon-file … --plugin gtk --output appimage` +(the `linuxdeploy-plugin-gtk` bundles GTK 4 and its schemas). Bundle `libkmod.so.2` and +`libdisplay-info.so.*` with `--library` since they are `dlopen`ed and invisible to ELF scanning. +This has not been run; build on the oldest distribution you intend to support. diff --git a/po/qlinuxdevicemanager.pot b/po/qlinuxdevicemanager.pot new file mode 100644 index 0000000..e170536 --- /dev/null +++ b/po/qlinuxdevicemanager.pot @@ -0,0 +1,1906 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the QLinuxDeviceManager package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: QLinuxDeviceManager\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-15 03:05+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Device addition observed" +msgstr "" + +msgid "Device removal observed" +msgstr "" + +msgid "Device change reported" +msgstr "" + +msgid "Driver binding reported" +msgstr "" + +msgid "Driver unbinding reported" +msgstr "" + +msgid "Device path move reported" +msgstr "" + +msgid "Device online event reported" +msgstr "" + +msgid "Device offline event reported" +msgstr "" + +msgid "udev action: %1" +msgstr "" + +msgid "Unavailable in event payload" +msgstr "" + +msgid "Observed: %1" +msgstr "" + +msgid "Elapsed since monitor startup: %1 ms" +msgstr "" + +msgid "Source: live udev observation" +msgstr "" + +msgid "Action: %1" +msgstr "" + +msgid "Description: %1" +msgstr "" + +msgid "Kernel path: %1" +msgstr "" + +msgid "Subsystem: %1" +msgstr "" + +msgid "Device type: %1" +msgstr "" + +msgid "Driver in event payload: %1" +msgstr "" + +msgid "udev sequence: %1" +msgstr "" + +msgid "Initialization stamp: %1" +msgstr "" + +msgid "Local instance token: %1" +msgstr "" + +msgid "Previous path: %1" +msgstr "" + +msgid "" +"Live udev events since %1. Receipt times; exact device instance only. Latest " +"%3 events per device; %2 retained globally. Historical logs are not queried." +msgstr "" + +msgid "monitor startup" +msgstr "" + +msgid "%1 older events evicted from the global history." +msgstr "" + +msgid "%1 events could not be associated safely with an inventory instance." +msgstr "" + +msgid "" +"%1 event-identity resets after monitor failures/losses; coverage is " +"incomplete." +msgstr "" + +msgid "" +"Live event monitoring is unavailable. Viewing and retained events remain " +"available." +msgstr "" + +msgid "No safe live-event association is available for this instance." +msgstr "" + +msgid "" +"EFI variables are reconciled by inventory scans; libudev does not supply " +"their value-change history." +msgstr "" + +msgid "" +"Removed/replaced device: retained events are a read-only instance snapshot." +msgstr "" + +msgid "" +"No retained live events for this instance. This does not establish that the " +"device has had no errors." +msgstr "" + +msgid "AC power adapter" +msgstr "" + +msgid "%1 battery" +msgstr "" + +msgid "Battery" +msgstr "" + +msgid "USB-C power supply (port %1)" +msgstr "" + +msgid "USB power supply" +msgstr "" + +msgid "Headphone jack detection" +msgstr "" + +msgid "Microphone jack detection" +msgstr "" + +msgid "HDMI/DisplayPort jack detection (PCM %1)" +msgstr "" + +msgid "Touchpad (%1)" +msgstr "" + +msgid "Mouse (%1)" +msgstr "" + +msgid "%1 (module candidate)" +msgstr "" + +msgid "Empty value" +msgstr "" + +msgid "Unavailable" +msgstr "" + +msgid "Permission denied" +msgstr "" + +msgid "Device removed" +msgstr "" + +msgid "Unsupported" +msgstr "" + +msgid "Not applicable" +msgstr "" + +msgid "Not queried: shared health-query time limit reached" +msgstr "" + +msgid "UDisks2 support is not enabled in this build" +msgstr "" + +msgid "Drive health is not exposed through a supported UDisks2 interface" +msgstr "" + +msgid "Detailed health query is not supported by UDisks2" +msgstr "" + +msgid "Available; cached properties only" +msgstr "" + +msgid "" +"Application mount namespace only; other namespaces may differ. No filesystem " +"is mounted or unlocked." +msgstr "" + +msgid "No mount observed in the application namespace" +msgstr "" + +msgid "Invalid capacity or sector-size metadata" +msgstr "" + +msgid "%1 bytes" +msgstr "" + +msgid "%1 bytes (%2)" +msgstr "" + +msgid "Yes" +msgstr "" + +msgid "No" +msgstr "" + +msgid "Invalid boolean metadata" +msgstr "" + +msgid "Removable (UDisks2 hint)" +msgstr "" + +msgid "Fixed (UDisks2 hint)" +msgstr "" + +msgid "Invalid removable metadata" +msgstr "" + +msgid "%1 °C" +msgstr "" + +msgid "Invalid health metadata" +msgstr "" + +msgid "%1%" +msgstr "" + +msgid "%1 seconds (%2 hours)" +msgstr "" + +msgid "%1 hours" +msgstr "" + +msgid "%1 minutes" +msgstr "" + +msgid "Failure predicted by drive" +msgstr "" + +msgid "No failure predicted by drive (not a guarantee of health)" +msgstr "" + +msgid "No critical warnings reported (not a guarantee of health)" +msgstr "" + +msgid "Available spare is below its threshold" +msgstr "" + +msgid "Temperature is outside a permitted threshold" +msgstr "" + +msgid "NVM subsystem reliability is degraded" +msgstr "" + +msgid "Media is in read-only mode" +msgstr "" + +msgid "Volatile-memory backup has failed" +msgstr "" + +msgid "Persistent memory region is read-only or unreliable" +msgstr "" + +msgid "Unrecognized warning: %1" +msgstr "" + +msgid "Storage snapshot" +msgstr "" + +msgid "" +"Each entity is a separate storage layer; capacities are not added. Health is " +"cached UDisks2 evidence, separate from operational status. Reload never " +"requests SMART updates or self-tests. Deep search includes native metadata " +"only." +msgstr "" + +msgid "Storage entity" +msgstr "" + +msgid "Device name" +msgstr "" + +msgid "Application grouping" +msgstr "" + +msgid "Category" +msgstr "" + +msgid "Manufacturer" +msgstr "" + +msgid "Bus / subsystem" +msgstr "" + +msgid "Device udev metadata and direct driver bus" +msgstr "" + +msgid "Location" +msgstr "" + +msgid "Kernel path" +msgstr "" + +msgid "Removed or replaced; showing a snapshot." +msgstr "" + +msgid "Present in the last inventory; properties have not been verified." +msgstr "" + +msgid "Present at the last property read." +msgstr "" + +msgid "No directly bound driver; this alone is not a fault." +msgstr "" + +msgid "Driver bound: %1" +msgstr "" + +msgid "Driver binding: %1" +msgstr "" + +msgid "USB device/interface is unauthorized." +msgstr "" + +msgid "USB device/interface is authorized." +msgstr "" + +msgid "USB authorization: %1" +msgstr "" + +msgid "Device status" +msgstr "" + +msgid "Observed presence, binding and USB authorization only" +msgstr "" + +msgid "No directly bound driver" +msgstr "" + +msgid "Bound kernel driver" +msgstr "" + +msgid "Hardware identifiers" +msgstr "" + +msgid "Selected udev and direct sysfs identifiers" +msgstr "" + +msgid "Driver bus" +msgstr "" + +msgid "Owning kernel module" +msgstr "" + +msgid "Built-in or modular" +msgstr "" + +msgid "Modular (kernel initstate evidence)" +msgstr "" + +msgid "" +"Undetermined; a missing module link/version does not prove a built-in driver." +msgstr "" + +msgid "Running module version" +msgstr "" + +msgid "Installed module filename" +msgstr "" + +msgid "Installed module version" +msgstr "" + +msgid "Installed module description" +msgstr "" + +msgid "Installed module author" +msgstr "" + +msgid "Installed module license" +msgstr "" + +msgid "Declared firmware names" +msgstr "" + +msgid "Reported device firmware revision" +msgstr "" + +msgid "Metadata scope" +msgstr "" + +msgid "" +"Installed metadata may differ from code already loaded before an update. " +"Declared firmware names do not prove firmware is loaded or report its " +"version." +msgstr "" + +msgid "Parent device path" +msgstr "" + +msgid "Recorded ancestry" +msgstr "" + +msgid "Original name" +msgstr "" + +msgid "Name source" +msgstr "" + +msgid "Kernel name" +msgstr "" + +msgid "Full kernel path" +msgstr "" + +msgid "Direct driver path" +msgstr "" + +msgid "Inventory: %1" +msgstr "" + +msgid "Inventory attribute: %1" +msgstr "" + +msgid "Last inventory; see name source for derived naming metadata" +msgstr "" + +msgid "Empty file" +msgstr "" + +msgid "Truncated: first 65536 bytes shown." +msgstr "" + +msgid "UEFI variable — hex dump" +msgstr "" + +msgid "" +"Complete efivarfs file bytes, including the attribute prefix; contents are " +"not decoded." +msgstr "" + +msgid "Query unsupported by the kernel/device" +msgstr "" + +msgid "Malformed ethtool reply" +msgstr "" + +msgid "Query timed out" +msgstr "" + +msgid "Reply exceeds collection limits" +msgstr "" + +msgid "Link changed during collection; reload properties" +msgstr "" + +msgid "%1 Gbps" +msgstr "" + +msgid "%1 Mbps" +msgstr "" + +msgid "Network interfaces" +msgstr "" + +msgid "No network interfaces observed" +msgstr "" + +msgid "Interfaces" +msgstr "" + +msgid "%1 — Ethernet link" +msgstr "" + +msgid "Connected" +msgstr "" + +msgid "Disconnected" +msgstr "" + +msgid "Link status" +msgstr "" + +msgid "Not applicable — link disconnected" +msgstr "" + +msgid "Unavailable (driver reports unknown speed)" +msgstr "" + +msgid "Link speed" +msgstr "" + +msgid "" +"Driver-reported operational speed, not measured throughput; it may be forced " +"when autonegotiation is disabled." +msgstr "" + +msgid "Half duplex" +msgstr "" + +msgid "Full duplex" +msgstr "" + +msgid "Unavailable (driver reports unknown duplex)" +msgstr "" + +msgid "Unrecognized duplex value: %1" +msgstr "" + +msgid "Duplex" +msgstr "" + +msgid "Disabled" +msgstr "" + +msgid "Enabled" +msgstr "" + +msgid "Unrecognized autonegotiation value: %1" +msgstr "" + +msgid "Autonegotiation" +msgstr "" + +msgid "Supported modes / capabilities" +msgstr "" + +msgid "" +"Kernel-named supported-mask entries include link modes and media, pause or " +"autonegotiation capabilities. Unnamed bits retain their numeric index." +msgstr "" + +msgid "Not applicable — autonegotiation disabled" +msgstr "" + +msgid "Unavailable — no partner advertisement reported" +msgstr "" + +msgid "Link-partner advertised modes" +msgstr "" + +msgid "%1 — Link" +msgstr "" + +msgid "Driver permanent address: ETHTOOL_GPERMADDR" +msgstr "" + +msgid "Hardware MAC address" +msgstr "" + +msgid "Current MAC address" +msgstr "" + +msgid "None" +msgstr "" + +msgid "Unavailable; permanent MAC could not be determined" +msgstr "" + +msgid "Overridden MAC address" +msgstr "" + +msgid "MTU" +msgstr "" + +msgid "Ethernet details" +msgstr "" + +msgid "%1 — IP addresses" +msgstr "" + +msgid "getifaddrs: application network namespace" +msgstr "" + +msgid "None assigned" +msgstr "" + +msgid "IP addresses" +msgstr "" + +msgid "IPv4 address" +msgstr "" + +msgid "IPv6 address" +msgstr "" + +msgid "Device busy" +msgstr "" + +msgid "Previous camera query has not exited; retry later" +msgstr "" + +msgid "Enumeration or report limit reached" +msgstr "" + +msgid "Malformed driver response" +msgstr "" + +msgid "Capture device" +msgstr "" + +msgid "Device node" +msgstr "" + +msgid "Driver-reported snapshot" +msgstr "" + +msgid "Incomplete enumeration: %1" +msgstr "" + +msgid "Query status" +msgstr "" + +msgid "Bus information" +msgstr "" + +msgid "Driver" +msgstr "" + +msgid "Collection scope" +msgstr "" + +msgid "Coverage" +msgstr "" + +msgid "" +"This capture node and current media pipeline. No capture or configuration " +"changes. Reported modes do not guarantee throughput or compatibility with " +"other simultaneous streams." +msgstr "" + +msgid "Format, size and frame rate combinations" +msgstr "" + +msgid "Capture queue" +msgstr "" + +msgid "Frame rates / intervals" +msgstr "" + +msgid "Pixel format" +msgstr "" + +msgid "Resolution / range" +msgstr "" + +msgid " (big endian)" +msgstr "" + +msgid " (compressed)" +msgstr "" + +msgid " (emulated, reported by driver)" +msgstr "" + +msgid "%1 × %2" +msgstr "" + +msgid "%1–%2 × %3–%4 pixels" +msgstr "" + +msgid " (continuous)" +msgstr "" + +msgid " (steps: %1 × %2 pixels)" +msgstr "" + +msgid "Not enumerated: intervals depend on the exact size" +msgstr "" + +msgid "%1 fps (%2 s)" +msgstr "" + +msgid "%1–%2 fps; intervals %3–%4 s" +msgstr "" + +msgid " (interval step: %1 s)" +msgstr "" + +msgid "Multiple planes" +msgstr "" + +msgid "Single plane" +msgstr "" + +msgid "" +"Intervals queried only for this pixel format and exact discrete size. Ranges " +"are not expanded." +msgstr "" + +msgid "Mode %1" +msgstr "" + +msgid "%1 bits per pixel" +msgstr "" + +msgid "%1 Hz" +msgstr "" + +msgid "%1° counterclockwise" +msgstr "" + +msgid "0°" +msgstr "" + +msgid "X and Y axes" +msgstr "" + +msgid "X axis" +msgstr "" + +msgid "Y axis" +msgstr "" + +msgid "Vertical axis" +msgstr "" + +msgid "Custom transform" +msgstr "" + +msgid "%1×" +msgstr "" + +msgid "X: %1×, Y: %2×" +msgstr "" + +msgid " (derived)" +msgstr "" + +msgid " (integer buffer scale)" +msgstr "" + +msgid "Monitor hardware (reported)" +msgstr "" + +msgid "Decoded by libdisplay-info" +msgstr "" + +msgid "Decoded with conformance warnings" +msgstr "" + +msgid "EDID decoding unavailable: build with libdisplay-info support" +msgstr "" + +msgid "Unsupported EDID version: %1" +msgstr "" + +msgid "Malformed EDID (invalid header, length, checksum or parser rejection)" +msgstr "" + +msgid "Monitor report changed during collection; reload properties" +msgstr "" + +msgid "Descriptor" +msgstr "" + +msgid "EDID version" +msgstr "" + +msgid "Manufacturer code" +msgstr "" + +msgid "Model" +msgstr "" + +msgid "Product code" +msgstr "" + +msgid "Serial number" +msgstr "" + +msgid "Image dimensions" +msgstr "" + +msgid "%1 × %2 cm" +msgstr "" + +msgid "Conformance warnings" +msgstr "" + +msgid "Monitor input (reported)" +msgstr "" + +msgid "Signal type" +msgstr "" + +msgid "Analog" +msgstr "" + +msgid "Digital" +msgstr "" + +msgid "Input interface" +msgstr "" + +msgid "Declared color depth" +msgstr "" + +msgid "%1 bits per color component" +msgstr "" + +msgid "Kernel-reported modes" +msgstr "" + +msgid "Mode names" +msgstr "" + +msgid "Scope" +msgstr "" + +msgid "" +"Cached mode names; refresh rates are not exposed here. The list may include " +"driver additions or restrictions and does not guarantee monitor capabilities." +msgstr "" + +msgid "Desktop session" +msgstr "" + +msgid "Configuration" +msgstr "" + +msgid "Backend" +msgstr "" + +msgid "Output" +msgstr "" + +msgid "No active mode" +msgstr "" + +msgid "Reported by desktop server" +msgstr "" + +msgid "" +"Read-only session query. Unavailable can mean missing runtime libraries, an " +"output not exposed by this session, or a protocol without usable output " +"names." +msgstr "" + +msgid "Active display mode" +msgstr "" + +msgid "Resolution" +msgstr "" + +msgid "Desktop color depth" +msgstr "" + +msgid "Refresh rate" +msgstr "" + +msgid "Orientation and scale" +msgstr "" + +msgid "Rotation" +msgstr "" + +msgid "Reflection" +msgstr "" + +msgid "Output scale" +msgstr "" + +msgid "%1 (%2)" +msgstr "" + +msgid "Connection" +msgstr "" + +msgid "USB version" +msgstr "" + +msgid "" +"USB specification version reported by the device descriptor; not the port's " +"maximum capability." +msgstr "" + +msgid "Connection speed" +msgstr "" + +msgid "" +"Kernel-reported link rate, not measured throughput. For root hubs this " +"describes the USB bus." +msgstr "" + +msgid "Power declarations" +msgstr "" + +msgid "Bus-powered" +msgstr "" + +msgid "Self-powered" +msgstr "" + +msgid "Not supported" +msgstr "" + +msgid "Supported" +msgstr "" + +msgid "Power source" +msgstr "" + +msgid "Declared maximum current" +msgstr "" + +msgid "Remote wakeup capability" +msgstr "" + +msgid "Active configuration" +msgstr "" + +msgid "Unconfigured" +msgstr "" + +msgid "Available configurations" +msgstr "" + +msgid "Description" +msgstr "" + +msgid "Not provided" +msgstr "" + +msgid "Interface count" +msgstr "" + +msgid "None observed" +msgstr "" + +msgid "Interface %1" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "Number (hex)" +msgstr "" + +msgid "Alternate setting" +msgstr "" + +msgid "Function class" +msgstr "" + +msgid "Subclass (hex)" +msgstr "" + +msgid "Protocol (hex)" +msgstr "" + +msgid "Bound driver" +msgstr "" + +msgid "Unbound" +msgstr "" + +msgid "(empty)" +msgstr "" + +msgid "Malformed reported value: %1" +msgstr "" + +msgid "Unavailable (%1)" +msgstr "" + +msgid "%1 (PCIe Gen %2)" +msgstr "" + +msgid "Unavailable (kernel reports unknown width: %1)" +msgstr "" + +msgid "Unavailable (reported width: %1)" +msgstr "" + +msgid "x%1 (%2 lanes)" +msgstr "" + +msgid "Malformed PCI capability data" +msgstr "" + +msgid "Incomplete PCI configuration data" +msgstr "" + +msgid "PCIe endpoint" +msgstr "" + +msgid "Legacy PCIe endpoint" +msgstr "" + +msgid "Root port" +msgstr "" + +msgid "Upstream switch port" +msgstr "" + +msgid "Downstream switch port" +msgstr "" + +msgid "PCIe-to-PCI/PCI-X bridge" +msgstr "" + +msgid "PCI/PCI-X-to-PCIe bridge" +msgstr "" + +msgid "Root Complex Integrated Endpoint" +msgstr "" + +msgid "Root Complex Event Collector" +msgstr "" + +msgid "Unrecognized PCIe device type: %1" +msgstr "" + +msgid "Connection type" +msgstr "" + +msgid "Access" +msgstr "" + +msgid "" +"Run the application as root or with sudo to read this device's PCIe " +"connection type." +msgstr "" + +msgid "PCIe link" +msgstr "" + +msgid "" +"Not applicable — integrated into the root complex; no external PCIe link." +msgstr "" + +msgid "" +"Current link reported for this device; an on-demand reading, not measured " +"throughput or a fault verdict." +msgstr "" + +msgid "Current link" +msgstr "" + +msgid "Link width" +msgstr "" + +msgid "" +"Maximum capability reported for this device, not the slot or entire upstream " +"path. Speed and width maxima are separate declarations." +msgstr "" + +msgid "Device capability" +msgstr "" + +msgid "Maximum link speed" +msgstr "" + +msgid "Maximum link width" +msgstr "" + +msgid "Interpretation" +msgstr "" + +msgid "Not assessed; upstream links may impose additional limits." +msgstr "" + +msgid "Upstream path" +msgstr "" + +msgid "Current vs. maximum" +msgstr "" + +msgid "" +"A lower current link speed or width is not automatically a fault. Power " +"management and topology can explain differences." +msgstr "" + +msgid "Not reported" +msgstr "" + +msgid "Unknown (reported)" +msgstr "" + +msgid "Charging" +msgstr "" + +msgid "Discharging" +msgstr "" + +msgid "Not charging" +msgstr "" + +msgid "Full" +msgstr "" + +msgid "Unavailable (reported value: 0)" +msgstr "" + +msgid "Current state" +msgstr "" + +msgid "Battery type or presence changed during collection; reload properties." +msgstr "" + +msgid "Malformed battery presence value: %1" +msgstr "" + +msgid "Collection status" +msgstr "" + +msgid "Supply type" +msgstr "" + +msgid "Absent" +msgstr "" + +msgid "Present" +msgstr "" + +msgid "Battery presence" +msgstr "" + +msgid "Charge percentage" +msgstr "" + +msgid "Voltage" +msgstr "" + +msgid "Current" +msgstr "" + +msgid "" +"Driver-reported instantaneous current; the reported sign is preserved. " +"Charging state is reported separately." +msgstr "" + +msgid "Power" +msgstr "" + +msgid "" +"Driver-reported instantaneous power; not calculated from voltage and current." +msgstr "" + +msgid "Unavailable — requires compatible full and design readings" +msgstr "" + +msgid "Unavailable — requires positive full and design readings" +msgstr "" + +msgid "%1 % (estimate)" +msgstr "" + +msgid "Charge capacity" +msgstr "" + +msgid "Energy capacity" +msgstr "" + +msgid "Design capacity" +msgstr "" + +msgid "Last full-charge capacity" +msgstr "" + +msgid "" +"Firmware-reported learned full capacity; it can depend on temperature, age " +"and calibration." +msgstr "" + +msgid "Remaining capacity" +msgstr "" + +msgid "Estimated capacity retention" +msgstr "" + +msgid "" +"100 × full / design using matching units; not a complete battery health " +"verdict. Values above 100% are not clamped." +msgstr "" + +msgid "Capacity" +msgstr "" + +msgid "Reported capacities" +msgstr "" + +msgid "Unavailable — no energy or charge capacity attributes exposed" +msgstr "" + +msgid "Battery information" +msgstr "" + +msgid "Cycle count" +msgstr "" + +msgid "" +"Reported full charge/discharge cycles. The kernel ABI defines zero as " +"unavailable." +msgstr "" + +msgid "Chemistry" +msgstr "" + +msgid "Unassigned" +msgstr "" + +msgid "Address unavailable (zeroed or masked)" +msgstr "" + +msgid "BAR %1" +msgstr "" + +msgid "Expansion ROM" +msgstr "" + +msgid "PCI resource %1" +msgstr "" + +msgid "Flags: %1" +msgstr "" + +msgid "Reported irq attribute; active mode not established" +msgstr "" + +msgid "PnP device disabled" +msgstr "" + +msgid "Resource metadata" +msgstr "" + +msgid "Read or parse failed" +msgstr "" + +msgid "Represented by" +msgstr "" + +msgid "Grouped records" +msgstr "" + +msgid "PnP device state" +msgstr "" + +msgid "Partition" +msgstr "" + +msgid "Device mapper" +msgstr "" + +msgid "RAID" +msgstr "" + +msgid "Logical volume" +msgstr "" + +msgid "Volume group" +msgstr "" + +msgid "Block device" +msgstr "" + +msgid "Conflicting metadata:" +msgstr "" + +msgid "%1 milliseconds" +msgstr "" + +msgid "%1 sectors" +msgstr "" + +msgid "Unavailable (unknown unit %1)" +msgstr "" + +msgid "Unnamed attribute" +msgstr "" + +msgid "ATA SMART attributes" +msgstr "" + +msgid "NVMe health details" +msgstr "" + +msgid "Temperature sensors" +msgstr "" + +msgid "Temperature sensor %1" +msgstr "" + +msgid "Health" +msgstr "" + +msgid "Health and endurance" +msgstr "" + +msgid "Usage" +msgstr "" + +msgid "Errors" +msgstr "" + +msgid "Temperatures" +msgstr "" + +msgid "Device" +msgstr "" + +msgid "Associated drive" +msgstr "" + +msgid "Cached drive health" +msgstr "" + +msgid "Cached health" +msgstr "" + +msgid "No supported cached drive health in this snapshot" +msgstr "" + +msgid "Source: %1" +msgstr "" + +msgid "" +"%1 (ID %2)\n" +"Normalized value: %3\n" +"Worst normalized value: %4\n" +"Threshold: %5\n" +"Interpreted value: %6\n" +"Flags: %7\n" +"Source: %8" +msgstr "" + +msgid "Operating System" +msgstr "" + +msgid "Hardware" +msgstr "" + +msgid "Processor" +msgstr "" + +msgid "RAM/Memory" +msgstr "" + +msgid "" +"Firmware-reported records; these do not establish removable modules, " +"physical slots, memory channels, upgradeability or active ECC. Transfer " +"rates are firmware declarations, not live measurements." +msgstr "" + +msgid "Unknown (no explicit presence flag)" +msgstr "" + +msgid "Device reported present" +msgstr "" + +msgid "No device reported present" +msgstr "" + +msgid "%1 MT/s" +msgstr "" + +msgid "No PCI display controller reported" +msgstr "" + +msgid "GPU [%1]" +msgstr "" + +msgid "Capability: Not exposed by kernel" +msgstr "" + +msgid "Capability: Supported" +msgstr "" + +msgid "BAR %1 — Current allocation: %2; supported sizes: %3" +msgstr "" + +msgid "BIOS setting: Unknown" +msgstr "" + +msgid "Unknown (no hypervisor identity reported)" +msgstr "" + +msgid "No memory-device records reported" +msgstr "" + +msgid "Memory device %1 (firmware record %2)" +msgstr "" + +msgid "" +"Owner: %1\n" +"Issuer: %2\n" +"Expires: %3" +msgstr "" + +msgid "No X.509 certificates in the exposed MOK list" +msgstr "" + +msgid "%1 non-certificate signatures omitted" +msgstr "" + +msgid "UEFI" +msgstr "" + +msgid "UEFI not exposed (legacy boot or restricted environment)" +msgstr "" + +msgid "Reported hypervisor: %1" +msgstr "" + +msgid "%1 GiB" +msgstr "" + +msgid " (%1 MiB is system reserved)" +msgstr "" + +msgid "%1 days, %2 hours, %3 minutes" +msgstr "" + +msgid "Logical CPUs %1: %2" +msgstr "" + +msgid "data" +msgstr "" + +msgid "instruction" +msgstr "" + +msgid "unified" +msgstr "" + +msgid "%1 %2: %3 (%4 unique instances)" +msgstr "" + +msgid "" +"Firmware SecureBoot state; does not establish shim validation or kernel " +"lockdown policy." +msgstr "" + +msgid "" +"GPU capability and current OS resource allocation are separate from the " +"firmware setting. A small BAR is not proof of disabled ReBAR; a large BAR is " +"not proof of driver use. No portable BIOS Enabled/Disabled value is " +"inferred. Only sysfs metadata is read; BAR memory is never accessed." +msgstr "" + +msgid "" +"Kernel-exposed instruction-set and CPU-feature flags for processors listed " +"in /proc/cpuinfo. These include non-instruction features and may be limited " +"by the kernel, operating system or hypervisor. Different reported feature " +"lists remain separate; this is not a complete raw hardware capability " +"inventory." +msgstr "" + +msgid "" +"System reserved is reported physical RAM minus Linux MemTotal. It includes " +"all memory unavailable to Linux, not just firmware reservations. It is " +"omitted if physical capacity is unavailable or smaller than usable RAM." +msgstr "" + +msgid "Memory device %1" +msgstr "" + +msgid "" +"Exposed runtime MOK certificates. Issuer and expiration are certificate " +"metadata, not verification of the signer or proof that this key signed the " +"running kernel. Dates are UTC. Firmware db certificates and pending " +"enrollments are not included." +msgstr "" + +msgid "%1 UTC" +msgstr "" + +msgid "MOK Certificate" +msgstr "" + +msgid "MOK Certificate %1" +msgstr "" + +msgid "Same as owner" +msgstr "" + +msgid "Owner:" +msgstr "" + +msgid "Issuer:" +msgstr "" + +msgid "Expires:" +msgstr "" + +msgid "MOK certificates" +msgstr "" + +msgid "This computer" +msgstr "" + +msgid "Subsystem" +msgstr "" + +msgid "Raw name source" +msgstr "" + +msgid "Raw name" +msgstr "" + +msgid "Ancestor retained for connection context" +msgstr "" + +msgid "%1 (namespace %2)" +msgstr "" + +msgid "%1: %2 (driver)" +msgstr "" + +msgid "%1: %2" +msgstr "" + +msgid "Device: %1" +msgstr "" + +msgid "Owning module / built-in status undetermined" +msgstr "" + +msgid "%1 (kernel module)" +msgstr "" + +msgid "Parent-device driver: %1 — %2" +msgstr "" + +msgid "%1 — Properties" +msgstr "" + +msgid "Loading properties…" +msgstr "" + +msgid "Reload properties" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "ℹ️ Live udev events since the application started" +msgstr "" + +msgid "Event type" +msgstr "" + +msgid "Observed time" +msgstr "" + +msgid "Copy selection" +msgstr "" + +msgid "Copy all events" +msgstr "" + +msgid "Properties could not be refreshed: %1. Showing the previous snapshot." +msgstr "" + +msgid "" +"Device removed or replaced. This dialog is a read-only snapshot; close it " +"and reopen Properties for the current device." +msgstr "" + +msgid "General" +msgstr "" + +msgid "" +"Addresses belong to the application's network namespace. Reload properties " +"to refresh. Hardware MAC is the permanent address reported by the driver. An " +"overridden MAC is the current address when it differs from the permanent " +"address, or Linux reports a random, inherited or explicitly set address." +msgstr "" + +msgid "Copy network properties" +msgstr "" + +msgid "Network" +msgstr "" + +msgid "ℹ️ Network interface snapshot" +msgstr "" + +msgid "" +"Monitor information is reported by this connector’s cached EDID and kernel " +"mode list; it does not guarantee capabilities. Active settings are shown " +"separately below. Resolution and refresh rate describe the configured " +"physical mode. Desktop color depth is not the panel or link depth. Wayland " +"scale derived from logical size may include rounding; integer scale is a " +"fallback. Reload properties to refresh. Unmatched or ambiguous connector " +"names remain unavailable." +msgstr "" + +msgid "Copy display properties" +msgstr "" + +msgid "Display" +msgstr "" + +msgid "ℹ️ Monitor hardware and display configuration snapshot" +msgstr "" + +msgid "" +"Queries only: no capture, format changes or measured frame rates. Reload " +"properties to refresh. Rows preserve the format, size and frame interval " +"relationship. Size ranges do not imply a common rate list." +msgstr "" + +msgid "Camera" +msgstr "" + +msgid "Copy camera properties" +msgstr "" + +msgid "ℹ️ Driver-reported camera capabilities snapshot" +msgstr "" + +msgid "" +"Power information is declared by the active USB configuration, not measured " +"consumption. USB version and speed are also available as raw values on " +"Details. Reload properties to refresh." +msgstr "" + +msgid "Copy USB properties" +msgstr "" + +msgid "USB" +msgstr "" + +msgid "ℹ️ USB configuration snapshot" +msgstr "" + +msgid "" +"Current link readings can reflect power management and topology; a lower " +"speed is not a fault verdict. Device maxima do not establish the capacity of " +"the upstream path. Reading these sysfs attributes may temporarily runtime-" +"resume the device. Reload properties to refresh." +msgstr "" + +msgid "Copy PCIe properties" +msgstr "" + +msgid "PCIe" +msgstr "" + +msgid "ℹ️ PCIe link snapshot for this device" +msgstr "" + +msgid "" +"Readings are reported by the kernel and battery firmware. Capacity retention " +"is an estimate from compatible full/design values, not a complete health " +"assessment. Energy and charge units remain separate. Reload properties to " +"refresh; no background monitoring or battery settings are changed." +msgstr "" + +msgid "Copy Battery properties" +msgstr "" + +msgid "ℹ️ Battery snapshot for this device" +msgstr "" + +msgid "Details" +msgstr "" + +msgid "Events" +msgstr "" + +msgid "Resources" +msgstr "" + +msgid "Show _advanced properties" +msgstr "" + +msgid "_Property:" +msgstr "" + +msgid "Copy value" +msgstr "" + +msgid "Copy all values" +msgstr "" + +msgid "Resource type" +msgstr "" + +msgid "Setting" +msgstr "" + +msgid "Active" +msgstr "" + +msgid "PnP device state: %1" +msgstr "" + +msgid "Copy all resources" +msgstr "" + +msgid "" +"Read-only metadata snapshot. Each entity is a separate layer; capacities are " +"not added. Kernel and UDisks2 block records are merged only by their exact " +"device-number mapping. Source paths remain in tooltips and copied text. Deep " +"search includes native metadata only." +msgstr "" + +msgid "ℹ️ Storage snapshot for this device" +msgstr "" + +msgid "Copy storage overview" +msgstr "" + +msgid "Storage" +msgstr "" + +msgid "Content" +msgstr "" + +msgid "Device / label" +msgstr "" + +msgid "Layer" +msgstr "" + +msgid "Mount points" +msgstr "" + +msgid "No related volume metadata available." +msgstr "" + +msgid "ℹ️ Related volumes and mounts" +msgstr "" + +msgid "" +"Related volumes may span several drives. Mount points cover only the " +"application mount namespace. No filesystem is probed, mounted or unlocked." +msgstr "" + +msgid "Copy selected volume" +msgstr "" + +msgid "Copy all volumes" +msgstr "" + +msgid "Volumes" +msgstr "" + +msgid "Cached health unavailable." +msgstr "" + +msgid "Flags" +msgstr "" + +msgid "ID" +msgstr "" + +msgid "Interpreted value" +msgstr "" + +msgid "Name" +msgstr "" + +msgid "Threshold" +msgstr "" + +msgid "Worst" +msgstr "" + +msgid "ℹ️ Cached drive health" +msgstr "" + +msgid "" +"Cached UDisks2 health only, separate from device operational status. Reload " +"does not request SMART updates or self-tests. Missing evidence is " +"unavailable; no reported warning does not guarantee health. Related drives " +"are shown separately." +msgstr "" + +msgid "_Drive:" +msgstr "" + +msgid "Copy selected drive health" +msgstr "" + +msgid "Copy all drive health" +msgstr "" + +msgid "System Information" +msgstr "" + +msgid "Copy all" +msgstr "" + +msgid "Refresh" +msgstr "" + +msgid "Refreshing…" +msgstr "" + +msgid "Not collected" +msgstr "" + +msgid "System _Information" +msgstr "" + +msgid "_Quit" +msgstr "" + +msgid "_File" +msgstr "" + +msgid "_Refresh" +msgstr "" + +msgid "_Properties" +msgstr "" + +msgid "_Action" +msgstr "" + +msgid "_Filter" +msgstr "" + +msgid "Show _all devices" +msgstr "" + +msgid "Expand all" +msgstr "" + +msgid "Collapse all" +msgstr "" + +msgid "_View" +msgstr "" + +msgid "_About" +msgstr "" + +msgid "_Help" +msgstr "" + +msgid "Deep search" +msgstr "" + +msgid "" +"Search device identifiers, generic properties, resources and native storage " +"metadata. Specialized hardware-tab information is not fully indexed. Refresh " +"updates cached metadata." +msgstr "" + +msgid "_Filter:" +msgstr "" + +msgid "Show System Information" +msgstr "" + +msgid "Re-enumerate existing kernel devices; does not rescan buses." +msgstr "" + +msgid "Properties" +msgstr "" + +msgid "QLinuxDeviceManager" +msgstr "" + +msgid " — Administrative mode" +msgstr "" + +msgid "All device metadata" +msgstr "" + +msgid "%1 records unavailable or removed during enumeration" +msgstr "" + +msgid "Initializing…" +msgstr "" + +msgid "Refreshing devices…" +msgstr "" + +msgid "%1 shown / %2 discovered" +msgstr "" + +msgid " — Deep search: %1/%2" +msgstr "" + +msgid "; %1 property reads unavailable" +msgstr "" + +msgid " — Matched: %1" +msgstr "" + +msgid "" +"A read-only Linux hardware viewer.\n" +"Categories are application groupings, not native Linux device classes.\n" +"Device presence does not establish hardware health." +msgstr "" + +msgid "Waiting for device metadata reads to finish…" +msgstr "" diff --git a/po/update-pot.sh b/po/update-pot.sh new file mode 100755 index 0000000..552571b --- /dev/null +++ b/po/update-pot.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Regenerates po/qlinuxdevicemanager.pot from every tr("...") call in the Rust sources. +# Strings built at run time (tr(variable)) are not extracted; keep their literals in a +# tr("...") call at the point they are defined. +set -eu +cd "$(dirname "$0")/.." +find crates -name '*.rs' -not -path '*/tests/*' | sort | xargs xgettext \ + --language=Rust --keyword=tr --from-code=UTF-8 --add-comments=TRANSLATORS \ + --package-name=QLinuxDeviceManager --msgid-bugs-address= \ + --sort-by-file --no-location -o po/qlinuxdevicemanager.pot diff --git a/project.json b/project.json index 3b6fac7..7f96ffa 100644 --- a/project.json +++ b/project.json @@ -13,13 +13,10 @@ "license": "GPL-3.0" }, "resources": { - "embedded": "resources/embedded", - "root": "resources/external", - "translations": "translations" + "root": "resources/external" }, "platform": { "os": "Linux", - "minimum_qt": "6.2", - "cpp_standard": 17 + "language": "Rust" } } diff --git a/resources/embedded/projectmetadata.h.in b/resources/embedded/projectmetadata.h.in deleted file mode 100644 index 4cd3eaa..0000000 --- a/resources/embedded/projectmetadata.h.in +++ /dev/null @@ -1,4 +0,0 @@ -#pragma once -#define QLDM_NAME "@APP_NAME@" -#define QLDM_VERSION "@APP_VERSION@" -#define QLDM_REPOSITORY "@APP_REPOSITORY@" diff --git a/resources/external/translations/qlinuxdevicemanager_en.ts b/resources/external/translations/qlinuxdevicemanager_en.ts deleted file mode 100644 index 26fc5f4..0000000 --- a/resources/external/translations/qlinuxdevicemanager_en.ts +++ /dev/null @@ -1,2232 +0,0 @@ - - - - - Categories - - - Audio devices - - - - - Batteries and power supplies - - - - - Bluetooth - - - - - Cameras and video devices - - - - - Disk drives - - - - - Display adapters - - - - - Human interface devices - - - - - Keyboards - - - - - Mice and pointing devices - - - - - Display outputs - - - - - Network adapters - - - - - Processors - - - - - Storage controllers - - - - - System devices - - - - - USB controllers and devices - - - - - UEFI variables - - - - - - Other devices - - - - - DeviceFunctions - - - USB 1.1 root hub - - - - - USB 2.0 root hub - - - - - USB 3.x root hub - - - - - ACPI power source - - - - - ACPI processor device - - - - - Ambient light sensor - - - - - ACPI processor aggregator - - - - - ACPI time and alarm device - - - - - User presence detector - - - - - High precision event timer - - - - - PS/2 keyboard interface - - - - - PCI bus - - - - - PCI Express bus - - - - - Real-time clock - - - - - Motherboard resources - - - - - ACPI embedded controller - - - - - ACPI control-method battery - - - - - ACPI fan - - - - - ACPI power button - - - - - ACPI lid switch - - - - - ACPI sleep button - - - - - ACPI WMI interface - - - - - I2C human interface device - - - - - Display sensor - - - - - ACPI thermal zone - - - - - ACPI video controller - - - - - Intel CPU temperature monitor - - - - - EFI real-time clock interface - - - - - Kernel alarm timer - - - - - PCI Express advanced error reporting service - - - - - PCI Express bandwidth control service - - - - - PCI Express power-management event service - - - - - PCI Express hot-plug service - - - - - CPU microcode interface - - - - - Dummy voltage regulator - - - - - Wireless regulatory interface - - - - - Dummy SoC audio component - - - - - Serial port function - - - - - Serial controller function - - - - - Bluetooth host controller - - - - - DeviceLabels - - - AC power adapter - - - - - Battery - - - - - %1 battery - - - - - USB-C power supply (port %1) - - - - - USB power supply - - - - - Headphone jack detection - - - - - Microphone jack detection - - - - - HDMI/DisplayPort jack detection (PCM %1) - - - - - Touchpad (%1) - - - - - Mouse (%1) - - - - - %1 (module candidate) - - - - - DeviceModel - - - Subsystem - - - - - Raw name source - - - - - Raw name - - - - - Kernel name - - - - - Ancestor retained for connection context - - - - - Represented by - - - - - Grouped records - - - - - - This computer - - - - - %1 (namespace %2) - - - - - No directly bound driver - - - - - %1: %2 (driver) - - - - - %1: %2 - - - - - Device: %1 - - - - - Source: %1 - - - - - Owning module / built-in status undetermined - - - - - - %1 (kernel module) - - - - - Parent-device driver: %1 — %2 - - - - - Devices by type - - - - - Devices by connection - - - - - Devices by driver - - - - - Drivers by device - - - - - Drivers by type - - - - - Resources by type - - - - - Resources by connection - - - - - Enumerator - - - Could not create the udev context. - - - - - Could not enumerate kernel devices. - - - - - Live monitoring unavailable; retrying (F5 remains available) - - - - - Changing device branches are being reconciled - - - - - MainWindow - - - QLinuxDeviceManager - - - - - — Administrative mode - - - - - &Filter: - - - - - - Device name - - - - - Deep search - - - - - Search all metadata exposed by Properties, including advanced fields and numbers. Refresh updates cached metadata. - - - - - All device metadata - - - - - &File - - - - - System Information - - - - - &Quit - - - - - &Action - - - - - &Refresh - - - - - Re-enumerate existing kernel devices; does not rescan buses. - - - - - &Properties - - - - - &View - - - - - &Filter - - - - - Show &virtual and internal devices - - - - - Include virtual devices, partitions, interface endpoints and internal kernel objects. - - - - - Expand all - - - - - Collapse all - - - - - &Help - - - - - &About - - - - - About QLinuxDeviceManager - - - - - A read-only Linux hardware viewer. -Categories are application groupings, not native Linux device classes. -Device presence does not establish hardware health. -Licensed under GNU GPL version 3; see LICENSE. - - - - - Main toolbar - - - - - - Refreshing devices… - - - - - %1 records unavailable or removed during enumeration - - - - - %1 shown / %2 discovered - - - - - — Deep search: %1/%2 - - - - - ; %1 property reads unavailable - - - - - — Matched: %1 - - - - - Waiting for device metadata reads to finish… - - - - - PropertiesDialog - - - - - Device name - - - - - - Original name - - - - - - Name source - - - - - - - Category - - - - - - Full kernel path - - - - - - Parent device path - - - - - - Kernel name - - - - - - Bus / subsystem - - - - - - - Bound kernel driver - - - - - Represented by - - - - - Grouped records - - - - - - Device status - - - - - - - UEFI variable — hex dump - - - - - - PnP device state - - - - - PnP device disabled - - - - - - Truncated: first 65536 bytes shown. - - - - - Memory range - - - - - I/O range - - - - - IRQ - - - - - DMA channel - - - - - Bus range - - - - - BAR %1 - - - - - Expansion ROM - - - - - PCI resource %1 - - - - - Unassigned - - - - - - Disabled - - - - - Address unavailable (zeroed or masked) - - - - - Prefetchable - - - - - Read-only - - - - - 64-bit memory - - - - - Bridge window - - - - - Reported irq attribute; active mode not established - - - - - Resource metadata - - - - - Empty value - - - - - - - Unavailable - - - - - Permission denied - - - - - Device removed - - - - - Unsupported in this build - - - - - Not applicable - - - - - Read error (errno %1) - - - - - Application grouping - - - - - Manufacturer - - - - - Device udev metadata and direct driver bus - - - - - Location - - - - - Kernel path - - - - - Removed or replaced; showing a snapshot. - - - - - Present in the last inventory; properties have not been verified. - - - - - Present at the last property read. - - - - - No directly bound driver; this alone is not a fault. - - - - - Driver bound: %1 - - - - - Driver binding: %1 - - - - - USB device/interface is unauthorized. - - - - - USB device/interface is authorized. - - - - - USB authorization: %1 - - - - - Observed presence, binding and USB authorization only - - - - - No directly bound driver - - - - - Hardware identifiers - - - - - Selected udev and direct sysfs identifiers - - - - - Driver bus - - - - - Owning kernel module - - - - - Built-in or modular - - - - - Modular (kernel initstate evidence) - - - - - Undetermined; a missing module link/version does not prove a built-in driver. - - - - - Running module version - - - - - Installed module filename - - - - - Installed module version - - - - - Installed module description - - - - - Installed module author - - - - - Installed module license - - - - - Declared firmware names - - - - - Reported device firmware revision - - - - - Metadata scope - - - - - Installed metadata may differ from code already loaded before an update. Declared firmware names do not prove firmware is loaded or report its version. - - - - - Recorded ancestry - - - - - Direct driver path - - - - - Inventory: %1 - - - - - Inventory attribute: %1 - - - - - Last inventory; see name source for derived naming metadata - - - - - Empty file - - - - - Complete efivarfs file bytes, including the attribute prefix; contents are not decoded. - - - - - - Resources - - - - - Direct device resource metadata - - - - - Device addition observed - - - - - Device removal observed - - - - - Device change reported - - - - - Driver binding reported - - - - - Driver unbinding reported - - - - - Device path move reported - - - - - Device online event reported - - - - - Device offline event reported - - - - - udev action: %1 - - - - - Unavailable in event payload - - - - - Observed: %1 - - - - - Elapsed since monitor startup: %1 ms - - - - - Source: live udev observation - - - - - Action: %1 - - - - - Description: %1 - - - - - Kernel path: %1 - - - - - Subsystem: %1 - - - - - Device type: %1 - - - - - Driver in event payload: %1 - - - - - udev sequence: %1 - - - - - Initialization stamp: %1 - - - - - Local instance token: %1 - - - - - Previous path: %1 - - - - - %1 — Properties - - - - - - Loading properties… - - - - - General - - - - - Driver - - - - - ℹ️ Storage snapshot for this device - - - - - Copy storage overview - - - - - ℹ️ Related volumes and mounts - - - - - Device / label - - - - - Layer - - - - - Capacity - - - - - Content - - - - - Mount points - - - - - Copy selected volume - - - - - Copy all volumes - - - - - ℹ️ Cached drive health - - - - - &Drive: - - - - - Copy selected drive health - - - - - Copy all drive health - - - - - &Property: - - - - - Show &advanced properties - - - - - - - Copy selection - - - - - Copy value - - - - - Copy all values - - - - - - Details - - - - - Resource type - - - - - Setting - - - - - Copy all resources - - - - - ℹ️ Live udev events since the application started - - - - - Observed time - - - - - Event type - - - - - Description - - - - - Copy all events - - - - - Events - - - - - Reload properties - - - - - Properties could not be refreshed: %1. Showing the previous snapshot. - - - - - - Removed/replaced device: retained events are a read-only instance snapshot. - - - - - Device removed or replaced. This dialog is a read-only snapshot; close it and reopen Properties for the current device. - - - - - - - - Source: %1 - - - - - PnP device state: %1 - - - - - Active - - - - - Storage - - - - - Volumes - - - - - Health - - - - - Read-only metadata snapshot. Each entity is a separate layer; capacities are not added. Kernel and UDisks2 block records are merged only by their exact device-number mapping. Source paths remain in tooltips and copied text. Deep search includes native metadata only. - - - - - Related volumes may span several drives. Mount points cover only the application mount namespace. No filesystem is probed, mounted or unlocked. - - - - - Cached UDisks2 health only, separate from device operational status. Reload does not request SMART updates or self-tests. Missing evidence is unavailable; no reported warning does not guarantee health. Related drives are shown separately. - - - - - No related volume metadata available. - - - - - Cached health unavailable. - - - - - Live udev events since %1. Receipt times; exact device instance only. Latest %3 events per device; %2 retained globally. Historical logs are not queried. - - - - - monitor startup - - - - - %1 older events evicted from the global history. - - - - - %1 events could not be associated safely with an inventory instance. - - - - - %1 event-identity resets after monitor failures/losses; coverage is incomplete. - - - - - Live event monitoring is unavailable. Viewing and retained events remain available. - - - - - No safe live-event association is available for this instance. - - - - - EFI variables are reconciled by inventory scans; libudev does not supply their value-change history. - - - - - No retained live events for this instance. This does not establish that the device has had no errors. - - - - - Storage - - - Available; cached properties only - - - - - Application mount namespace only; other namespaces may differ. No filesystem is mounted or unlocked. - - - - - No mount observed in the application namespace - - - - - Invalid capacity or sector-size metadata - - - - - %1 bytes - - - - - %1 bytes (%2) - - - - - Yes - - - - - No - - - - - Invalid boolean metadata - - - - - Removable (UDisks2 hint) - - - - - Fixed (UDisks2 hint) - - - - - Invalid removable metadata - - - - - - - - Unavailable - - - - - %1 °C (cached) - - - - - Failure predicted by drive (cached) - - - - - No failure predicted by drive (cached; not a guarantee of health) - - - - - No critical warnings reported (cached; not a guarantee of health) - - - - - Storage snapshot - - - - - Each entity is a separate storage layer; capacities are not added. Health is cached UDisks2 evidence, separate from operational status. Reload never requests SMART updates or self-tests. Deep search includes native metadata only. - - - - - Storage entity - - - - - Source: %1 - - - - - - - - - UDisks2 metadata - - - - - - Related storage metadata - - - - - UDisks2 block metadata - - - - - Drive manufacturer - - - - - Drive model - - - - - Drive serial number - - - - - Drive firmware revision - - - - - Drive transport - - - - - Drive capacity - - - - - Removable/fixed hint - - - - - Drive media removable - - - - - Drive media available - - - - - - Content usage - - - - - - Filesystem / content type - - - - - Filesystem / content version - - - - - - Filesystem / content label - - - - - - Filesystem / content UUID - - - - - - Partition number - - - - - Partition type - - - - - Partition offset - - - - - Partition capacity - - - - - Partition name - - - - - Partition UUID - - - - - - Partition table type - - - - - RAID name - - - - - - RAID level - - - - - RAID UUID - - - - - RAID member count - - - - - Logical volume name - - - - - Logical volume type - - - - - Volume group name - - - - - ATA SMART supported - - - - - ATA SMART enabled - - - - - Cached health updated - - - - - Cached temperature - - - - - Cached ATA failure prediction - - - - - Cached bad sectors - - - - - Cached failing attributes - - - - - Cached NVMe critical warnings - - - - - - Cached health - - - - - Storage relationship - - - - - Storage inventory - - - - - Partition of - - - - - Backed by - - - - - Used by - - - - - - Storage relationships - - - - - Mount metadata - - - - - Device node - - - - - Major:minor - - - - - Block capacity - - - - - Logical sector size - - - - - Physical sector size - - - - - Kernel removable media - - - - - Block read-only - - - - - Partition start (512-byte sectors) - - - - - Disk model - - - - - Manufacturer - - - - - Serial number - - - - - Firmware revision - - - - - Transport - - - - - Device-mapper name - - - - - Device-mapper UUID - - - - - RAID state - - - - - RAID missing members - - - - - - Native transport - - - - - Hardware model - - - - - Hardware vendor - - - - - Hardware revision - - - - - Hardware serial - - - - - Hardware firmware revision - - - - - Mount points (application namespace) - - - - - Controller model - - - - - Controller serial number - - - - - Controller firmware revision - - - - - Controller namespace / partition - - - - - Storage topology changed during collection - - - - - SystemDialog - - - System Information - - - - - Not collected - - - - - System - - - - - Hostname - - - - - Operating system - - - - - Kernel architecture - - - - - Kernel version - - - - - Kernel build - - - - - Boot mode - - - - - Virtualization environment - - - - - Uptime - - - - - Hardware and firmware - - - - - System manufacturer - - - - - System model - - - - - Motherboard manufacturer - - - - - Motherboard model - - - - - Firmware version - - - - - Firmware date (reported) - - - - - Physical RAM - - - - - Usable RAM - - - - - Processor - - - - - CPU model(s) - - - - - Sockets (reported topology) - - - - - Physical cores (reported topology) - - - - - Logical CPUs present - - - - - Logical CPUs online - - - - - CPU caches (online CPUs) - - - - - Copy all - - - - - - Refresh - - - - - Refreshing… - - - - - Unknown (no hypervisor identity reported) - - - - - UEFI - - - - - UEFI not exposed (legacy boot or restricted environment) - - - - - Reported hypervisor: %1 - - - - - %1 GiB - - - - - (%1 MiB is system reserved) - - - - - %1 days, %2 hours, %3 minutes - - - - - data - - - - - instruction - - - - - unified - - - - - %1 %2: %3 (%4 unique instances) - - - - - System reserved is reported physical RAM minus Linux MemTotal. It includes all memory unavailable to Linux, not just firmware reservations. It is omitted if physical capacity is unavailable or smaller than usable RAM. - - - - diff --git a/resources/external/translations/update.sh b/resources/external/translations/update.sh deleted file mode 100755 index 83d8796..0000000 --- a/resources/external/translations/update.sh +++ /dev/null @@ -1 +0,0 @@ -lupdate-qt6 ../../.. -ts qlinuxdevicemanager_en.ts diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..40d36bf --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 160 +use_small_heuristics = "Max" diff --git a/src/devices/cameraproperties.cpp b/src/devices/cameraproperties.cpp deleted file mode 100644 index 5053f62..0000000 --- a/src/devices/cameraproperties.cpp +++ /dev/null @@ -1,309 +0,0 @@ -#include "cameraproperties.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -extern char **environ; -namespace { -constexpr int reportLimit = 1024 * 1024; -constexpr int modeLimit = 2048; -constexpr unsigned enumerationLimit = 128; -constexpr int queryLimit = 4096; -constexpr int budgetMs = 2000; -struct Descriptor { - int fd; - explicit Descriptor(int value) : fd(value) {} - ~Descriptor() { if (fd >= 0) close(fd); } - Descriptor(const Descriptor &) = delete; - Descriptor &operator=(const Descriptor &) = delete; -}; -Attribute failure(int error) -{ - return {error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENODEV || error == ENOENT || error == ENXIO ? ReadState::Removed - : error == ENOTTY || error == EOPNOTSUPP || error == EINVAL ? ReadState::Unsupported : ReadState::Error, {}, error}; -} -QJsonObject failed(int error) { return {{"error", error}}; } -bool validText(const __u8 *value, size_t size) -{ - const char *data = reinterpret_cast(value); - const size_t length = strnlen(data, size); - if (length == size) return false; - const QByteArray bytes(data, static_cast(length)); - return QString::fromUtf8(bytes).toUtf8() == bytes; -} -QString text(const __u8 *value, size_t size) -{ - const char *data = reinterpret_cast(value); - const size_t length = strnlen(data, size); - return length == size ? QString() : QString::fromUtf8(data, static_cast(length)); -} -QJsonArray fraction(const v4l2_fract &value) -{ - return {static_cast(value.numerator), static_cast(value.denominator)}; -} -bool validFraction(const v4l2_fract &value) { return value.numerator && value.denominator; } -bool ordered(const v4l2_fract &a, const v4l2_fract &b) -{ - return static_cast(a.numerator) * b.denominator <= static_cast(b.numerator) * a.denominator; -} -class Query { -public: - explicit Query(int fd) : fd_(fd) { timer_.start(); } - template bool get(unsigned long request, T &value) { - if (++calls_ > queryLimit) { error = EOVERFLOW; return false; } - for (;;) { - if (timer_.elapsed() >= budgetMs) { error = ETIMEDOUT; return false; } - // Unregistered fd: documented libv4l2 passthrough, no converter setup, - // emulated formats, software controls or capture initialization. - if (v4l2_ioctl(fd_, request, &value) == 0) { error = 0; return true; } - error = errno; - if (error != EINTR) return false; - } - } - int error = 0; -private: - int fd_; - int calls_ = 0; - QElapsedTimer timer_; -}; -QJsonObject intervals(Query &query, __u32 format, __u32 width, __u32 height) -{ - QJsonArray discrete; - for (unsigned index = 0; index <= enumerationLimit; ++index) { - v4l2_frmivalenum value {}; - value.index = index; value.pixel_format = format; value.width = width; value.height = height; - if (!query.get(VIDIOC_ENUM_FRAMEINTERVALS, value)) { - if (query.error == EINVAL && index > 0) return {{"kind", "discrete"}, {"values", discrete}}; - return failed(query.error); - } - if (value.index != index || value.pixel_format != format || value.width != width || value.height != height) - return failed(EPROTO); - if (index == enumerationLimit) return failed(EOVERFLOW); - if (value.type == V4L2_FRMIVAL_TYPE_DISCRETE) { - if (!validFraction(value.discrete)) return failed(EPROTO); - discrete.append(fraction(value.discrete)); - } else if (index == 0 && (value.type == V4L2_FRMIVAL_TYPE_STEPWISE || value.type == V4L2_FRMIVAL_TYPE_CONTINUOUS)) { - const auto &range = value.stepwise; - if (!validFraction(range.min) || !validFraction(range.max) || !ordered(range.min, range.max) - || (value.type == V4L2_FRMIVAL_TYPE_STEPWISE && !validFraction(range.step))) return failed(EPROTO); - return {{"kind", value.type == V4L2_FRMIVAL_TYPE_CONTINUOUS ? "continuous" : "stepwise"}, - {"min", fraction(range.min)}, {"max", fraction(range.max)}, {"step", fraction(range.step)}}; - } else return failed(EPROTO); - } - return failed(EOVERFLOW); -} -QJsonObject queryDevice(int directory, const QString &path, const QString &node) -{ - struct stat selected {}; - if (fstat(directory, &selected) != 0) return failed(errno); - if (!S_ISDIR(selected.st_mode)) return failed(EPROTO); - Descriptor devFile(openat(directory, "dev", O_RDONLY | O_CLOEXEC | O_NOFOLLOW)); - if (devFile.fd < 0) return failed(errno); - char dev[65] {}; - const ssize_t length = read(devFile.fd, dev, 64); - if (length <= 0 || length == 64) return failed(length < 0 ? errno : EPROTO); - const QStringList numbers = QString::fromLatin1(dev, static_cast(length)).trimmed().split(':'); - bool majorOk = false, minorOk = false; - const uint deviceMajor = numbers.value(0).toUInt(&majorOk), deviceMinor = numbers.value(1).toUInt(&minorOk); - if (numbers.size() != 2 || !majorOk || !minorOk) return failed(EPROTO); - const QByteArray name = QFile::encodeName(node); - Descriptor device(open(name.constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW)); - if (device.fd < 0) return failed(errno); - struct stat opened {}; - if (fstat(device.fd, &opened) != 0) return failed(errno); - if (!S_ISCHR(opened.st_mode) || major(opened.st_rdev) != deviceMajor || minor(opened.st_rdev) != deviceMinor) - return failed(ENODEV); - const QString binding = QString("/sys/dev/char/%1:%2").arg(deviceMajor).arg(deviceMinor); - const auto sameDevice = [&] { - const QByteArray sysPath = QFile::encodeName(path); - struct stat current {}, nodeNow {}; - return stat(sysPath.constData(), ¤t) == 0 && current.st_dev == selected.st_dev && current.st_ino == selected.st_ino - && lstat(name.constData(), &nodeNow) == 0 && S_ISCHR(nodeNow.st_mode) - && nodeNow.st_rdev == opened.st_rdev && nodeNow.st_ino == opened.st_ino && nodeNow.st_dev == opened.st_dev - && QFileInfo(binding).canonicalFilePath() == path; - }; - if (!sameDevice()) return failed(ENODEV); - Query query(device.fd); - v4l2_capability capability {}; - if (!query.get(VIDIOC_QUERYCAP, capability)) return failed(query.error); - if (!validText(capability.card, sizeof(capability.card)) - || !validText(capability.driver, sizeof(capability.driver)) - || !validText(capability.bus_info, sizeof(capability.bus_info))) return failed(EPROTO); - const __u32 caps = capability.capabilities & V4L2_CAP_DEVICE_CAPS ? capability.device_caps : capability.capabilities; - if (!(caps & (V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_VIDEO_CAPTURE_MPLANE)) - || (caps & (V4L2_CAP_VIDEO_M2M | V4L2_CAP_VIDEO_M2M_MPLANE))) return {{"applicable", false}}; - QJsonObject result {{"applicable", true}, {"card", text(capability.card, sizeof(capability.card))}, - {"driver", text(capability.driver, sizeof(capability.driver))}, {"bus", text(capability.bus_info, sizeof(capability.bus_info))}}; - QJsonArray modes; - int collectionError = 0; - for (__u32 type : {V4L2_BUF_TYPE_VIDEO_CAPTURE, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE}) { - if (!(caps & (type == V4L2_BUF_TYPE_VIDEO_CAPTURE ? V4L2_CAP_VIDEO_CAPTURE : V4L2_CAP_VIDEO_CAPTURE_MPLANE))) continue; - QVector formats; - for (unsigned index = 0; index <= enumerationLimit; ++index) { - v4l2_fmtdesc format {}; format.type = type; format.index = index; - if (!query.get(VIDIOC_ENUM_FMT, format)) { - if (query.error != EINVAL || index == 0) collectionError = query.error; - break; - } - if (!validText(format.description, sizeof(format.description))) { collectionError = EPROTO; break; } - if (format.type != type || format.index != index || !format.pixelformat) { collectionError = EPROTO; break; } - if (index == enumerationLimit) { collectionError = EOVERFLOW; break; } - formats.append(format); - } - for (const auto &format : formats) { - QJsonObject base {{"queue", type == V4L2_BUF_TYPE_VIDEO_CAPTURE ? "single" : "multi"}, - {"format", static_cast(format.pixelformat)}, {"description", text(format.description, sizeof(format.description))}, - {"flags", static_cast(format.flags)}}; - QVector sizes; - for (unsigned index = 0; index <= enumerationLimit; ++index) { - v4l2_frmsizeenum size {}; size.index = index; size.pixel_format = format.pixelformat; - if (!query.get(VIDIOC_ENUM_FRAMESIZES, size)) { - if (query.error != EINVAL || index == 0) { QJsonObject row = base; row["size_error"] = query.error; sizes.append(row); } - break; - } - if (index == enumerationLimit) { collectionError = EOVERFLOW; break; } - if (size.index != index || size.pixel_format != format.pixelformat) { collectionError = EPROTO; break; } - QJsonObject row = base; - if (size.type == V4L2_FRMSIZE_TYPE_DISCRETE) { - if (!size.discrete.width || !size.discrete.height) { collectionError = EPROTO; break; } - row["size_kind"] = "discrete"; row["width"] = static_cast(size.discrete.width); row["height"] = static_cast(size.discrete.height); - } else if (index == 0 && (size.type == V4L2_FRMSIZE_TYPE_STEPWISE || size.type == V4L2_FRMSIZE_TYPE_CONTINUOUS)) { - const auto &range = size.stepwise; - if (!range.min_width || !range.min_height || range.min_width > range.max_width || range.min_height > range.max_height - || !range.step_width || !range.step_height - || (size.type == V4L2_FRMSIZE_TYPE_CONTINUOUS && (range.step_width != 1 || range.step_height != 1))) { collectionError = EPROTO; break; } - row["size_kind"] = size.type == V4L2_FRMSIZE_TYPE_CONTINUOUS ? "continuous" : "stepwise"; - row["width"] = static_cast(range.min_width); row["height"] = static_cast(range.min_height); - row["max_width"] = static_cast(range.max_width); row["max_height"] = static_cast(range.max_height); - row["step_width"] = static_cast(range.step_width); row["step_height"] = static_cast(range.step_height); - } else { collectionError = EPROTO; break; } - sizes.append(row); - if (size.type != V4L2_FRMSIZE_TYPE_DISCRETE) break; - } - for (QJsonObject row : sizes) { - // Ranges stay ranges: never imply one rate list applies to every size. - if (row.value("size_kind") == "discrete") - row["intervals"] = intervals(query, format.pixelformat, static_cast<__u32>(row.value("width").toInteger()), static_cast<__u32>(row.value("height").toInteger())); - if (modes.size() == modeLimit) { collectionError = EOVERFLOW; break; } - modes.append(row); - } - if (query.error == ETIMEDOUT || query.error == EOVERFLOW) collectionError = query.error; - if (modes.size() == modeLimit) collectionError = EOVERFLOW; - if (query.error == ETIMEDOUT || query.error == EOVERFLOW || modes.size() == modeLimit) break; - } - if (query.error == ETIMEDOUT || query.error == EOVERFLOW || modes.size() == modeLimit) break; - } - if (!sameDevice()) return failed(ENODEV); - result["modes"] = modes; - result["error"] = collectionError; - return result; -} -} // namespace - -int runCameraQueryHelper(int directory, const QString &path, const QString &node) -{ - // If the GUI dies, do not leave an ordinary helper running independently. - if (prctl(PR_SET_PDEATHSIG, SIGKILL) != 0 || getppid() == 1) return 1; - QByteArray bytes = QJsonDocument(queryDevice(directory, path, node)).toJson(QJsonDocument::Compact); - if (bytes.size() > reportLimit) bytes = QJsonDocument(failed(EOVERFLOW)).toJson(QJsonDocument::Compact); - qsizetype offset = 0; - while (offset < bytes.size()) { - const ssize_t count = write(STDOUT_FILENO, bytes.constData() + offset, static_cast(bytes.size() - offset)); - if (count < 0 && errno == EINTR) continue; - if (count <= 0) return 1; - offset += count; - } - return 0; -} - -CameraProperties collectCameraProperties(int directory, const Device &device, const QString &node) -{ - CameraProperties result; result.applicable = true; result.node = node; - if (node.isEmpty()) { result.state = {}; return result; } - if (!node.startsWith("/dev/") || node.size() > 4096 || device.path.size() > 4096 || node.contains(QChar::Null)) { - result.state = failure(EPROTO); return result; - } - // One properties worker serializes requests. Never accumulate stuck helpers. - static pid_t pending = -1; - if (pending > 0) { - const pid_t reaped = waitpid(pending, nullptr, WNOHANG); - if (reaped == 0 || (reaped < 0 && errno == EINTR)) { result.state = failure(EAGAIN); return result; } - pending = -1; - } - int pipes[2]; - if (pipe2(pipes, O_CLOEXEC | O_NONBLOCK) != 0) { result.state = failure(errno); return result; } - Descriptor input(pipes[0]), output(pipes[1]); - Descriptor inherited(fcntl(directory, F_DUPFD_CLOEXEC, 64)); - if (inherited.fd < 0) { result.state = failure(errno); return result; } - // Only stdout is nonblocking in the parent; helper writes use a blocking pipe. - if (fcntl(output.fd, F_SETFL, 0) < 0) { result.state = failure(errno); return result; } - const QByteArray executable = QFile::encodeName(QCoreApplication::applicationFilePath()); - const QByteArray path = QFile::encodeName(device.path), dev = QFile::encodeName(node), fd = QByteArray::number(inherited.fd); - char *arguments[] = {const_cast(executable.constData()), const_cast("--camera-query"), - const_cast(path.constData()), const_cast(dev.constData()), const_cast(fd.constData()), nullptr}; - posix_spawn_file_actions_t actions; - int error = posix_spawn_file_actions_init(&actions); - if (error) { result.state = failure(error); return result; } - error = posix_spawn_file_actions_adddup2(&actions, inherited.fd, inherited.fd); - if (!error) error = posix_spawn_file_actions_adddup2(&actions, output.fd, STDOUT_FILENO); - if (!error && input.fd != STDOUT_FILENO) error = posix_spawn_file_actions_addclose(&actions, input.fd); - if (!error && output.fd != STDOUT_FILENO) error = posix_spawn_file_actions_addclose(&actions, output.fd); - pid_t child = -1; - if (!error) error = posix_spawn(&child, executable.constData(), &actions, nullptr, arguments, environ); - posix_spawn_file_actions_destroy(&actions); - if (error) { result.state = failure(error); return result; } - close(output.fd); output.fd = -1; - QElapsedTimer timer; timer.start(); - QByteArray bytes; - bool eof = false; - int exitStatus = 0; - bool exited = false; - while (!eof || !exited) { - if (QThread::currentThread()->isInterruptionRequested()) { error = ECANCELED; break; } - if (timer.elapsed() >= budgetMs) { error = ETIMEDOUT; break; } - char buffer[4096]; - const ssize_t count = read(input.fd, buffer, sizeof(buffer)); - if (count > 0) { bytes.append(buffer, static_cast(count)); if (bytes.size() > reportLimit) { error = EOVERFLOW; break; } } - else if (count == 0) eof = true; - else if (errno != EAGAIN && errno != EINTR) { error = errno; break; } - if (!exited) { - const pid_t reaped = waitpid(child, &exitStatus, WNOHANG); - if (reaped == child) exited = true; - else if (reaped < 0 && errno != EINTR) { error = errno; break; } - } - if (!eof && count <= 0) { pollfd wait {input.fd, POLLIN, 0}; poll(&wait, 1, 25); } - else if (eof && !exited) { poll(nullptr, 0, 10); } - } - if (error) { - if (!exited) { kill(child, SIGKILL); if (waitpid(child, nullptr, WNOHANG) != child) pending = child; } - result.state = failure(error); return result; - } - if (!WIFEXITED(exitStatus) || WEXITSTATUS(exitStatus) != 0) { result.state = failure(EPROTO); return result; } - QJsonParseError parse; - const QJsonDocument document = QJsonDocument::fromJson(bytes, &parse); - if (parse.error != QJsonParseError::NoError || !document.isObject()) { result.state = failure(EPROTO); return result; } - result.report = document.object(); - if (result.report.contains("applicable")) result.applicable = result.report.value("applicable").toBool(); - const int reportError = result.report.value("error").toInt(); - result.state = reportError ? failure(reportError) : Attribute {ReadState::Available, {}, 0}; - return result; -} diff --git a/src/devices/cameraproperties.h b/src/devices/cameraproperties.h deleted file mode 100644 index d2b09d7..0000000 --- a/src/devices/cameraproperties.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once -#include "device.h" -#include - -struct CameraProperties { - bool applicable = false; - Attribute state; - QString node; - // Owned, bounded helper report: each mode retains its queue/format/size/interval relation. - QJsonObject report; -}; -CameraProperties collectCameraProperties(int directory, const Device &device, const QString &node); -// Private headless entry point, before QApplication; never captures or sets a format. -int runCameraQueryHelper(int directory, const QString &path, const QString &node); diff --git a/src/devices/cpumetadata.cpp b/src/devices/cpumetadata.cpp deleted file mode 100644 index 00bfc00..0000000 --- a/src/devices/cpumetadata.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include "cpumetadata.h" - -#include -#include -#include -#include - -QHash parseCpuModels(const QString &cpuinfo) -{ - QHash models; - QHash record; - const auto commit = [&] { - bool valid = false; - const int cpu = record.value("processor").toInt(&valid); - if (!valid || cpu < 0) return; - for (const char *key : {"model name", "cpu model", "uarch"}) { - const QString value = record.value(QLatin1String(key)); - if (!value.isEmpty()) { models.insert(cpu, value); break; } - } - }; - for (const QString &line : cpuinfo.split('\n')) { - if (line.trimmed().isEmpty()) { commit(); record.clear(); continue; } - const int colon = line.indexOf(':'); - if (colon < 0) continue; - const QString key = line.left(colon).trimmed(); - // Only per-processor keys. Never assign a board's global Hardware string to a CPU. - if (key == "processor" || key == "model name" || key == "cpu model" || key == "uarch") - record.insert(key, line.mid(colon + 1).trimmed()); - } - commit(); - return models; -} - -Attribute readCpuInfo() -{ - Attribute result; - QFile file(QStringLiteral("/proc/cpuinfo")); - if (!file.open(QIODevice::ReadOnly)) { - result.state = file.error() == QFileDevice::PermissionsError - ? ReadState::PermissionDenied : ReadState::Unavailable; - return result; - } - constexpr qint64 limit = 4 * 1024 * 1024; - QByteArray contents; - while (contents.size() <= limit) { - if (QThread::currentThread()->isInterruptionRequested()) { - result.state = ReadState::Error; - result.error = ECANCELED; - return result; - } - const QByteArray chunk = file.read(qMin(65536, limit + 1 - contents.size())); - if (file.error() != QFileDevice::NoError) { - result.state = ReadState::Error; - return result; - } - if (chunk.isEmpty()) { - result.state = ReadState::Available; - result.value = QString::fromUtf8(contents); - return result; - } - contents.append(chunk); - } - result.state = ReadState::Error; - result.error = EOVERFLOW; - return result; // A truncated file must not become partially authoritative CPU metadata. -} diff --git a/src/devices/cpumetadata.h b/src/devices/cpumetadata.h deleted file mode 100644 index d132fbc..0000000 --- a/src/devices/cpumetadata.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once -#include "device.h" - -QHash parseCpuModels(const QString &cpuinfo); -Attribute readCpuInfo(); diff --git a/src/devices/device.cpp b/src/devices/device.cpp deleted file mode 100644 index dc068f9..0000000 --- a/src/devices/device.cpp +++ /dev/null @@ -1,199 +0,0 @@ -#include "device.h" -#include "functionnames.h" -#include "devicelabel.h" - -#include -#include - -const QVector &categories() -{ - static const QVector list = { - {"audio", QT_TRANSLATE_NOOP("Categories", "Audio devices"), "audio-card"}, - {"battery", QT_TRANSLATE_NOOP("Categories", "Batteries and power supplies"), "battery"}, - {"bluetooth", QT_TRANSLATE_NOOP("Categories", "Bluetooth"), "bluetooth"}, - {"camera", QT_TRANSLATE_NOOP("Categories", "Cameras and video devices"), "camera-web"}, - {"disk", QT_TRANSLATE_NOOP("Categories", "Disk drives"), "drive-harddisk"}, - {"display", QT_TRANSLATE_NOOP("Categories", "Display adapters"), "video-display"}, - {"hid", QT_TRANSLATE_NOOP("Categories", "Human interface devices"), "input-gaming"}, - {"keyboard", QT_TRANSLATE_NOOP("Categories", "Keyboards"), "input-keyboard"}, - {"mouse", QT_TRANSLATE_NOOP("Categories", "Mice and pointing devices"), "input-mouse"}, - {"monitor", QT_TRANSLATE_NOOP("Categories", "Display outputs"), "video-display"}, - {"network", QT_TRANSLATE_NOOP("Categories", "Network adapters"), "network-wired"}, - {"cpu", QT_TRANSLATE_NOOP("Categories", "Processors"), "cpu"}, - {"storage", QT_TRANSLATE_NOOP("Categories", "Storage controllers"), "drive-harddisk"}, - {"system", QT_TRANSLATE_NOOP("Categories", "System devices"), "computer"}, - {"usb", QT_TRANSLATE_NOOP("Categories", "USB controllers and devices"), "drive-removable-media-usb"}, - {"efi", QT_TRANSLATE_NOOP("Categories", "UEFI variables"), "preferences-system"}, - {"other", QT_TRANSLATE_NOOP("Categories", "Other devices"), "preferences-system"} - }; - return list; -} -QString categoryLabel(const QString &id) -{ - for (const auto &c : categories()) - if (id == QLatin1String(c.id)) - return QCoreApplication::translate("Categories", c.label); - return QCoreApplication::translate("Categories", "Other devices"); -} -QString categoryIcon(const QString &id) -{ - for (const auto &c : categories()) - if (id == QLatin1String(c.id)) - return QString::fromLatin1(c.icon); - return QStringLiteral("preferences-system"); -} -void classify(Device &d) -{ - const QString &s = d.subsystem; - const QString &n = d.sysname; - const auto flag = [&d](const char *key) { return d.properties.value(QLatin1String(key)) == "1"; }; - d.category = "other"; - d.hidden = d.path.startsWith("/sys/devices/virtual/"); - if (s == "pci") { - bool ok = false; - const uint code = d.properties.value("PCI_CLASS").toUInt(&ok, 16); - const uint base = code >> 16; - const uint sub = (code >> 8) & 0xff; - if (ok && base == 1) d.category = "storage"; - else if (ok && base == 2) d.category = "network"; - else if (ok && base == 3) d.category = "display"; - else if (ok && base == 4) d.category = "audio"; - else if (ok && base == 0x0c && sub == 3) d.category = "usb"; - else d.category = "system"; - } else if (s == "usb") { - d.category = "usb"; - d.hidden = d.hidden || d.devtype != "usb_device"; - } else if (s == "block") { - d.category = "disk"; - d.hidden = d.hidden || d.devtype != "disk"; - } else if (s == "input") { - if (isAudioJackSwitch(d)) { d.category = "audio"; d.hidden = true; } - else if (flag("ID_INPUT_KEYBOARD")) d.category = "keyboard"; - else if (flag("ID_INPUT_MOUSE") || flag("ID_INPUT_TOUCHPAD") || flag("ID_INPUT_POINTINGSTICK")) d.category = "mouse"; - else d.category = "hid"; - // Keep inputN functions; event/js/mouse endpoints are redundant in the type view. - d.hidden = d.hidden || !n.startsWith("input"); - } else if (s == "hid" || s == "hidraw") { - d.category = "hid"; - d.hidden = true; - } else if (s == "sound") { - d.category = "audio"; - d.hidden = d.hidden || !n.startsWith("card"); - } else if (s == "drm") { - const bool connector = n.contains('-'); - d.category = connector ? "monitor" : "display"; - const Attribute status = d.attributes.value("status"); - const Attribute enabled = d.attributes.value("enabled"); - // Keep connected/configured outputs visible. Only hide a physical - // connector when both reads establish it is disconnected and disabled; - // unknown or failed reads must not masquerade as an unused output. - const bool unused = status.state == ReadState::Available && status.value == "disconnected" - && enabled.state == ReadState::Available && enabled.value == "disabled"; - d.hidden = d.hidden || !connector || unused; - } else if (s == "net") d.category = "network"; - else if (s == "bluetooth") { - d.category = "bluetooth"; - d.hidden = d.hidden || !n.startsWith("hci") || n.contains(':'); - } else if (s == "video4linux") d.category = "camera"; - else if (s == "power_supply") d.category = "battery"; - else if (s == "cpu") d.category = "cpu"; - else if (s == "nvme" || s == "scsi_host" || s == "ata_port") { - d.category = "storage"; - d.hidden = d.hidden || s != "nvme"; - } else if (s == "platform" || s == "acpi" || s == "pnp") { - d.category = "system"; - d.hidden = d.hidden || (s == "acpi" && d.driver.isEmpty()); - } else { - // Unrecognized directly bound physical functions remain useful fallbacks. - d.hidden = d.hidden || d.driver.isEmpty(); - } -} -void nameDevice(Device &d) -{ - d.nameTranslated = false; - d.nameCandidate = false; - const auto attributeName = [&d](const QString &key) { - const auto it = d.attributes.constFind(key); - if (it == d.attributes.cend() || it->state != ReadState::Available || it->value.isEmpty()) return false; - d.name = it->value; - d.nameSource = QStringLiteral("sysfs: %1/%2").arg(d.path, key); - return true; - }; - if (d.subsystem == "cpu") { - const auto model = d.attributes.value("cpu_model"); - if (model.state == ReadState::Available && !model.value.isEmpty()) { - d.name = model.value; - d.nameSource = QStringLiteral("/proc/cpuinfo: processor %1").arg(d.sysname.mid(3)); - return; - } - } - // Function names take priority over generic USB receiver/camera model names. - if ((d.subsystem == "input" || d.subsystem == "video4linux") && attributeName("name")) return; - if (d.subsystem == "input") { - QString name = d.properties.value("NAME"); - if (name.startsWith('"') && name.endsWith('"') && name.size() >= 2) name = name.mid(1, name.size() - 2); - if (!name.isEmpty()) { d.name = name; d.nameSource = "udev: NAME"; return; } - } - if (d.subsystem == "video4linux") { - const QString name = d.properties.value("ID_V4L_PRODUCT"); - if (!name.isEmpty()) { d.name = name; d.nameSource = "udev: ID_V4L_PRODUCT"; return; } - } - if (d.subsystem == "nvme" && attributeName("model")) return; - if (d.subsystem == "hdaudio") { - const auto chip = d.attributes.value("chip_name"); - const auto vendor = d.attributes.value("vendor_name"); - if (chip.state == ReadState::Available && !chip.value.isEmpty()) { - d.name = chip.value; - if (vendor.state == ReadState::Available && !vendor.value.isEmpty()) d.name.prepend(vendor.value + ' '); - d.nameSource = QStringLiteral("sysfs: %1/chip_name and available vendor_name").arg(d.path); - return; - } - } - // Replace generic database root-hub text only for verified Linux root hubs. - if (d.subsystem == "usb") { - const FunctionName rootHub = knownFunctionName(d); - if (!rootHub.label.isEmpty()) { - d.name = rootHub.label; - d.nameSource = rootHub.source; - d.nameTranslated = true; - return; - } - } - for (const char *key : {"ID_MODEL_FROM_DATABASE", "ID_MODEL", "NAME"}) { - if (std::strcmp(key, "ID_MODEL") == 0 && d.subsystem == "usb" - && d.devtype == "usb_device" && attributeName("product")) return; - const QString value = d.properties.value(QLatin1String(key)); - if (!value.isEmpty()) { - d.name = value; - d.nameSource = d.propertySources.value(QLatin1String(key), QStringLiteral("udev: %1").arg(QLatin1String(key))); - return; - } - } - if (d.subsystem == "sound" && attributeName("id")) return; - if (d.subsystem == "power_supply" && attributeName("model_name")) return; - const FunctionName function = knownFunctionName(d); - if (!function.label.isEmpty()) { - d.name = function.label; - d.nameSource = function.source; - d.nameTranslated = true; - return; - } - d.name = d.sysname.isEmpty() ? d.path : d.sysname; - d.nameSource = "kernel name"; - // Describe unresolved bound functions using real binding evidence, without - // claiming that a module/driver name is the hardware's marketing model. - if (!d.driver.isEmpty() && d.subsystem != "cpu") { - d.name = QStringLiteral("%1 — %2").arg(d.driver, d.name); - d.nameSource = "direct kernel driver and kernel name"; - } -} -void reconcile(QVector &next, const QVector &previous, quint64 &generation) -{ - QHash old; - for (const auto &d : previous) old.insert(d.path, &d); - for (auto &d : next) { - const Device *before = old.value(d.path, nullptr); - d.generation = before && before->incarnation == d.incarnation && before->eventInstance == d.eventInstance - ? before->generation : ++generation; - } -} diff --git a/src/devices/device.h b/src/devices/device.h deleted file mode 100644 index ff2f7fe..0000000 --- a/src/devices/device.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once -#include "deviceevents.h" - -#include -#include -#include -#include -#include -#include - -struct DeviceResources; - -enum class ReadState { Available, Unavailable, PermissionDenied, Removed, Error, Unsupported, NotApplicable }; -struct Attribute { - ReadState state = ReadState::Unavailable; - QString value; - int error = 0; -}; - -struct Device { - QString path; // Canonical current-instance lookup key, not a hardware ID. - QString parentPath; - QString subsystem; - QString sysname; - QString devtype; - QString driver; // Direct binding only; never inherited. - QString driverModule; // Observed driver/module link; absence is undetermined. - QHash properties; - QHash attributes; - QString name; - QString nameSource; - bool nameTranslated = false; // Static function caption; translate only in the view. - bool nameCandidate = false; // An installed-module alias match, not a binding claim. - QString representedByPath; // Presentation grouping only, never a new ownership edge. - QHash propertySources; - QString category; // Stable, untranslated application category ID. - QString incarnation; // Directory identity plus optional udev initialization stamp. - quint64 eventInstance = 0; // Session-local live-event association token; zero is unassociated. - quint64 generation = 0; // Assigned by the GUI's inventory reconciliation. - bool hidden = false; - std::shared_ptr resources; // Immutable resource snapshot; worker-collected. -}; - -struct Inventory { - QVector devices; - QString error; - int skipped = 0; - quint64 request = 0; - QSet removedPaths; // Includes descendants when an ancestor was removed. - bool identityLost = false; // Event loss: no old generation can be trusted. - QString monitorNote; - DeviceEventsSnapshot events; // Owned, bounded snapshot from the monitor worker. -}; -Q_DECLARE_METATYPE(Inventory) - -struct Category { - const char *id; - const char *label; - const char *icon; -}; -const QVector &categories(); -QString categoryLabel(const QString &id); -QString categoryIcon(const QString &id); -void classify(Device &device); -void nameDevice(Device &device); -void reconcile(QVector &next, const QVector &previous, quint64 &generation); diff --git a/src/devices/deviceevents.cpp b/src/devices/deviceevents.cpp deleted file mode 100644 index a157376..0000000 --- a/src/devices/deviceevents.cpp +++ /dev/null @@ -1,98 +0,0 @@ -#include "deviceevents.h" -#include "device.h" -#include -#include - -DeviceEventHistory::DeviceEventHistory(const QDateTime &started) -{ - snapshot_.started = started; -} -DeviceEventHistory::Identity *DeviceEventHistory::identity(const QString &path) -{ - auto found = identities_.find(path); - if (found != identities_.end()) return &found.value(); - if (identities_.size() >= IdentityLimit) return nullptr; - return &identities_.insert(path, {}).value(); -} -void DeviceEventHistory::retire(const QString &path) -{ - for (auto it = identities_.begin(); it != identities_.end(); ++it) - if (it.key() == path || it.key().startsWith(path + '/')) it->retired = true; -} -void DeviceEventHistory::observe(DeviceEvent event) -{ - if (!event.path.startsWith("/sys/devices/") || event.path.size() > 4096) return; - // Bound all caller-owned strings too, including fixture/future collectors. - for (QString *value : {&event.path, &event.action, &event.subsystem, &event.devtype, - &event.driver, &event.initialized, &event.incarnation, &event.oldPath}) - value->truncate(4096); - event.instance = 0; // Only this tracker may assign an association. - Identity *current = identity(event.path); - const bool stale = current && event.sequence && current->sequence && event.sequence <= current->sequence; - if (stale) current = nullptr; // Reordered/duplicate payloads cannot change identity state. - if (current) { - const bool stampChanged = !current->initialized.isEmpty() && !event.initialized.isEmpty() - && current->initialized != event.initialized; - const bool inodeChanged = !current->incarnation.isEmpty() && !event.incarnation.isEmpty() - && current->incarnation != event.incarnation; - if (event.action == "add") { - if (!current->token || current->retired || stampChanged || inodeChanged) - *current = {++nextToken_, event.incarnation, event.initialized, false, event.sequence}; - event.instance = current->token; - } else if (current->token && !stampChanged && !inodeChanged && (!current->retired || event.action == "remove")) { - // Removal may have no readable inode, but can use a known stream - // identity. A late event with conflicting evidence stays unassociated. - event.instance = current->token; - } else if (!current->token && event.action != "remove" && !event.incarnation.isEmpty()) { - *current = {++nextToken_, event.incarnation, event.initialized, false, event.sequence}; - event.instance = current->token; - } - if (event.sequence > current->sequence) current->sequence = event.sequence; - if (event.action == "remove" && !stampChanged && !inodeChanged) retire(event.path); - } - if (!stale && !event.oldPath.isEmpty() && event.oldPath != event.path) retire(event.oldPath); - if (!event.instance) ++snapshot_.unassociated; - if (snapshot_.records.size() == DeviceEventsSnapshot::Limit) { - snapshot_.records.removeFirst(); - ++snapshot_.evicted; - } - snapshot_.records.append(std::move(event)); -} -void DeviceEventHistory::lose() -{ - identities_.clear(); - ++snapshot_.gaps; -} -void DeviceEventHistory::reconcile(QVector &devices) -{ - QSet present; - for (Device &device : devices) { - present.insert(device.path); - Identity *current = identity(device.path); - device.eventInstance = 0; - if (!current) continue; - const QString initialized = device.properties.value("USEC_INITIALIZED"); - const bool same = current->token && !current->retired - && ((!current->incarnation.isEmpty() && current->incarnation == device.incarnation) - || (current->incarnation.isEmpty() && !current->initialized.isEmpty() - && current->initialized == initialized)); - if (!same) *current = {++nextToken_, device.incarnation, initialized, false, 0}; - else { - current->incarnation = device.incarnation; - current->initialized = initialized; - } - device.eventInstance = current->token; - } - // Missing/withheld records are not allowed to retain live associations. - // Their already-observed events remain in the bounded ring for old dialogs. - for (auto it = identities_.begin(); it != identities_.end();) { - if (!present.contains(it.key())) it = identities_.erase(it); - else ++it; - } -} -DeviceEventsSnapshot DeviceEventHistory::snapshot(bool monitoring) const -{ - DeviceEventsSnapshot result = snapshot_; - result.monitoring = monitoring; - return result; -} diff --git a/src/devices/deviceevents.h b/src/devices/deviceevents.h deleted file mode 100644 index 699d511..0000000 --- a/src/devices/deviceevents.h +++ /dev/null @@ -1,60 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -struct Device; - -// An owned udev observation, not evidence of hardware health. Wall time is the -// receipt time; monotonic elapsed time and sequence retain observation ordering. -struct DeviceEvent { - QDateTime observed; - qint64 elapsedMs = 0; - quint64 sequence = 0; - quint64 instance = 0; - QString path; - QString action; - QString subsystem; - QString devtype; - QString driver; - QString initialized; - QString incarnation; - QString oldPath; -}; -struct DeviceEventsSnapshot { - static constexpr int Limit = 1024; - static constexpr int PerDeviceLimit = 256; - QDateTime started; - QVector records; - quint64 evicted = 0; - quint64 unassociated = 0; - quint64 gaps = 0; - bool monitoring = false; -}; - -// Enumerator-thread ownership only. Tokens are session-local, independent of -// paths and serial numbers. Monitor gaps retire every association conservatively. -class DeviceEventHistory { -public: - static constexpr int IdentityLimit = 16384; - explicit DeviceEventHistory(const QDateTime &started = QDateTime::currentDateTime()); - void observe(DeviceEvent event); - void lose(); - void reconcile(QVector &devices); - DeviceEventsSnapshot snapshot(bool monitoring) const; -private: - struct Identity { - quint64 token = 0; - QString incarnation; - QString initialized; - bool retired = false; - quint64 sequence = 0; // Last observed sequence for this path; holes are normal. - }; - Identity *identity(const QString &path); - void retire(const QString &path); - QHash identities_; - DeviceEventsSnapshot snapshot_; - quint64 nextToken_ = 0; -}; diff --git a/src/devices/devicelabel.cpp b/src/devices/devicelabel.cpp deleted file mode 100644 index a7940e6..0000000 --- a/src/devices/devicelabel.cpp +++ /dev/null @@ -1,120 +0,0 @@ -#include "devicelabel.h" -#include "efivariables.h" -#include -#include -#include - -namespace { -QString caption(const char *text) -{ - return QCoreApplication::translate("DeviceLabels", text); -} -QString unquote(QString value) -{ - if (value.size() >= 2 && value.startsWith('"') && value.endsWith('"')) - value = value.mid(1, value.size() - 2); - return value; -} -QString supplyField(const Device &d, const char *attribute, const char *property) -{ - const auto it = d.attributes.constFind(QLatin1String(attribute)); - if (it != d.attributes.cend()) - return it->state == ReadState::Available ? it->value.trimmed() : QString(); - return d.properties.value(QLatin1String(property)).trimmed(); -} -QString friendlyLabel(const Device &d) -{ - if (d.subsystem == "power_supply") { - const QString type = supplyField(d, "type", "POWER_SUPPLY_TYPE"); - if (type == "Mains") return caption(QT_TRANSLATE_NOOP("DeviceLabels", "AC power adapter")); - if (type == "Battery") { - const QString vendor = supplyField(d, "manufacturer", "POWER_SUPPLY_MANUFACTURER"); - QString model = supplyField(d, "model_name", "POWER_SUPPLY_MODEL_NAME"); - // Firmware role placeholders add no useful model identity. - if (model.compare("Primary", Qt::CaseInsensitive) == 0 - || model.compare("Secondary", Qt::CaseInsensitive) == 0 - || model.compare("Battery", Qt::CaseInsensitive) == 0) model.clear(); - QString label = vendor.isEmpty() ? caption(QT_TRANSLATE_NOOP("DeviceLabels", "Battery")) - : caption(QT_TRANSLATE_NOOP("DeviceLabels", "%1 battery")).arg(vendor); - if (!model.isEmpty()) label += QStringLiteral(" (%1)").arg(model); - return label; - } - if (type == "USB") { - // UCSI names append the connector number to the controller's name. - // Match the recorded direct parent, not an ambiguous trailing digit. - const QString controller = d.parentPath.section('/', -1); - const QString prefix = "ucsi-source-psy-" + controller; - bool valid = false; - const QString suffix = d.sysname.startsWith(prefix) ? d.sysname.mid(prefix.size()) : QString(); - const uint port = suffix.toUInt(&valid); - if (!controller.isEmpty() && valid && port > 0) - return caption(QT_TRANSLATE_NOOP("DeviceLabels", "USB-C power supply (port %1)")).arg(port); - return caption(QT_TRANSLATE_NOOP("DeviceLabels", "USB power supply")); - } - } - if (isAudioJackSwitch(d)) { - const QString name = inputDeviceName(d); - if (name.endsWith(" Headphone") || name == "Headphone") - return caption(QT_TRANSLATE_NOOP("DeviceLabels", "Headphone jack detection")); - if (name.endsWith(" Mic") || name == "Mic") - return caption(QT_TRANSLATE_NOOP("DeviceLabels", "Microphone jack detection")); - static const QRegularExpression pcm(QStringLiteral("HDMI/DP,pcm=([0-9]+)$")); - return caption(QT_TRANSLATE_NOOP("DeviceLabels", "HDMI/DisplayPort jack detection (PCM %1)")) - .arg(pcm.match(inputDeviceName(d)).captured(1)); - } - if (d.subsystem == "input") { - // Shorten only the kernel-generated I2C HID identity form. Product names - // from USB/Bluetooth and descriptive input names remain untouched. - static const QRegularExpression generated(QStringLiteral( - "^([A-Z0-9]{4,8}:[0-9]{2}) ([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4}) (Mouse|Touchpad)$")); - const auto match = generated.match(inputDeviceName(d)); - const QStringList product = d.properties.value("PRODUCT").split('/'); - bool valid = false; - const uint bus = product.size() == 4 ? product[0].toUInt(&valid, 16) : 0; - if (match.hasMatch() && valid && bus == 0x18) { - bool vendorValid = false, productValid = false; - const uint vendor = product[1].toUInt(&vendorValid, 16); - const uint id = product[2].toUInt(&productValid, 16); - if (!vendorValid || !productValid || vendor != match.captured(2).toUInt(nullptr, 16) - || id != match.captured(3).toUInt(nullptr, 16)) return {}; - if (match.captured(4) == "Touchpad" && d.properties.value("ID_INPUT_TOUCHPAD") == "1") - return caption(QT_TRANSLATE_NOOP("DeviceLabels", "Touchpad (%1)")).arg(match.captured(1)); - if (match.captured(4) == "Mouse" && d.properties.value("ID_INPUT_MOUSE") == "1" - && d.properties.value("ID_INPUT_POINTINGSTICK") != "1") - return caption(QT_TRANSLATE_NOOP("DeviceLabels", "Mouse (%1)")).arg(match.captured(1)); - } - } - return {}; -} -} -QString inputDeviceName(const Device &d) -{ - const auto it = d.attributes.constFind("name"); - if (it != d.attributes.cend() && it->state == ReadState::Available && !it->value.isEmpty()) return it->value; - return unquote(d.properties.value("NAME")); -} -bool isAudioJackSwitch(const Device &d) -{ - static const QRegularExpression name(QStringLiteral("(?:^| )(?:HDMI/DP,pcm=[0-9]+|Headphone|Mic)$")); - return d.subsystem == "input" && d.properties.value("ID_INPUT_SWITCH") == "1" - && unquote(d.properties.value("PHYS")) == "ALSA" - && d.properties.value("ID_INPUT_KEYBOARD") != "1" - && d.properties.value("ID_INPUT_MOUSE") != "1" - && d.properties.value("ID_INPUT_TOUCHPAD") != "1" - && d.properties.value("ID_INPUT_POINTINGSTICK") != "1" - && name.match(inputDeviceName(d)).hasMatch(); -} -QString deviceDisplayName(const Device &d, bool includeIdentifiers) -{ - if (d.subsystem == "efivarfs") { - const QString label = efiVariableLabel(d.name); - return includeIdentifiers ? label + QStringLiteral(" [%1]").arg(d.properties.value("EFI_VENDOR_GUID")) : label; - } - QString label = friendlyLabel(d); - if (!label.isEmpty()) - return includeIdentifiers ? label + QStringLiteral(" [%1]").arg(d.sysname) : label; - if (d.name == d.sysname || d.nameSource == "direct kernel driver and kernel name") return d.name; - label = d.nameTranslated ? QCoreApplication::translate("DeviceFunctions", d.name.toUtf8().constData()) : d.name; - if (d.nameCandidate) label = caption(QT_TRANSLATE_NOOP("DeviceLabels", "%1 (module candidate)")).arg(label); - return label + QStringLiteral(" [%1]").arg(d.sysname); -} diff --git a/src/devices/devicelabel.h b/src/devices/devicelabel.h deleted file mode 100644 index 0bc7a1b..0000000 --- a/src/devices/devicelabel.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once -#include "device.h" - -// Presentation only: retain raw inventory names and perform no metadata reads. -QString inputDeviceName(const Device &device); -bool isAudioJackSwitch(const Device &device); -QString deviceDisplayName(const Device &device, bool includeIdentifiers = false); diff --git a/src/devices/devicepresentation.cpp b/src/devices/devicepresentation.cpp deleted file mode 100644 index 81a5e2e..0000000 --- a/src/devices/devicepresentation.cpp +++ /dev/null @@ -1,111 +0,0 @@ -#include "devicepresentation.h" -#include "devicelabel.h" -#include - -namespace { -bool pciClass(const Device &d, uint base, int subclass = -1) -{ - if (d.subsystem != "pci") return false; - bool valid = false; - const uint code = d.properties.value("PCI_CLASS").toUInt(&valid, 16); - return valid && (code >> 16) == base - && (subclass < 0 || ((code >> 8) & 0xff) == static_cast(subclass)); -} -// Follow recorded udev parents to the nearest requested subsystem. PCI callers -// stop at the first function, so an outer controller cannot represent its child. -int ancestor(const Device &d, const QVector &devices, const QHash &lookup, - const char *subsystem, const char *devtype = nullptr) -{ - QString path = d.parentPath; - QSet visited; - while (!path.isEmpty() && !visited.contains(path)) { - visited.insert(path); - const auto entry = lookup.constFind(path); - if (entry == lookup.cend()) return -1; - const Device &parent = devices.at(entry.value()); - if (parent.subsystem == QLatin1String(subsystem) - && (!devtype || parent.devtype == QLatin1String(devtype))) return entry.value(); - path = parent.parentPath; - } - return -1; -} -void usbNetworkName(Device &network, const Device &usb) -{ - // Match the owning USB device's database identity, falling back to its - // readable descriptors. Do not decode sanitized ID_MODEL by guessing what - // underscores originally represented, or inherit a hub's/neighbor's name. - QString model = usb.properties.value("ID_MODEL_FROM_DATABASE").trimmed(); - QString modelSource = usb.propertySources.value("ID_MODEL_FROM_DATABASE", "udev: ID_MODEL_FROM_DATABASE"); - if (model.isEmpty()) { - const Attribute product = usb.attributes.value("product"); - if (product.state != ReadState::Available || product.value.trimmed().isEmpty()) return; - model = product.value.trimmed(); - modelSource = "sysfs: " + usb.path + "/product"; - } - QString vendor = usb.properties.value("ID_VENDOR_FROM_DATABASE").trimmed(); - QString vendorSource = usb.propertySources.value("ID_VENDOR_FROM_DATABASE", "udev: ID_VENDOR_FROM_DATABASE"); - if (vendor.isEmpty()) { - const Attribute manufacturer = usb.attributes.value("manufacturer"); - if (manufacturer.state == ReadState::Available) vendor = manufacturer.value.trimmed(); - vendorSource = "sysfs: " + usb.path + "/manufacturer"; - } - const bool alreadyNamed = !vendor.isEmpty() && model.startsWith(vendor, Qt::CaseInsensitive) - && (model.size() == vendor.size() || !model.at(vendor.size()).isLetterOrNumber()); - network.name = vendor.isEmpty() || alreadyNamed ? model : vendor + ' ' + model; - network.nameSource = "USB device " + usb.path + ": " + modelSource; - if (!vendor.isEmpty() && !alreadyNamed) network.nameSource += "; " + vendorSource; - network.nameTranslated = false; - network.nameCandidate = false; -} -} - -void refinePresentation(QVector &devices) -{ - QHash lookup; - for (int i = 0; i < devices.size(); ++i) lookup.insert(devices.at(i).path, i); - QHash> functions; - for (int i = 0; i < devices.size(); ++i) { - if (devices.at(i).subsystem == "net") { - const int usb = ancestor(devices.at(i), devices, lookup, "usb", "usb_device"); - // Stop at the nearest USB device even if it has no readable name; - // never continue outward to a hub or host controller for a label. - if (usb >= 0 && !devices.at(usb).sysname.startsWith("usb")) - usbNetworkName(devices[i], devices.at(usb)); - } - const Device &d = devices.at(i); - if (isAudioJackSwitch(d)) { - const int card = ancestor(d, devices, lookup, "sound"); - if (card >= 0) devices[i].representedByPath = devices.at(card).path; - } - if (d.hidden || (d.subsystem != "net" && d.subsystem != "nvme" && d.subsystem != "sound")) continue; - const int parent = ancestor(d, devices, lookup, "pci"); - if (parent < 0 || devices.at(parent).hidden) continue; - if (d.subsystem == "net" && pciClass(devices.at(parent), 2)) { - functions[parent].append(i); - } else if (d.subsystem == "sound" && d.sysname.startsWith("card") && pciClass(devices.at(parent), 4)) { - functions[parent].append(i); - } else if (d.subsystem == "nvme" && pciClass(devices.at(parent), 1, 8)) { - // PCI function represents the controller; keep namespace disks separately. - devices[i].hidden = true; - devices[i].representedByPath = devices.at(parent).path; - } - } - for (auto it = functions.cbegin(); it != functions.cend(); ++it) { - if (it.value().size() == 1) { - const int child = it.value().front(); - devices[child].hidden = true; - devices[child].representedByPath = devices.at(it.key()).path; - } else { - // Multiple cards/ports need their independently useful function rows. - // Suppress the extra bus row, never collapse distinct cards/interfaces. - devices[it.key()].hidden = true; - } - } - // A single ALSA card may itself be grouped under its PCI audio function. - for (auto &d : devices) { - if (!isAudioJackSwitch(d)) continue; - const auto card = lookup.constFind(d.representedByPath); - if (card != lookup.cend() && !devices.at(card.value()).representedByPath.isEmpty()) - d.representedByPath = devices.at(card.value()).representedByPath; - } -} diff --git a/src/devices/devicepresentation.h b/src/devices/devicepresentation.h deleted file mode 100644 index 756246c..0000000 --- a/src/devices/devicepresentation.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once -#include "device.h" - -// Cross-record presentation rules; never delete inventory or alter ancestry/binding. -void refinePresentation(QVector &devices); diff --git a/src/devices/deviceproperties.cpp b/src/devices/deviceproperties.cpp deleted file mode 100644 index 697f2a8..0000000 --- a/src/devices/deviceproperties.cpp +++ /dev/null @@ -1,633 +0,0 @@ -#include "deviceproperties.h" -#include "directoryscan.h" -#include -#ifdef QLDM_HAVE_KMOD -#include -#endif -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -constexpr int textLimit = 4096; -constexpr int efiLimit = 65536; -struct Descriptor { - int fd = -1; - explicit Descriptor(int value) : fd(value) {} - ~Descriptor() { if (fd >= 0) close(fd); } - Descriptor(const Descriptor &) = delete; - Descriptor &operator=(const Descriptor &) = delete; -}; -Attribute failure(int error) -{ - Attribute a; - a.error = error; - a.state = error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENODEV ? ReadState::Removed - : error == ENOENT || error == ENODATA ? ReadState::Unavailable : ReadState::Error; - return a; -} -Attribute available(const QString &value) { return {ReadState::Available, value, 0}; } -Attribute boundedText(const char *value) -{ - if (!value) return {}; - const size_t length = strnlen(value, textLimit + 1); - return length > textLimit ? failure(EOVERFLOW) - : available(QString::fromUtf8(value, static_cast(length))); -} -QByteArray readBytes(int fd, int limit, int &error) -{ - QByteArray bytes; - char buffer[4096]; - error = 0; - while (bytes.size() <= limit && !QThread::currentThread()->isInterruptionRequested()) { - const int wanted = qMin(static_cast(sizeof(buffer)), limit + 1 - static_cast(bytes.size())); - const ssize_t count = read(fd, buffer, wanted); - if (count < 0) { - if (errno == EINTR) continue; - error = errno; - return {}; - } - if (count == 0) break; - bytes.append(buffer, static_cast(count)); - } - if (QThread::currentThread()->isInterruptionRequested()) { error = ECANCELED; return {}; } - return bytes; -} -Attribute readText(int directory, const char *name) -{ - Descriptor file(openat(directory, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)); - if (file.fd < 0) return failure(errno); - int error = 0; - const QByteArray bytes = readBytes(file.fd, textLimit, error); - if (error) return failure(error); - if (bytes.size() > textLimit) return failure(EOVERFLOW); - return available(QString::fromUtf8(bytes).trimmed()); -} -Attribute linkTarget(int directory, const char *name) -{ - char buffer[textLimit + 1]; - const ssize_t size = readlinkat(directory, name, buffer, sizeof(buffer)); - if (size < 0) return failure(errno); - if (size > textLimit) return failure(EOVERFLOW); - return available(QString::fromUtf8(buffer, static_cast(size))); -} -QString inodeIdentity(const struct stat &info) -{ - return QStringLiteral("%1:%2").arg(static_cast(info.st_dev)) - .arg(static_cast(info.st_ino)); -} -void put(DeviceProperties &result, const QString &id, const Attribute &value, const QString &source) -{ - result.values.insert(id, value); - result.sources.insert(id, source); -} -void sysfs(DeviceProperties &result, int fd, const char *key) -{ - const QString name = QLatin1String(key); - put(result, "sysfs/" + name, readText(fd, key), result.device.path + '/' + name); -} -Attribute pcieDeviceType(int directory, QString &source) -{ - // Ordinary sysfs link reads remain usable without administrative privileges. - // Never escalate or attempt a configuration-space read for a normal user. - if (geteuid() != 0) return failure(EACCES); - Descriptor config(openat(directory, "config", O_RDONLY | O_CLOEXEC | O_NOFOLLOW)); - if (config.fd < 0) return failure(errno); - const auto readValue = [&](int offset, int size, unsigned int &value) -> Attribute { - if (QThread::currentThread()->isInterruptionRequested()) return failure(ECANCELED); - unsigned char bytes[2] {}; - const ssize_t count = pread(config.fd, bytes, size, offset); - if (count < 0) return failure(errno); - if (count != size) return failure(EMSGSIZE); - value = bytes[0] | (size == 2 ? static_cast(bytes[1]) << 8 : 0); - return available(QString()); - }; - unsigned int status = 0, header = 0, pointer = 0; - Attribute state = readValue(PCI_STATUS, 2, status); - if (state.state != ReadState::Available) return state; - if (status == 0xffff) return failure(ENODEV); - if (!(status & PCI_STATUS_CAP_LIST)) return failure(ENODATA); - state = readValue(PCI_HEADER_TYPE, 1, header); - if (state.state != ReadState::Available) return state; - header &= PCI_HEADER_TYPE_MASK; - if (header != PCI_HEADER_TYPE_NORMAL && header != PCI_HEADER_TYPE_BRIDGE - && header != PCI_HEADER_TYPE_CARDBUS) return failure(EPROTO); - state = readValue(header == PCI_HEADER_TYPE_CARDBUS ? PCI_CB_CAPABILITY_LIST : PCI_CAPABILITY_LIST, 1, pointer); - if (state.state != ReadState::Available) return state; - // Follow only standard capability headers and the two-byte Express flags. - // At most 48 aligned entries in bytes 0x40..0xff; no extended-space scan, - // capability payload dump, BAR access, or write to the device. - bool visited[256] {}; - for (int entries = 0; pointer && entries < 48; ++entries) { - if (pointer < 0x40 || pointer > 0xfc || (pointer & 3) || visited[pointer]) - return failure(EPROTO); - visited[pointer] = true; - unsigned int capability = 0; - state = readValue(static_cast(pointer), 2, capability); - if (state.state != ReadState::Available) return state; - if ((capability & 0xff) == PCI_CAP_ID_EXP) { - unsigned int flags = 0; - state = readValue(static_cast(pointer) + PCI_EXP_FLAGS, 2, flags); - if (state.state != ReadState::Available) return state; - if (flags == 0xffff || !(flags & PCI_EXP_FLAGS_VERS)) return failure(EPROTO); - source += QStringLiteral("; PCI Express flags at offset 0x%1").arg(pointer + PCI_EXP_FLAGS, 2, 16, QLatin1Char('0')); - return available(QString::number((flags & PCI_EXP_FLAGS_TYPE) >> 4)); - } - pointer = capability >> 8; - } - return failure(pointer ? EPROTO : ENODATA); -} -void pcieProperties(DeviceProperties &result, int fd) -{ - if (result.device.subsystem != "pci") return; - QHash values; - bool applicable = false; - for (const char *key : {"current_link_speed", "current_link_width", "max_link_speed", "max_link_width"}) { - if (QThread::currentThread()->isInterruptionRequested()) return; - Attribute value = readText(fd, key); - // Absent files alone do not establish PCIe applicability. Preserve actual - // read failures so a present but unreadable PCIe device still has a tab. - if (value.state == ReadState::Available || value.error != ENOENT) - applicable = true; - if (value.error == EOPNOTSUPP || value.error == ENOSYS) - value.state = ReadState::Unsupported; - values.insert(QLatin1String(key), value); - } - if (!applicable) return; - result.pcieApplicable = true; - for (auto it = values.cbegin(); it != values.cend(); ++it) - put(result, "sysfs/" + it.key(), it.value(), result.device.path + '/' + it.key()); - result.pcieTypeSource = result.device.path + "/config"; - result.pcieTypeRequiresRoot = geteuid() != 0; - result.pcieType = pcieDeviceType(fd, result.pcieTypeSource); -} -void batteryProperties(DeviceProperties &result, int fd) -{ - if (result.device.subsystem != "power_supply") return; - Attribute type = result.values.value("sysfs/type"); - if (type.error == EOPNOTSUPP || type.error == ENOSYS) type.state = ReadState::Unsupported; - const Attribute previousType = result.device.attributes.value("type"); - // Inventory evidence controls only whether a failed snapshot gets a tab. - // It must not replace an unreadable current type or supply battery values. - if (type.state == ReadState::Available && type.value != "Battery") return; - if (type.state != ReadState::Available - && (previousType.state != ReadState::Available || previousType.value != "Battery")) return; - BatteryProperties &battery = result.battery; - battery.applicable = true; - battery.values.insert("type", type); - if (type.state != ReadState::Available) { battery.state = type; return; } - battery.state = available(QString()); - for (const char *key : {"manufacturer", "model_name", "serial_number", "present"}) - battery.values.insert(QLatin1String(key), result.values.value(QStringLiteral("sysfs/") + QLatin1String(key))); - const Attribute present = battery.values.value("present"); - if (present.state == ReadState::Available && present.value != "0" && present.value != "1") { - battery.state = failure(EINVAL); - return; - } - const bool absent = present.state == ReadState::Available && present.value == "0"; - for (const char *key : {"status", "technology", "cycle_count", "capacity", "voltage_now", - "energy_full_design", "energy_full", "energy_now", - "charge_full_design", "charge_full", "charge_now", "current_now", "power_now"}) { - if (QThread::currentThread()->isInterruptionRequested()) { - battery.state = failure(ECANCELED); - battery.values.clear(); - return; - } - Attribute value = absent ? Attribute {ReadState::NotApplicable, {}, 0} : readText(fd, key); - if (value.error == EOPNOTSUPP || value.error == ENOSYS) value.state = ReadState::Unsupported; - battery.values.insert(QLatin1String(key), value); - } - // A power_supply node can remain while a removable battery disappears. - // Reject changed presence/type evidence rather than presenting mixed values. - const auto same = [](const Attribute &a, const Attribute &b) { - return a.state == b.state && a.value == b.value && a.error == b.error; - }; - const Attribute afterType = readText(fd, "type"); - const Attribute afterPresent = readText(fd, "present"); - if (!same(type, afterType) || !same(present, afterPresent)) { - battery.values.clear(); - if (afterType.state != ReadState::Available) battery.state = afterType; - else if (!same(present, afterPresent) && afterPresent.state != ReadState::Available) battery.state = afterPresent; - else battery.state = failure(EAGAIN); - if (battery.state.error == EOPNOTSUPP || battery.state.error == ENOSYS) - battery.state.state = ReadState::Unsupported; - } -} -void usbProperties(DeviceProperties &result, int fd) -{ - const Device &device = result.device; - if (device.subsystem != "usb" || device.devtype != "usb_device") return; - UsbProperties &usb = result.usb; - for (const char *key : {"bConfigurationValue", "bNumConfigurations", "configuration", - "bNumInterfaces", "bmAttributes", "bMaxPower"}) - usb.values.insert(QLatin1String(key), readText(fd, key)); - const Attribute configuration = usb.values.value("bConfigurationValue"); - const bool unconfigured = configuration.state == ReadState::Available - && (configuration.value.isEmpty() || configuration.value == "0"); - if (unconfigured) { - for (const char *key : {"configuration", "bNumInterfaces", "bmAttributes", "bMaxPower"}) - usb.values.insert(QLatin1String(key), {ReadState::NotApplicable, {}, 0}); - usb.interfaceState = {ReadState::NotApplicable, {}, 0}; - } - // Only direct USB interface children, never descendant devices behind hubs. - const DirectoryNames names = unconfigured ? DirectoryNames() - : boundedDirectoryNames(device.path, 256, {device.sysname + ":*"}); - if (!unconfigured) usb.interfaceState = names.error ? failure(names.error) : available(QString()); - for (const QString &name : names.names) { - if (QThread::currentThread()->isInterruptionRequested()) { - usb.interfaces.clear(); - usb.interfaceState = failure(ECANCELED); - break; - } - UsbInterfaceProperties interface; - interface.name = name; - interface.path = device.path + '/' + name; - const QByteArray encoded = name.toUtf8(); - Descriptor child(openat(fd, encoded.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)); - struct stat before {}; - const int readError = child.fd < 0 ? errno : (fstat(child.fd, &before) != 0 ? errno : 0); - if (readError) { - interface.values.insert("state", failure(readError)); - } else { - const Attribute driver = linkTarget(child.fd, "driver"); - for (const char *key : {"bInterfaceNumber", "bAlternateSetting", "bInterfaceClass", - "bInterfaceSubClass", "bInterfaceProtocol", "interface"}) - interface.values.insert(QLatin1String(key), readText(child.fd, key)); - Attribute binding = driver; - if (binding.state == ReadState::Available) binding.value = QFileInfo(binding.value).fileName(); - else if (binding.state == ReadState::Unavailable && binding.error == ENOENT) binding = available(QString()); - interface.values.insert("driver", binding); - const Attribute afterDriver = linkTarget(child.fd, "driver"); - const Attribute afterSetting = readText(child.fd, "bAlternateSetting"); - const Attribute setting = interface.values.value("bAlternateSetting"); - struct stat after {}; - if (fstatat(fd, encoded.constData(), &after, AT_SYMLINK_NOFOLLOW) != 0) { - const int error = errno; - interface.values.clear(); - interface.values.insert("state", failure(error)); - } else if (inodeIdentity(before) != inodeIdentity(after) - || driver.state != afterDriver.state || driver.value != afterDriver.value || driver.error != afterDriver.error - || setting.state != afterSetting.state || setting.value != afterSetting.value || setting.error != afterSetting.error) { - interface.values.clear(); - interface.values.insert("state", failure(EAGAIN)); - } - } - usb.interfaces.append(interface); - } - // A listing failure must not become a successful empty/partial result. - const Attribute count = usb.values.value("bNumInterfaces"); - bool validCount = false; - const uint expected = count.value.toUInt(&validCount); - if (!unconfigured && !names.error && names.names.isEmpty() && count.state != ReadState::Available) - usb.interfaceState = count; - if (!unconfigured && !names.error && count.state == ReadState::Available - && (!validCount || expected > 255 || expected != static_cast(names.names.size()))) { - usb.interfaces.clear(); - usb.interfaceState = failure(validCount && expected <= 255 ? EAGAIN : EINVAL); - } - // Configuration changes invalidate both its declarations and interface list. - const Attribute afterConfiguration = readText(fd, "bConfigurationValue"); - if (configuration.state != afterConfiguration.state || configuration.value != afterConfiguration.value - || configuration.error != afterConfiguration.error) { - usb.interfaces.clear(); - const Attribute changed = failure(EAGAIN); - for (auto it = usb.values.begin(); it != usb.values.end(); ++it) it.value() = changed; - usb.interfaceState = changed; - } -} - -NetworkProperties networkInterface(int fd, const QString &path, const QString &name, bool ethernetDetails) -{ - const Attribute index = readText(fd, "ifindex"); - const Attribute type = readText(fd, "type"); - bool valid = false; - const unsigned int number = index.value.toUInt(&valid); - NetworkProperties result; - if (index.state == ReadState::Available && valid && number) - result = collectNetworkProperties(name, number, type.state == ReadState::Available && type.value == "1"); - else result.addressState = result.permanentAddress = index.state == ReadState::Available ? failure(EINVAL) : index; - result.name = name; - result.path = path; - result.linkType = type; - if (type.state != ReadState::Available) result.permanentAddress = type; - result.currentAddress = readText(fd, "address"); - result.assignmentType = readText(fd, "addr_assign_type"); - result.mtu = readText(fd, "mtu"); - if (ethernetDetails && type.state == ReadState::Available && type.value == "1") { - // ARPHRD_ETHER describes MAC framing and includes Wi-Fi. Check both - // kernel wireless markers before issuing Ethernet-only ethtool queries. - bool wireless = false; - int scopeError = 0; - for (const char *marker : {"phy80211", "wireless"}) { - struct stat entry {}; - if (fstatat(fd, marker, &entry, AT_SYMLINK_NOFOLLOW) == 0) wireless = true; - else if (errno != ENOENT) scopeError = errno; - } - if (!wireless && scopeError) { - result.ethernet.scope = failure(scopeError); - result.ethernet.source = path + "/phy80211; " + path + "/wireless"; - } else if (!wireless && index.state == ReadState::Available && valid && number - && !QThread::currentThread()->isInterruptionRequested()) { - result.ethernet = collectEthernetProperties(name, number); - } - } - const Attribute afterIndex = readText(fd, "ifindex"); - const bool indexChanged = index.state == ReadState::Available - && (afterIndex.state != ReadState::Available || afterIndex.value != index.value); - if (indexChanged || result.addressState.state == ReadState::Removed || result.permanentAddress.state == ReadState::Removed - || result.ethernet.scope.state == ReadState::Removed) { - result.addresses.clear(); - const Attribute error = indexChanged - ? (afterIndex.state != ReadState::Available ? afterIndex : failure(EAGAIN)) : failure(ENODEV); - result.addressState = result.permanentAddress = result.currentAddress - = result.assignmentType = result.mtu = error; - if (result.ethernet.applicable || result.ethernet.scope.state != ReadState::NotApplicable) - result.ethernet.invalidate(error); - } - return result; -} -void networkProperties(DeviceProperties &result, int fd, bool ethernetDetails) -{ - const Device &device = result.device; - if (device.subsystem == "net") { - result.network.append(networkInterface(fd, device.path, device.sysname, ethernetDetails)); - result.networkState = available(QString()); - return; - } - if (device.category != "network") return; - // A single net interface is represented by its PCI function in the device - // tree. Read only that function's direct net directory, never ancestor ports. - Descriptor directory(openat(fd, "net", O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)); - if (directory.fd < 0) { result.networkState = failure(errno); return; } - const QString path = device.path + "/net"; - const DirectoryNames entries = boundedDirectoryNames(path, 256); - if (entries.error) { result.networkState = failure(entries.error); return; } - result.networkState = available(QString()); - for (const QString &name : entries.names) { - if (QThread::currentThread()->isInterruptionRequested()) break; - const QByteArray encoded = name.toUtf8(); - Descriptor interface(openat(directory.fd, encoded.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)); - if (interface.fd < 0) { - const Attribute error = failure(errno); - NetworkProperties unavailable; - unavailable.name = name; - unavailable.path = path + '/' + name; - unavailable.addressState = unavailable.permanentAddress = unavailable.currentAddress - = unavailable.assignmentType = unavailable.linkType = unavailable.mtu = error; - result.network.append(unavailable); - } else result.network.append(networkInterface(interface.fd, path + '/' + name, name, ethernetDetails)); - } -} -void moduleMetadata(DeviceProperties &result, int driverFd) -{ - Attribute owner = linkTarget(driverFd, "module"); - // ENOENT is absent evidence, not evidence of a built-in driver. - if (owner.state == ReadState::Available) owner.value = QFileInfo(owner.value).fileName(); - put(result, "module/name", owner, result.values.value("driver/path").value + "/module"); - const QStringList fields {"filename", "version", "description", "author", "license", "firmware"}; - for (const QString &key : fields) - put(result, "module/" + key, {}, QStringLiteral("installed module metadata")); - put(result, "module/type", {}, QStringLiteral("kernel module initstate")); - put(result, "module/runtime_version", {}, QStringLiteral("kernel module version")); - if (owner.state != ReadState::Available || owner.value.isEmpty()) return; - Descriptor moduleFd(openat(driverFd, "module", O_RDONLY | O_DIRECTORY | O_CLOEXEC)); - if (moduleFd.fd >= 0) { - put(result, "module/runtime_version", readText(moduleFd.fd, "version"), - QStringLiteral("/sys/module/%1/version").arg(owner.value)); - const Attribute state = readText(moduleFd.fd, "initstate"); - put(result, "module/type", state.state == ReadState::Available ? Attribute() : state, - QStringLiteral("/sys/module/%1/initstate").arg(owner.value)); - if (state.state == ReadState::Available - && (state.value == "live" || state.value == "coming" || state.value == "going")) - put(result, "module/type", available("modular"), QStringLiteral("/sys/module/%1/initstate").arg(owner.value)); - } else { - const Attribute error = failure(errno); - put(result, "module/runtime_version", error, QStringLiteral("kernel module version")); - put(result, "module/type", error, QStringLiteral("kernel module initstate")); - } -#ifdef QLDM_HAVE_KMOD - const char *const noConfiguration[] = {nullptr}; - std::unique_ptr context(kmod_new(nullptr, noConfiguration), &kmod_unref); - kmod_module *raw = nullptr; - const QByteArray name = owner.value.toUtf8(); - const int status = context ? kmod_module_new_from_name_lookup(context.get(), name.constData(), &raw) : -ENOMEM; - std::unique_ptr module(raw, &kmod_module_unref); - if (status < 0 || !module) { - if (status < 0) - for (const QString &key : fields) result.values["module/" + key] = failure(-status); - return; - } - const Attribute filename = boundedText(kmod_module_get_path(module.get())); - put(result, "module/filename", filename, QStringLiteral("libkmod: running-kernel installed module index")); - kmod_list *info = nullptr; - const int count = kmod_module_get_info(module.get(), &info); - if (count < 0) { - for (const QString &key : fields) - if (key != "filename") result.values["module/" + key] = failure(-count); - } else { - kmod_list *entry = nullptr; - int examined = 0; - kmod_list_foreach(entry, info) { - if (QThread::currentThread()->isInterruptionRequested()) break; - if (++examined > 4096) { - for (const QString &key : fields) - if (key != "filename") result.values["module/" + key] = failure(EOVERFLOW); - break; - } - const Attribute keyText = boundedText(kmod_module_info_get_key(entry)); - const QString key = keyText.value; - if (keyText.state != ReadState::Available || key == "filename" || !fields.contains(key)) continue; - Attribute value = boundedText(kmod_module_info_get_value(entry)); - const Attribute previous = result.values.value("module/" + key); - if (previous.state == ReadState::Error) continue; - if (value.state == ReadState::Available && previous.state == ReadState::Available) { - value.value = previous.value + '\n' + value.value; - if (value.value.toUtf8().size() > textLimit) value = failure(EOVERFLOW); - } - put(result, "module/" + key, value, - filename.state == ReadState::Available ? filename.value + ": .modinfo/" + key - : QStringLiteral("libkmod: installed metadata/%1").arg(key)); - } - } - kmod_module_info_free_list(info); -#else - for (const QString &key : fields) result.values["module/" + key].state = ReadState::Unsupported; -#endif -} -} - -void PropertiesReader::setRequest(const Device &device, quint64 request) -{ - Q_ASSERT(!isRunning()); - device_ = device; - request_ = request; -} -DeviceProperties PropertiesReader::takeResult() -{ - Q_ASSERT(!isRunning()); - return std::move(result_); -} -DeviceProperties collectDeviceProperties(const Device &device, bool cachedStorageService) -{ - DeviceProperties result; - result.device = device; - const Device &d = result.device; - const bool efi = d.subsystem == "efivarfs"; - const QByteArray path = d.path.toUtf8(); - if (efi) { - struct stat entry {}; - if (lstat(path.constData(), &entry) != 0) { - result.error = errno; - result.state = errno == ENOENT || errno == ENODEV ? ReadState::Removed : failure(errno).state; - return result; - } - if (inodeIdentity(entry) != d.incarnation) { result.state = ReadState::Removed; return result; } - if (!S_ISREG(entry.st_mode) || !(entry.st_mode & S_IROTH)) { - result.state = ReadState::PermissionDenied; - result.error = EACCES; - return result; - } - } - Descriptor deviceFd(open(path.constData(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | (efi ? 0 : O_DIRECTORY))); - if (deviceFd.fd < 0) { - result.error = errno; - result.state = errno == ENOENT || errno == ENODEV ? ReadState::Removed : failure(errno).state; - return result; - } - struct stat before {}; - if (fstat(deviceFd.fd, &before) != 0) { - result.error = errno; result.state = ReadState::Error; return result; - } - const QString identity = inodeIdentity(before); - const bool sameIdentity = efi ? identity == d.incarnation : d.incarnation.startsWith(identity + ':'); - if (!sameIdentity) { result.state = ReadState::Removed; return result; } - result.state = ReadState::Available; - if (efi) { - if (!S_ISREG(before.st_mode) || !(before.st_mode & S_IROTH)) { - result.state = ReadState::PermissionDenied; - result.error = EACCES; - return result; - } - int error = 0; - result.efiBytes = readBytes(deviceFd.fd, efiLimit, error); - if (error) put(result, "efi/hex", failure(error), QStringLiteral("efivarfs: complete file bytes")); - else { - result.efiTruncated = result.efiBytes.size() > efiLimit; - result.efiBytes.truncate(efiLimit); - put(result, "efi/hex", available(QString()), QStringLiteral("efivarfs: complete file bytes, no decoding")); - } - } else { - std::unique_ptr context(udev_new(), &udev_unref); - std::unique_ptr raw( - context ? udev_device_new_from_syspath(context.get(), path.constData()) : nullptr, &udev_device_unref); - if (!raw) { - result.state = ReadState::Error; - result.error = context ? EIO : ENOMEM; - return result; - } - const QString stamp = boundedText(udev_device_get_property_value(raw.get(), "USEC_INITIALIZED")).value; - if (identity + ':' + stamp != d.incarnation) { result.state = ReadState::Removed; return result; } - // Selected udev metadata only; no unbounded property dump. - for (const char *key : {"ID_VENDOR_FROM_DATABASE", "ID_VENDOR", "ID_MODEL_FROM_DATABASE", "ID_MODEL", - "ID_BUS", "ID_PATH", "ID_PATH_TAG", "ID_SERIAL", "ID_SERIAL_SHORT", "ID_REVISION", - "ID_VENDOR_ID", "ID_MODEL_ID", "PCI_ID", "PCI_SUBSYS_ID", "PCI_CLASS", "PCI_SLOT_NAME", - "PRODUCT", "PHYS", "MODALIAS", "DEVNAME", "DEVTYPE", "DRIVER", "ID_NET_DRIVER", - "ID_USB_DRIVER", "ID_FIRMWARE_FROM_DATABASE"}) { - const Attribute value = boundedText(udev_device_get_property_value(raw.get(), key)); - if (value.state != ReadState::Unavailable) - put(result, QStringLiteral("udev/") + QLatin1String(key), value, QStringLiteral("udev: %1 on %2").arg(QLatin1String(key), d.path)); - } - const Attribute driverLink = linkTarget(deviceFd.fd, "driver"); - Attribute driverPath = driverLink; - if (driverPath.state == ReadState::Available) - driverPath.value = QDir::cleanPath(driverPath.value.startsWith('/') ? driverPath.value : d.path + '/' + driverPath.value); - put(result, "driver/path", driverPath, d.path + "/driver"); - Attribute binding = driverLink; - if (binding.state == ReadState::Available) binding.value = QFileInfo(binding.value).fileName(); - else if (binding.state == ReadState::Unavailable && binding.error == ENOENT) binding = available(QString()); - put(result, "driver/name", binding, d.path + "/driver"); - if (driverLink.state == ReadState::Available) { - // Follow this one documented kernel link, never a guessed parent driver. - Descriptor driverFd(openat(deviceFd.fd, "driver", O_RDONLY | O_DIRECTORY | O_CLOEXEC)); - if (driverFd.fd >= 0) { - const Attribute subsystem = linkTarget(deviceFd.fd, "subsystem"); - put(result, "driver/bus", subsystem.state == ReadState::Available - ? available(QFileInfo(subsystem.value).fileName()) : subsystem, d.path + "/subsystem"); - moduleMetadata(result, driverFd.fd); - } else put(result, "module/name", failure(errno), driverPath.value + "/module"); - } - QStringList attributes; - if (d.subsystem == "pci") attributes = {"vendor", "device", "subsystem_vendor", "subsystem_device", "class", "revision", "modalias"}; - else if (d.subsystem == "usb") attributes = {"idVendor", "idProduct", "bcdDevice", "manufacturer", "product", "serial", "authorized", "busnum", "devnum", "version", "speed", "modalias"}; - else if (d.subsystem == "input") attributes = {"name", "phys", "uniq", "modalias"}; - else if (d.subsystem == "nvme") attributes = {"model", "serial", "firmware_rev", "state"}; - else if (d.subsystem == "power_supply") attributes = {"type", "manufacturer", "model_name", "serial_number", "present"}; - else if (d.subsystem == "net") attributes = {"address", "addr_assign_type", "type", "mtu", "ifindex", "operstate", "flags"}; - else if (d.subsystem == "drm" && d.sysname.contains('-')) attributes = {"status", "enabled"}; - else if (d.subsystem == "hdaudio") attributes = {"vendor_name", "chip_name", "vendor_id", "subsystem_id", "revision_id"}; - else if (d.subsystem == "acpi") attributes = {"hid", "uid", "path", "modalias"}; - else if (d.subsystem == "pnp" || d.subsystem == "platform" || d.subsystem == "hid") attributes = {"modalias"}; - else if (d.subsystem == "video4linux") attributes = {"name", "index"}; - else if (d.subsystem == "sound" && d.sysname.startsWith("card")) attributes = {"id"}; - for (const QString &key : attributes) { - if (QThread::currentThread()->isInterruptionRequested()) break; - const QByteArray name = key.toLatin1(); - sysfs(result, deviceFd.fd, name.constData()); - } - if (cachedStorageService && !QThread::currentThread()->isInterruptionRequested()) - pcieProperties(result, deviceFd.fd); - if (!QThread::currentThread()->isInterruptionRequested()) result.resources = collectDeviceResources(deviceFd.fd, d); - if (!QThread::currentThread()->isInterruptionRequested()) - result.storage = collectStorageProperties(d, cachedStorageService); - if (!QThread::currentThread()->isInterruptionRequested()) networkProperties(result, deviceFd.fd, cachedStorageService); - if (cachedStorageService && !QThread::currentThread()->isInterruptionRequested()) - usbProperties(result, deviceFd.fd); - if (cachedStorageService && !QThread::currentThread()->isInterruptionRequested()) - batteryProperties(result, deviceFd.fd); - if (cachedStorageService && d.subsystem == "video4linux" && d.sysname.startsWith("video") - && !QThread::currentThread()->isInterruptionRequested()) - result.camera = collectCameraProperties(deviceFd.fd, d, boundedText(udev_device_get_devnode(raw.get())).value); - // Desktop-session queries belong to explicit properties requests, not - // deep search (which passes cachedStorageService=false). - if (cachedStorageService && d.subsystem == "drm" && d.sysname.contains('-') - && !QThread::currentThread()->isInterruptionRequested()) { - result.monitor = collectMonitorProperties(deviceFd.fd, d.path); - if (!QThread::currentThread()->isInterruptionRequested()) - result.display = collectDisplayProperties(d, readText(deviceFd.fd, "status")); - } - // Binding can change during a read even if the device instance remains. - const Attribute afterLink = linkTarget(deviceFd.fd, "driver"); - if (afterLink.state != driverLink.state || afterLink.value != driverLink.value || afterLink.error != driverLink.error) { - result.state = ReadState::Error; - result.error = EAGAIN; - } - } - struct stat after {}; - if (lstat(path.constData(), &after) != 0) { - result.error = errno; - result.state = errno == ENOENT || errno == ENODEV ? ReadState::Removed : failure(errno).state; - } else if (inodeIdentity(after) != identity) result.state = ReadState::Removed; - else if (efi && !(after.st_mode & S_IROTH)) { - result.state = ReadState::PermissionDenied; - result.error = EACCES; - result.efiBytes.clear(); - } - if (QThread::currentThread()->isInterruptionRequested()) { result.state = ReadState::Error; result.error = ECANCELED; } - return result; -} - -void PropertiesReader::run() -{ - Q_ASSERT(isCurrentThread()); - result_ = collectDeviceProperties(device_); - result_.request = request_; -} diff --git a/src/devices/deviceproperties.h b/src/devices/deviceproperties.h deleted file mode 100644 index 7e089d4..0000000 --- a/src/devices/deviceproperties.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include "device.h" -#include "deviceresources.h" -#include "storageproperties.h" -#include "networkproperties.h" -#include "displayproperties.h" -#include "monitorproperties.h" -#include "cameraproperties.h" -#include -#include - -// USB-specific values stay separate from generic Details metadata. -struct UsbInterfaceProperties { - QString name; - QString path; - QHash values; -}; -struct UsbProperties { - QHash values; - QVector interfaces; - Attribute interfaceState; -}; -struct BatteryProperties { - bool applicable = false; - QHash values; - Attribute state; -}; - -// Owned raw results. Display labels, translations and hex formatting belong to UI. -struct DeviceProperties { - Device device; - QHash values; - QHash sources; - DeviceResources resources; - StorageProperties storage; - QVector network; - Attribute networkState; - DisplayProperties display; - MonitorProperties monitor; - CameraProperties camera; - UsbProperties usb; - BatteryProperties battery; - bool pcieApplicable = false; // Evidence from this PCI device's link attributes only. - Attribute pcieType; // Numeric PCI Express device/port type; no GPU-ID inference. - QString pcieTypeSource; - bool pcieTypeRequiresRoot = false; - QByteArray efiBytes; - bool efiTruncated = false; - ReadState state = ReadState::Unavailable; - int error = 0; - quint64 request = 0; -}; - -// Worker-thread collector shared by Properties and deep search. No GUI objects. -DeviceProperties collectDeviceProperties(const Device &device, bool cachedStorageService = true); - -// One window-owned worker, shared by successive dialogs. Requests serialize. -class PropertiesReader final : public QThread { - Q_OBJECT -public: - explicit PropertiesReader(QObject *parent = nullptr) : QThread(parent) {} - void setRequest(const Device &device, quint64 request); // Only while stopped. - DeviceProperties takeResult(); // Only after finished(). -protected: - void run() override; -private: - Device device_; - quint64 request_ = 0; - DeviceProperties result_; -}; diff --git a/src/devices/deviceresources.cpp b/src/devices/deviceresources.cpp deleted file mode 100644 index 83cc511..0000000 --- a/src/devices/deviceresources.cpp +++ /dev/null @@ -1,247 +0,0 @@ -#include "deviceresources.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -// Stable userspace resource flag values: Linux include/linux/ioport.h. -constexpr quint64 typeMask = 0x00001f00; -constexpr quint64 io = 0x00000100; -constexpr quint64 memory = 0x00000200; -constexpr quint64 window = 0x00200000; -constexpr quint64 disabled = 0x10000000; -constexpr quint64 unset = 0x20000000; -constexpr int inputLimit = 65536; -constexpr int rowLimit = 256; -constexpr int vectorLimit = 4096; - -Attribute failure(int error) -{ - return {error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENODEV ? ReadState::Removed - : error == ENOENT ? ReadState::Unavailable : ReadState::Error, {}, error}; -} -void issue(DeviceResources &result, const QString &source, int error) -{ - result.issues.append({source, failure(error)}); -} -Attribute readMetadata(int parent, const char *name, int limit = inputLimit) -{ - const int fd = openat(parent, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); - if (fd < 0) return failure(errno); - QByteArray bytes; - char buffer[4096]; - int error = 0; - while (bytes.size() <= limit) { - if (QThread::currentThread()->isInterruptionRequested()) { error = ECANCELED; break; } - const int wanted = qMin(static_cast(sizeof(buffer)), limit + 1 - static_cast(bytes.size())); - const ssize_t count = read(fd, buffer, wanted); - if (count < 0) { - if (errno == EINTR) continue; - error = errno; - break; - } - if (!count) break; - bytes.append(buffer, static_cast(count)); - } - close(fd); - if (error) return failure(error); - if (bytes.size() > limit) return failure(EOVERFLOW); - return {ReadState::Available, QString::fromUtf8(bytes), 0}; -} -void recordReadError(DeviceResources &result, const QString &source, const Attribute &value) -{ - if (value.state != ReadState::Available && value.state != ReadState::Unavailable) - result.issues.append({source, value}); -} -bool hexNumber(const QString &text, quint64 &value) -{ - static const QRegularExpression hex(QStringLiteral("^(?:0[xX])?[0-9a-fA-F]{1,16}$")); - if (!hex.match(text).hasMatch()) return false; - bool ok = false; - value = text.toULongLong(&ok, 16); - return ok; -} -bool decimalNumber(const QString &text, quint64 &value) -{ - static const QRegularExpression decimal(QStringLiteral("^[0-9]{1,10}$")); - if (!decimal.match(text).hasMatch()) return false; - bool ok = false; - value = text.toULongLong(&ok, 10); - return ok && value <= 0xffffffffULL; -} -void append(DeviceResources &target, const DeviceResources &source) -{ - target.items += source.items; - target.issues += source.issues; - target.pnpState = source.pnpState; -} -} - -DeviceResources parsePciResources(const QString &text, const QString &source) -{ - DeviceResources result; - if (text.size() > inputLimit) { issue(result, source, EOVERFLOW); return result; } - const QStringList lines = text.split('\n'); - int index = 0; - static const QRegularExpression whitespace(QStringLiteral("\\s+")); - for (int line = 0; line < lines.size(); ++line) { - const QString record = lines[line].trimmed(); - if (record.isEmpty() && line == lines.size() - 1) continue; - if (index >= rowLimit) { issue(result, source, EOVERFLOW); break; } - const QStringList fields = record.split(whitespace, Qt::SkipEmptyParts); - quint64 start = 0, end = 0, flags = 0; - if (fields.size() != 3 || !hexNumber(fields[0], start) - || !hexNumber(fields[1], end) || !hexNumber(fields[2], flags)) { - // Do not shift slot numbers or invent ranges after malformed input. - result.items.clear(); - issue(result, source, EINVAL); - return result; - } - const int slot = index++; - if (start == 0 && end == 0 && flags == 0) continue; // Empty resource slot. - const quint64 type = flags & typeMask; - if (type != io && type != memory) continue; - DeviceResource r; - r.pci = true; - r.index = slot; - r.type = type == io ? DeviceResource::Type::Io : DeviceResource::Type::Memory; - r.start = start; - r.end = end; - r.flags = flags; - r.source = source; - if (flags & disabled) r.allocation = DeviceResource::Allocation::Disabled; - else if (flags & unset) r.allocation = DeviceResource::Allocation::Unassigned; - else if (start == 0 && end == 0) r.allocation = DeviceResource::Allocation::Unavailable; - else if (end < start) { result.items.clear(); issue(result, source, EINVAL); return result; } - result.items.append(r); - } - return result; -} - -DeviceResources parsePnpResources(const QString &text, const QString &source) -{ - DeviceResources result; - if (text.size() > inputLimit) { issue(result, source, EOVERFLOW); return result; } - const QStringList lines = text.trimmed().split('\n'); - if (lines.isEmpty() || (lines[0] != "state = active" && lines[0] != "state = disabled")) { - issue(result, source, EINVAL); return result; - } - result.pnpState = lines[0].mid(8); - static const QRegularExpression record(QStringLiteral("^(io|mem|irq|dma|bus) (.+)$")); - static const QRegularExpression range(QStringLiteral("^(0[xX][0-9a-fA-F]+|0)-(0[xX][0-9a-fA-F]+|0)( window)?$")); - for (int line = 1; line < lines.size(); ++line) { - if (line > rowLimit) { issue(result, source, EOVERFLOW); break; } - const auto match = record.match(lines[line].trimmed()); - if (!match.hasMatch()) { result.items.clear(); issue(result, source, EINVAL); return result; } - const QString type = match.captured(1), value = match.captured(2); - DeviceResource r; - r.type = type == "io" ? DeviceResource::Type::Io : type == "mem" ? DeviceResource::Type::Memory - : type == "irq" ? DeviceResource::Type::Irq : type == "dma" ? DeviceResource::Type::Dma : DeviceResource::Type::Bus; - r.source = source; - if (value == "disabled") r.allocation = DeviceResource::Allocation::Disabled; - else if (r.type == DeviceResource::Type::Irq || r.type == DeviceResource::Type::Dma) { - if (value == "-1") r.allocation = DeviceResource::Allocation::Unassigned; - else if (!decimalNumber(value, r.start)) { result.items.clear(); issue(result, source, EINVAL); return result; } - r.end = r.start; - } else { - const auto addresses = range.match(value); - if (!addresses.hasMatch() || !hexNumber(addresses.captured(1), r.start) - || !hexNumber(addresses.captured(2), r.end) || r.end < r.start) { - result.items.clear(); issue(result, source, EINVAL); return result; - } - if (!addresses.captured(3).isEmpty()) r.flags |= window; - if (r.start == 0 && r.end == 0) r.allocation = DeviceResource::Allocation::Unavailable; - } - if (result.pnpState == "disabled") r.allocation = DeviceResource::Allocation::Disabled; - result.items.append(r); - } - return result; -} - -DeviceResources collectDeviceResources(int deviceFd, const Device &device) -{ - DeviceResources result; - if (device.subsystem == "pnp") { - const Attribute data = readMetadata(deviceFd, "resources"); - const QString source = device.path + "/resources"; - if (data.state == ReadState::Available) append(result, parsePnpResources(data.value, source)); - else recordReadError(result, source, data); - return result; - } - if (device.subsystem != "pci") return result; // No guessed parent resources. - const Attribute ranges = readMetadata(deviceFd, "resource"); - if (ranges.state == ReadState::Available) append(result, parsePciResources(ranges.value, device.path + "/resource")); - else recordReadError(result, device.path + "/resource", ranges); - - const int fd = openat(deviceFd, "msi_irqs", O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); - bool msiObserved = false; - if (fd >= 0) { - DIR *raw = fdopendir(fd); - if (!raw) { const int error = errno; close(fd); issue(result, device.path + "/msi_irqs", error); } - else { - const auto closeDirectory = [](DIR *directory) { closedir(directory); }; - std::unique_ptr entries(raw, closeDirectory); - QVector vectors; - int examined = 0; - while (!QThread::currentThread()->isInterruptionRequested()) { - errno = 0; - const dirent *entry = readdir(entries.get()); - if (!entry) { - if (errno) issue(result, device.path + "/msi_irqs", errno); - break; - } - const QString filename = QString::fromUtf8(entry->d_name); - if (filename == "." || filename == "..") continue; - if (++examined > vectorLimit) { issue(result, device.path + "/msi_irqs", EOVERFLOW); break; } - quint64 number = 0; - if (!decimalNumber(filename, number) || number == 0) { - issue(result, device.path + "/msi_irqs", EINVAL); continue; - } - msiObserved = true; - const Attribute mode = readMetadata(dirfd(entries.get()), entry->d_name, 32); - const QString source = device.path + "/msi_irqs/" + filename; - if (mode.state != ReadState::Available) { - // Even a vanished vector is an explicit incomplete observation. - result.issues.append({source, mode}); continue; - } - const QString value = mode.value.trimmed(); - if (value != "msi" && value != "msix") { issue(result, source, EINVAL); continue; } - DeviceResource r; - r.type = DeviceResource::Type::Irq; - r.start = r.end = number; - r.mode = value; - r.source = source; - vectors.append(r); - } - std::sort(vectors.begin(), vectors.end(), [](const DeviceResource &a, const DeviceResource &b) { return a.start < b.start; }); - result.items += vectors; - } - } else if (errno != ENOENT) issue(result, device.path + "/msi_irqs", errno); - - // irq may be the first MSI vector or the legacy INTx number during MSI-X. - // Prefer the complete msi_irqs allocation; never invent an active INTx IRQ. - if (!msiObserved && !QThread::currentThread()->isInterruptionRequested()) { - const Attribute irq = readMetadata(deviceFd, "irq", 32); - if (irq.state != ReadState::Available) recordReadError(result, device.path + "/irq", irq); - else { - quint64 number = 0; - if (!decimalNumber(irq.value.trimmed(), number)) issue(result, device.path + "/irq", EINVAL); - else if (number != 0) { - DeviceResource r; - r.type = DeviceResource::Type::Irq; - r.start = r.end = number; - r.mode = "reported"; - r.source = device.path + "/irq"; - result.items.append(r); - } - } - } - return result; -} diff --git a/src/devices/deviceresources.h b/src/devices/deviceresources.h deleted file mode 100644 index 460eab3..0000000 --- a/src/devices/deviceresources.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include "device.h" - -struct DeviceResource { - enum class Type { Memory, Io, Irq, Dma, Bus }; - enum class Allocation { Assigned, Unassigned, Disabled, Unavailable }; - Type type = Type::Memory; - Allocation allocation = Allocation::Assigned; - quint64 start = 0; - quint64 end = 0; - quint64 flags = 0; - int index = -1; // PCI resource slot, not necessarily a BAR. - bool pci = false; - QString mode; // MSI/MSI-X or reported irq; raw kernel evidence only. - QString source; -}; -struct ResourceIssue { - QString source; - Attribute error; -}; -struct DeviceResources { - QVector items; - QVector issues; - QString pnpState; - bool hasInformation() const { return !items.isEmpty() || !issues.isEmpty(); } -}; - -// Strict parsers produce owned raw records. No translation or presentation here. -DeviceResources parsePciResources(const QString &text, const QString &source); -DeviceResources parsePnpResources(const QString &text, const QString &source); -// Read only selected metadata relative to the caller's pinned device descriptor. -DeviceResources collectDeviceResources(int deviceFd, const Device &device); diff --git a/src/devices/devicesearch.cpp b/src/devices/devicesearch.cpp deleted file mode 100644 index e7370a7..0000000 --- a/src/devices/devicesearch.cpp +++ /dev/null @@ -1,174 +0,0 @@ -#include "devicesearch.h" -#include "devicelabel.h" -#include "propertytext.h" -#include -#include -#include -#include -#include - -namespace { -QString numbers(quint64 value) -{ - return QStringLiteral("%1 0x%2 %3").arg(value).arg(value, 0, 16).arg(value, 16, 16, QLatin1Char('0')); -} -// Only documented numeric fields are interpreted. Serial numbers/names stay literal. -QString numericAliases(const QString &key, const QString &value, const QString &subsystem) -{ - static const QSet hexKeys {"idVendor", "idProduct", "bcdDevice", "vendor_id", "subsystem_id", "revision_id", - "ID_VENDOR_ID", "ID_MODEL_ID", "PCI_CLASS", "flags"}; - static const QSet pciHex {"vendor", "device", "subsystem_vendor", "subsystem_device", "class", "revision"}; - static const QSet decimalKeys {"busnum", "devnum", "ifindex", "index", "authorized", "present"}; - int base = hexKeys.contains(key) || (subsystem == "pci" && pciHex.contains(key)) ? 16 - : decimalKeys.contains(key) ? 10 : 0; - QStringList components; - if (key == "PCI_ID" || key == "PCI_SUBSYS_ID") { base = 16; components = value.split(':'); } - else if (key == "PRODUCT") { base = 16; components = value.split('/'); } - else components.append(value); - if (!base) return {}; - QStringList result; - for (const QString &part : components) { - bool ok = false; - const quint64 number = part.trimmed().toULongLong(&ok, base); - if (ok) result.append(numbers(number)); - } - return result.join(' '); -} -} -SearchDocument deviceSearchDocument(const SearchRecord &record, const DeviceProperties &p) -{ - SearchDocument doc; - const Device &d = record.device; - const auto add = [&](const QString &label, const QString &value) { - if (!value.isEmpty()) doc.append({label, value}); - }; - const auto attribute = [&](const QString &key, const Attribute &a, const QString &source) { - add(key, a.state == ReadState::Available ? a.value : propertyReadValue(a)); - if (a.error) add(key, QString::number(a.error)); - if (a.state == ReadState::Available) - add(key, numericAliases(key.section('/', -1), a.value, d.subsystem)); - add(key, source); - }; - add(QCoreApplication::translate("PropertiesDialog", "Device name"), record.label); - add(QCoreApplication::translate("PropertiesDialog", "Device name"), deviceDisplayName(d)); - add(QCoreApplication::translate("PropertiesDialog", "Original name"), d.name); - add(QCoreApplication::translate("PropertiesDialog", "Name source"), d.nameSource); - add(QCoreApplication::translate("PropertiesDialog", "Category"), categoryLabel(d.category)); - add(QCoreApplication::translate("PropertiesDialog", "Category"), d.category); - add(QCoreApplication::translate("PropertiesDialog", "Full kernel path"), d.path); - add(QCoreApplication::translate("PropertiesDialog", "Parent device path"), d.parentPath); - add(QCoreApplication::translate("PropertiesDialog", "Kernel name"), d.sysname); - add(QCoreApplication::translate("PropertiesDialog", "Bus / subsystem"), d.subsystem); - add("DEVTYPE", d.devtype); - add(QCoreApplication::translate("PropertiesDialog", "Bound kernel driver"), d.driver); - add(QCoreApplication::translate("PropertiesDialog", "Represented by"), d.representedByPath); - add(QCoreApplication::translate("PropertiesDialog", "Grouped records"), record.groupedPaths); - for (auto it = d.properties.cbegin(); it != d.properties.cend(); ++it) - attribute(it.key(), {ReadState::Available, it.value(), 0}, d.propertySources.value(it.key())); - for (auto it = d.attributes.cbegin(); it != d.attributes.cend(); ++it) attribute(it.key(), it.value(), {}); - // An invalidated read must not contribute partially collected properties. - if (p.state != ReadState::Available) { - add(QCoreApplication::translate("PropertiesDialog", "Device status"), propertyReadValue({p.state, {}, p.error})); - return doc; - } - for (auto it = p.values.cbegin(); it != p.values.cend(); ++it) attribute(it.key(), it.value(), p.sources.value(it.key())); - for (const PropertyEntry &entry : propertyEntries(p)) { - add(entry.label, entry.value); - add(entry.label, entry.source); - } - if (p.values.value("efi/hex").state == ReadState::Available) - add(QCoreApplication::translate("PropertiesDialog", "UEFI variable — hex dump"), QString::fromLatin1(p.efiBytes.toHex())); - add(QCoreApplication::translate("PropertiesDialog", "PnP device state"), p.resources.pnpState); - if (p.resources.pnpState == "disabled") add(QCoreApplication::translate("PropertiesDialog", "PnP device state"), QCoreApplication::translate("PropertiesDialog", "PnP device disabled")); - if (p.efiTruncated) add(QCoreApplication::translate("PropertiesDialog", "UEFI variable — hex dump"), QCoreApplication::translate("PropertiesDialog", "Truncated: first 65536 bytes shown.")); - for (const DeviceResource &r : p.resources.items) { - QString label; - switch (r.type) { - case DeviceResource::Type::Memory: label = QCoreApplication::translate("PropertiesDialog", "Memory range"); break; - case DeviceResource::Type::Io: label = QCoreApplication::translate("PropertiesDialog", "I/O range"); break; - case DeviceResource::Type::Irq: label = QCoreApplication::translate("PropertiesDialog", "IRQ"); break; - case DeviceResource::Type::Dma: label = QCoreApplication::translate("PropertiesDialog", "DMA channel"); break; - case DeviceResource::Type::Bus: label = QCoreApplication::translate("PropertiesDialog", "Bus range"); break; - } - add(label, r.source); - add(label, numbers(r.start) + ' ' + numbers(r.end) + ' ' + numbers(r.flags)); - add(label, QStringLiteral("%1 – %2").arg(r.start, r.type == DeviceResource::Type::Memory ? 16 : 4, 16, QLatin1Char('0')) - .arg(r.end, r.type == DeviceResource::Type::Memory ? 16 : 4, 16, QLatin1Char('0'))); - if (r.pci) add(label, r.index < 6 ? QCoreApplication::translate("PropertiesDialog", "BAR %1").arg(r.index) - : r.index == 6 ? QCoreApplication::translate("PropertiesDialog", "Expansion ROM") : QCoreApplication::translate("PropertiesDialog", "PCI resource %1").arg(r.index)); - switch (r.allocation) { - case DeviceResource::Allocation::Assigned: break; - case DeviceResource::Allocation::Unassigned: add(label, QCoreApplication::translate("PropertiesDialog", "Unassigned")); break; - case DeviceResource::Allocation::Disabled: add(label, QCoreApplication::translate("PropertiesDialog", "Disabled")); break; - case DeviceResource::Allocation::Unavailable: add(label, QCoreApplication::translate("PropertiesDialog", "Address unavailable (zeroed or masked)")); break; - } - if (r.flags & 0x2000) add(label, QCoreApplication::translate("PropertiesDialog", "Prefetchable")); - if (r.flags & 0x4000) add(label, QCoreApplication::translate("PropertiesDialog", "Read-only")); - if (r.flags & 0x100000) add(label, QCoreApplication::translate("PropertiesDialog", "64-bit memory")); - if (r.flags & 0x200000) add(label, QCoreApplication::translate("PropertiesDialog", "Bridge window")); - add(label, r.mode == "msix" ? QStringLiteral("MSI-X") : r.mode); - if (r.mode == "reported") add(label, QCoreApplication::translate("PropertiesDialog", "Reported irq attribute; active mode not established")); - } - for (const ResourceIssue &issue : p.resources.issues) attribute(QCoreApplication::translate("PropertiesDialog", "Resource metadata"), issue.error, issue.source); - return doc; -} -QString searchDocumentMatch(const SearchDocument &document, const QString &query) -{ - if (query.isEmpty()) return {}; - for (const SearchField &field : document) - if (field.value.contains(query, Qt::CaseInsensitive) || field.label.contains(query, Qt::CaseInsensitive)) return field.label; - return {}; -} -void DeepSearchWorker::setRequest(QVector records, QString query, quint64 revision, quint64 request) -{ - Q_ASSERT(!isRunning()); - records_ = std::move(records); - query_ = std::move(query); - revision_ = revision; - request_ = request; -} -void DeepSearchWorker::run() -{ - Q_ASSERT(isCurrentThread()); - if (revision_ != cachedRevision_) { - cache_.clear(); cacheBytes_ = 0; cachedRevision_ = revision_; - } - SearchBatch batch; - batch.request = request_; - batch.total = static_cast(records_.size()); - QElapsedTimer timer; - timer.start(); - for (const SearchRecord &record : records_) { - if (isInterruptionRequested()) return; - const auto cached = cache_.constFind(record.device.path); - SearchDocument document; - bool unavailable = false; - if (cached != cache_.cend() && cached->generation == record.device.generation) { - document = cached->document; - unavailable = cached->unavailable; - } else { - const DeviceProperties properties = collectDeviceProperties(record.device, false); - if (isInterruptionRequested()) return; - unavailable = properties.state != ReadState::Available || !properties.resources.issues.isEmpty(); - for (const Attribute &value : properties.values) - if (value.state == ReadState::PermissionDenied || value.state == ReadState::Error) unavailable = true; - document = deviceSearchDocument(record, properties); - qsizetype bytes = 0; - for (const SearchField &field : document) bytes += 2 * (field.label.size() + field.value.size()) + sizeof(SearchField); - // Bound cached payload to 64 MiB. Uncached records still get fully searched. - if (cacheBytes_ + bytes <= 64 * 1024 * 1024) { - cache_.insert(record.device.path, {record.device.generation, document, unavailable}); - cacheBytes_ += bytes; - } - } - const QString match = searchDocumentMatch(document, query_); - if (!match.isEmpty()) batch.matches.append({record.device.path, record.device.generation, match}); - ++batch.done; - if (unavailable) ++batch.unavailable; - if (timer.elapsed() >= 100 || batch.matches.size() >= 128) { - emit batchReady(batch); - batch.matches.clear(); timer.restart(); - } - } - emit batchReady(batch); -} diff --git a/src/devices/devicesearch.h b/src/devices/devicesearch.h deleted file mode 100644 index 600ed5b..0000000 --- a/src/devices/devicesearch.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once -#include "deviceproperties.h" - -struct SearchRecord { - Device device; - QString label; // Exact label currently presented by the source model. - QString groupedPaths; -}; -struct SearchField { QString label; QString value; }; -using SearchDocument = QVector; -struct SearchMatch { QString path; quint64 generation = 0; QString field; }; -struct SearchBatch { - quint64 request = 0; - QVector matches; - int done = 0; - int total = 0; - int unavailable = 0; -}; -Q_DECLARE_METATYPE(SearchBatch) - -SearchDocument deviceSearchDocument(const SearchRecord &record, const DeviceProperties &properties); -QString searchDocumentMatch(const SearchDocument &document, const QString &query); - -// One serial worker. All records/results are owned; cache is worker-thread-only. -class DeepSearchWorker final : public QThread { - Q_OBJECT -public: - explicit DeepSearchWorker(QObject *parent = nullptr) : QThread(parent) {} - void setRequest(QVector records, QString query, quint64 revision, quint64 request); -signals: - void batchReady(SearchBatch batch); -protected: - void run() override; -private: - struct Cached { quint64 generation; SearchDocument document; bool unavailable; }; - QVector records_; - QString query_; - quint64 revision_ = 0; - quint64 cachedRevision_ = 0; - quint64 request_ = 0; - QHash cache_; - qsizetype cacheBytes_ = 0; -}; diff --git a/src/devices/directoryscan.h b/src/devices/directoryscan.h deleted file mode 100644 index 66ea832..0000000 --- a/src/devices/directoryscan.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once -#include -#include -#include -#include - -struct DirectoryNames { - QStringList names; - int error = 0; -}; - -// Nonrecursive sysfs directory listing. Resolve links for type filtering, retain -// link names, omit hidden/dot entries, and sort only after collecting a bounded set. -// Like QDir::entryList, an inaccessible/missing directory yields an empty list; -// callers that need an access error must validate the directory separately. -inline DirectoryNames boundedDirectoryNames(const QString &path, qsizetype limit, - const QStringList &filters = {}) -{ - if (limit < 0) return {{}, EINVAL}; - if (QThread::currentThread()->isInterruptionRequested()) return {{}, ECANCELED}; - DirectoryNames result; - using Flag = QDirListing::IteratorFlag; - for (const auto &entry : QDirListing(path, filters, Flag::DirsOnly | Flag::ResolveSymlinks)) { - if (QThread::currentThread()->isInterruptionRequested()) return {{}, ECANCELED}; - if (result.names.size() >= limit) return {{}, EOVERFLOW}; - result.names.append(entry.fileName()); - } - if (QThread::currentThread()->isInterruptionRequested()) return {{}, ECANCELED}; - result.names.sort(Qt::CaseSensitive); - return result; -} diff --git a/src/devices/displaybackend.h b/src/devices/displaybackend.h deleted file mode 100644 index d93a1b1..0000000 --- a/src/devices/displaybackend.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once -#include "displayproperties.h" -#include - -namespace DisplayBackend { -constexpr int outputLimit = 256; -constexpr int textLimit = 4096; -constexpr int queryTimeoutMs = 2000; -inline Attribute available(const QString &value) { return {ReadState::Available, value, 0}; } -inline Attribute error(int number) { return {ReadState::Error, {}, number}; } -inline QString number(double value) { return QString::number(value, 'g', 8); } -inline QString canonicalName(QString name) -{ - // RandR's modesetting driver uses HDMI-N for the kernel's HDMI-A-N. - if (name.startsWith("HDMI-A-")) name.replace(0, 7, "HDMI-"); - return name; -} -template bool resolve(QLibrary &library, const char *name, Function &function) -{ - function = reinterpret_cast(library.resolve(name)); - return function != nullptr; -} -DisplayProperties x11(const QString &connector); -DisplayProperties wayland(const QString &connector); -} diff --git a/src/devices/displayproperties.cpp b/src/devices/displayproperties.cpp deleted file mode 100644 index 5628579..0000000 --- a/src/devices/displayproperties.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "displaybackend.h" -#include "directoryscan.h" -#include - -DisplayProperties collectDisplayProperties(const Device &device, const Attribute &connectionStatus) -{ - DisplayProperties result; - if (device.subsystem != "drm" || !device.sysname.contains('-')) { - result.status.state = ReadState::NotApplicable; - return result; - } - // Read by the properties worker from the open device directory, rather - // than using the potentially stale enumeration snapshot. - if (connectionStatus.state == ReadState::Available && connectionStatus.value == "disconnected") { - result.status = DisplayBackend::available("disconnected"); - result.sources.insert("configuration", device.path + "/status"); - return result; - } - const QString connector = device.sysname.mid(device.sysname.indexOf('-') + 1); - // Session protocols name outputs, not GPU-qualified kernel connectors. - // Do not silently attribute eDP-1/DP-1 to the wrong GPU on multi-card systems. - const DirectoryNames names = boundedDirectoryNames("/sys/class/drm", DisplayBackend::outputLimit * 2); - if (names.error) { result.status = DisplayBackend::error(names.error); return result; } - int matches = 0; - for (const QString &name : names.names) { - const int separator = name.indexOf('-'); - if (separator >= 0 && DisplayBackend::canonicalName(name.mid(separator + 1)) - == DisplayBackend::canonicalName(connector)) ++matches; - } - if (matches != 1) { result.status = DisplayBackend::error(matches ? ENOTUNIQ : ENODEV); return result; } - const QByteArray session = qgetenv("XDG_SESSION_TYPE"); - const bool wayland = session == "wayland" || (session != "x11" && !qgetenv("WAYLAND_DISPLAY").isEmpty()); - if (wayland) result = DisplayBackend::wayland(connector); - else if (!qgetenv("DISPLAY").isEmpty()) result = DisplayBackend::x11(connector); - // Never fall back to XWayland's emulated outputs after a Wayland failure. - return result; -} diff --git a/src/devices/displayproperties.h b/src/devices/displayproperties.h deleted file mode 100644 index 41a2905..0000000 --- a/src/devices/displayproperties.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once -#include "device.h" - -// Owned desktop-session snapshot. All numeric strings use the C locale. -struct DisplayProperties { - QString backend; - QString output; - Attribute status; - QHash values; - QHash sources; -}; - -DisplayProperties collectDisplayProperties(const Device &device, const Attribute &connectionStatus); diff --git a/src/devices/displaywayland.cpp b/src/devices/displaywayland.cpp deleted file mode 100644 index d28002d..0000000 --- a/src/devices/displaywayland.cpp +++ /dev/null @@ -1,279 +0,0 @@ -#include "displaybackend.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace DisplayBackend { -namespace { -// Private xdg-output v3 wire descriptors. Only this read-only protocol is bound; -// using runtime core interfaces avoids generated code's link-time Wayland symbols. -const wl_message xdgRequests[] {{"destroy", "", nullptr}}; -const wl_message xdgEvents[] {{"logical_position", "ii", nullptr}, {"logical_size", "ii", nullptr}, - {"done", "", nullptr}, {"name", "2s", nullptr}, {"description", "2s", nullptr}}; -const wl_interface xdgInterface {"zxdg_output_v1", 3, 1, xdgRequests, 5, xdgEvents}; -struct XdgListener { - void (*position)(void *, wl_proxy *, int32_t, int32_t); - void (*size)(void *, wl_proxy *, int32_t, int32_t); - void (*done)(void *, wl_proxy *); - void (*name)(void *, wl_proxy *, const char *); - void (*description)(void *, wl_proxy *, const char *); -}; -struct Output { - uint32_t id = 0; - wl_proxy *proxy = nullptr; - wl_proxy *xdg = nullptr; - QString name; - bool nameConflict = false; - bool removed = false; - bool modeKnown = false; - int width = 0, height = 0, refresh = 0; - int transform = -1, scale = 1, logicalWidth = 0, logicalHeight = 0; -}; -void outputName(Output &output, const char *name) -{ - if (!name || strnlen(name, textLimit + 1) > textLimit) { output.nameConflict = true; return; } - const QString value = QString::fromUtf8(name); - if (!output.name.isEmpty() && output.name != value) output.nameConflict = true; - output.name = value; -} -const wl_output_listener outputListener { - [](void *data, wl_output *, int32_t, int32_t, int32_t, int32_t, int32_t, const char *, const char *, int32_t transform) { - static_cast(data)->transform = transform; - }, - [](void *data, wl_output *, uint32_t flags, int32_t width, int32_t height, int32_t refresh) { - if (!(flags & WL_OUTPUT_MODE_CURRENT)) return; - auto &output = *static_cast(data); - output.modeKnown = true; - output.width = width; output.height = height; output.refresh = refresh; - }, - [](void *, wl_output *) {}, - [](void *data, wl_output *, int32_t scale) { static_cast(data)->scale = scale; }, - [](void *data, wl_output *, const char *name) { outputName(*static_cast(data), name); }, - [](void *, wl_output *, const char *) {} -}; -const XdgListener xdgListener { - [](void *, wl_proxy *, int32_t, int32_t) {}, - [](void *data, wl_proxy *, int32_t width, int32_t height) { - auto &output = *static_cast(data); - output.logicalWidth = width; output.logicalHeight = height; - }, - [](void *, wl_proxy *) {}, - [](void *data, wl_proxy *, const char *name) { outputName(*static_cast(data), name); }, - [](void *, wl_proxy *, const char *) {} -}; -#define WAYLAND_FUNCTIONS(X) \ - X(connectFd, wl_display_connect_to_fd) X(disconnect, wl_display_disconnect) \ - X(getFd, wl_display_get_fd) X(flush, wl_display_flush) \ - X(dispatch, wl_display_dispatch_pending) X(prepare, wl_display_prepare_read) \ - X(cancel, wl_display_cancel_read) X(readEvents, wl_display_read_events) \ - X(marshal, wl_proxy_marshal_flags) X(addListener, wl_proxy_add_listener) \ - X(version, wl_proxy_get_version) X(destroy, wl_proxy_destroy) -struct Wayland { - QLibrary library {QStringLiteral("libwayland-client.so.0")}; - wl_display *display = nullptr; - wl_proxy *registry = nullptr, *manager = nullptr; - const wl_interface *registryInterface = nullptr, *outputInterface = nullptr, *callbackInterface = nullptr; - const wl_interface *getOutputTypes[2] {&xdgInterface, nullptr}; - wl_message managerRequests[2] {{"destroy", "", nullptr}, {"get_xdg_output", "no", getOutputTypes}}; - wl_interface managerInterface {"zxdg_output_manager_v1", 3, 2, managerRequests, 0, nullptr}; - std::vector> outputs; - QElapsedTimer timer; - int errorNumber = 0; -#define DECLARE(member, symbol) decltype(&symbol) member = nullptr; - WAYLAND_FUNCTIONS(DECLARE) -#undef DECLARE - ~Wayland() - { - for (const auto &output : outputs) { - if (output->xdg) destroy(output->xdg); - if (output->proxy) destroy(output->proxy); - } - if (manager) destroy(manager); - if (registry) destroy(registry); - if (display) disconnect(display); - library.unload(); - } - bool load() - { - if (!library.load()) return false; - bool valid = true; -#define LOAD(member, symbol) valid = resolve(library, #symbol, member) && valid; - WAYLAND_FUNCTIONS(LOAD) -#undef LOAD - registryInterface = reinterpret_cast(library.resolve("wl_registry_interface")); - outputInterface = reinterpret_cast(library.resolve("wl_output_interface")); - callbackInterface = reinterpret_cast(library.resolve("wl_callback_interface")); - getOutputTypes[1] = outputInterface; - return valid && registryInterface && outputInterface && callbackInterface; - } - bool connect() - { - // Open our own socket; never consume WAYLAND_SOCKET, which may already - // belong to Qt. This also makes connection establishment cancellable. - QString name = QString::fromLocal8Bit(qgetenv("WAYLAND_DISPLAY")); - if (name.isEmpty()) name = "wayland-0"; - if (!name.startsWith('/')) { - const QString directory = QString::fromLocal8Bit(qgetenv("XDG_RUNTIME_DIR")); - if (directory.isEmpty()) { errorNumber = ENOENT; return false; } - name = directory + '/' + name; - } - const QByteArray path = name.toLocal8Bit(); - sockaddr_un address {}; - if (path.size() >= static_cast(sizeof(address.sun_path))) { errorNumber = ENAMETOOLONG; return false; } - address.sun_family = AF_UNIX; - std::memcpy(address.sun_path, path.constData(), static_cast(path.size())); - const int socket = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); - if (socket < 0) { errorNumber = errno; return false; } - int status = ::connect(socket, reinterpret_cast(&address), sizeof(address)); - if (status < 0 && errno == EINPROGRESS) { - status = 0; - while (!QThread::currentThread()->isInterruptionRequested() && timer.elapsed() < queryTimeoutMs) { - pollfd event {socket, POLLOUT, 0}; - const int ready = poll(&event, 1, 100); - if (ready < 0 && errno == EINTR) continue; - if (ready < 0) { errorNumber = errno; break; } - if (!ready) continue; - int errorValue = 0; - socklen_t size = sizeof(errorValue); - if (getsockopt(socket, SOL_SOCKET, SO_ERROR, &errorValue, &size) != 0) errorValue = errno; - errorNumber = errorValue; - status = errorValue ? -1 : 1; - break; - } - if (!status) errorNumber = QThread::currentThread()->isInterruptionRequested() ? ECANCELED : ETIMEDOUT; - } else if (status < 0) errorNumber = errno; - if (errorNumber) { close(socket); return false; } - display = connectFd(socket); // Takes ownership, including on failure. - if (!display) { errorNumber = errno ? errno : EIO; return false; } - return true; - } - wl_proxy *bind(uint32_t id, const wl_interface *interface, uint32_t serverVersion) - { - const uint32_t requested = qMin(serverVersion, static_cast(interface->version)); - return marshal(registry, WL_REGISTRY_BIND, interface, requested, 0, id, interface->name, requested, nullptr); - } - bool sync() - { - bool done = false; - auto *callback = marshal(reinterpret_cast(display), WL_DISPLAY_SYNC, callbackInterface, 1, 0, nullptr); - if (!callback) { errorNumber = ENOMEM; return false; } - static const wl_callback_listener listener {[](void *data, wl_callback *, uint32_t) { *static_cast(data) = true; }}; - if (addListener(callback, reinterpret_cast(const_cast(&listener)), &done) < 0) errorNumber = EIO; - while (!done && !errorNumber) { - if (QThread::currentThread()->isInterruptionRequested()) { errorNumber = ECANCELED; break; } - const int remaining = queryTimeoutMs - static_cast(timer.elapsed()); - if (remaining <= 0) { errorNumber = ETIMEDOUT; break; } - if (dispatch(display) < 0) { errorNumber = EIO; break; } - if (done) break; - if (prepare(display) != 0) continue; - const int sent = flush(display); - const bool pendingWrite = sent < 0 && errno == EAGAIN; - if (sent < 0 && !pendingWrite) { cancel(display); errorNumber = EIO; break; } - pollfd event {getFd(display), static_cast(POLLIN | (pendingWrite ? POLLOUT : 0)), 0}; - const int ready = poll(&event, 1, qMin(remaining, 100)); - if (ready < 0) { - const int errorValue = errno; - cancel(display); - if (errorValue != EINTR) errorNumber = errorValue; - } else if (event.revents & (POLLERR | POLLHUP | POLLNVAL)) { cancel(display); errorNumber = EIO; } - else if (event.revents & POLLIN) { if (readEvents(display) < 0) errorNumber = EIO; } - else cancel(display); - } - destroy(callback); - return !errorNumber; - } -}; -#undef WAYLAND_FUNCTIONS -const wl_registry_listener registryListener { - [](void *data, wl_registry *, uint32_t id, const char *name, uint32_t version) { - auto &api = *static_cast(data); - if (std::strcmp(name, "wl_output") == 0) { - if (api.outputs.size() >= static_cast(outputLimit)) { api.errorNumber = EOVERFLOW; return; } - auto output = std::make_unique(); - output->id = id; - output->proxy = api.bind(id, api.outputInterface, qMin(version, 4u)); - if (!output->proxy) { api.errorNumber = ENOMEM; return; } - if (api.addListener(output->proxy, reinterpret_cast(const_cast(&outputListener)), output.get()) < 0) - api.errorNumber = EIO; - api.outputs.push_back(std::move(output)); - } else if (std::strcmp(name, "zxdg_output_manager_v1") == 0 && !api.manager) { - api.manager = api.bind(id, &api.managerInterface, qMin(version, 3u)); - if (!api.manager) api.errorNumber = ENOMEM; - } - }, - [](void *data, wl_registry *, uint32_t id) { - for (const auto &output : static_cast(data)->outputs) - if (output->id == id) output->removed = true; - } -}; -} - -DisplayProperties wayland(const QString &connector) -{ - DisplayProperties result; - result.backend = "Wayland"; - Wayland api; - if (!api.load()) return result; - api.timer.start(); - if (!api.connect()) { result.status = error(api.errorNumber); return result; } - api.registry = api.marshal(reinterpret_cast(api.display), WL_DISPLAY_GET_REGISTRY, api.registryInterface, 1, 0, nullptr); - if (!api.registry) { result.status = error(ENOMEM); return result; } - if (api.addListener(api.registry, reinterpret_cast(const_cast(®istryListener)), &api) < 0) { - result.status = error(EIO); return result; - } - if (!api.sync()) { result.status = error(api.errorNumber); return result; } - if (api.manager) { - for (const auto &output : api.outputs) { - if (output->removed) continue; - output->xdg = api.marshal(api.manager, 1, &xdgInterface, api.version(api.manager), 0, nullptr, output->proxy); - if (!output->xdg) { result.status = error(ENOMEM); return result; } - if (api.addListener(output->xdg, reinterpret_cast(const_cast(&xdgListener)), output.get()) < 0) { - result.status = error(EIO); return result; - } - } - } - if (!api.sync()) { result.status = error(api.errorNumber); return result; } - const Output *selected = nullptr; - for (const auto &output : api.outputs) { - if (output->removed || output->nameConflict || canonicalName(output->name) != canonicalName(connector)) continue; - if (selected) { result.status = error(ENOTUNIQ); return result; } - selected = output.get(); - } - if (!selected) return result; - result.output = selected->name; - result.status = available("exposed"); - const auto put = [&](const QString &key, const QString &value, const QString &source) { - result.values.insert(key, available(value)); result.sources.insert(key, source); - }; - result.sources.insert("depth", "Wayland output protocols do not expose desktop color depth"); - if (selected->modeKnown && selected->width > 0 && selected->height > 0) { - put("resolution", QStringLiteral("%1x%2").arg(selected->width).arg(selected->height), "wl_output current mode, before rotation/scaling"); - if (selected->refresh > 0) put("refresh", number(selected->refresh / 1000.0), "wl_output current mode; nominal rate, not measured/VRR rate"); - } - if (selected->transform >= 0 && selected->transform <= 7) { - put("rotation", QString::number((selected->transform % 4) * 90), "wl_output geometry transform, counterclockwise"); - put("reflection", selected->transform >= 4 ? "vertical" : "none", "wl_output transform: reflection about the vertical axis, then rotation"); - } - if (selected->logicalWidth > 0 && selected->logicalHeight > 0 && selected->modeKnown - && selected->width > 0 && selected->height > 0 && selected->transform >= 0 && selected->transform <= 7) { - const bool swapped = selected->transform % 2; - const double x = static_cast(swapped ? selected->height : selected->width) / selected->logicalWidth; - const double y = static_cast(swapped ? selected->width : selected->height) / selected->logicalHeight; - put("scale", number(x) + ' ' + number(y), "Wayland mode/logical-size ratio (X, Y), adjusted for rotation; approximate when logical sizes are rounded"); - put("scale_kind", "derived", "xdg-output logical_size"); - } else if (selected->scale > 0) { - put("scale", number(selected->scale) + ' ' + number(selected->scale), "wl_output integer buffer scale; fractional desktop scale unavailable"); - put("scale_kind", "integer", "wl_output scale"); - } - return result; -} -} diff --git a/src/devices/displayx11.cpp b/src/devices/displayx11.cpp deleted file mode 100644 index 5189da0..0000000 --- a/src/devices/displayx11.cpp +++ /dev/null @@ -1,196 +0,0 @@ -#include "displaybackend.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace DisplayBackend { -namespace { -template using Reply = std::unique_ptr; -// XCB keeps protocol/I/O errors local to this connection, unlike Xlib's global -// error handlers. These declarations introduce no direct library references. -#define XCB_FUNCTIONS(X) \ - X(connect, xcb_connect) X(disconnect, xcb_disconnect) \ - X(hasError, xcb_connection_has_error) X(setup, xcb_get_setup) \ - X(roots, xcb_setup_roots_iterator) X(nextScreen, xcb_screen_next) \ - X(fd, xcb_get_file_descriptor) X(flush, xcb_flush) X(pollReply, xcb_poll_for_reply) -#define RANDR_FUNCTIONS(X) \ - X(version, xcb_randr_query_version) \ - X(resources, xcb_randr_get_screen_resources_current) \ - X(outputs, xcb_randr_get_screen_resources_current_outputs) \ - X(modes, xcb_randr_get_screen_resources_current_modes) \ - X(outputInfo, xcb_randr_get_output_info) X(outputName, xcb_randr_get_output_info_name) \ - X(crtcInfo, xcb_randr_get_crtc_info) X(transform, xcb_randr_get_crtc_transform) -struct X11 { - QLibrary core {QStringLiteral("libxcb.so.1")}; - QLibrary randr {QStringLiteral("libxcb-randr.so.0")}; - xcb_connection_t *connection = nullptr; - QElapsedTimer timer; - int errorNumber = 0; -#define DECLARE(member, symbol) decltype(&symbol) member = nullptr; - XCB_FUNCTIONS(DECLARE) - RANDR_FUNCTIONS(DECLARE) -#undef DECLARE - ~X11() - { - if (connection) disconnect(connection); - randr.unload(); - core.unload(); - } - bool load() - { - if (!core.load() || !randr.load()) return false; - bool valid = true; -#define CORE_LOAD(member, symbol) valid = resolve(core, #symbol, member) && valid; - XCB_FUNCTIONS(CORE_LOAD) -#undef CORE_LOAD -#define RANDR_LOAD(member, symbol) valid = resolve(randr, #symbol, member) && valid; - RANDR_FUNCTIONS(RANDR_LOAD) -#undef RANDR_LOAD - return valid; - } - template Reply reply(unsigned int sequence) - { - void *raw = nullptr; - while (!QThread::currentThread()->isInterruptionRequested()) { - xcb_generic_error_t *protocolError = nullptr; - if (pollReply(connection, sequence, &raw, &protocolError)) { - if (protocolError || !raw) { - std::free(protocolError); - std::free(raw); - errorNumber = EPROTO; - return {nullptr, &std::free}; - } - return {static_cast(raw), &std::free}; - } - if (hasError(connection)) { errorNumber = EIO; break; } - const int remaining = queryTimeoutMs - static_cast(timer.elapsed()); - if (remaining <= 0) { errorNumber = ETIMEDOUT; break; } - if (flush(connection) <= 0 && hasError(connection)) { errorNumber = EIO; break; } - pollfd socket {fd(connection), POLLIN, 0}; - const int status = poll(&socket, 1, qMin(remaining, 100)); - if (status < 0 && errno != EINTR) { errorNumber = errno; break; } - if (status > 0 && (socket.revents & (POLLERR | POLLHUP | POLLNVAL))) { errorNumber = EIO; break; } - } - if (!errorNumber) errorNumber = ECANCELED; - return {nullptr, &std::free}; - } -}; -#undef XCB_FUNCTIONS -#undef RANDR_FUNCTIONS -} - -DisplayProperties x11(const QString &connector) -{ - DisplayProperties result; - result.backend = "X11 / RandR"; - X11 api; - if (!api.load()) return result; - int screenNumber = 0; - api.connection = api.connect(nullptr, &screenNumber); - if (!api.connection || api.hasError(api.connection)) { result.status = error(EIO); return result; } - api.timer.start(); - const auto version = api.reply(api.version(api.connection, 1, 3).sequence); - if (!version) { result.status = error(api.errorNumber); return result; } - if (version->major_version < 1 || (version->major_version == 1 && version->minor_version < 3)) return result; - const xcb_setup_t *setup = api.setup(api.connection); - if (!setup) { result.status = error(EIO); return result; } - auto screen = api.roots(setup); - for (int i = 0; i < screenNumber && screen.rem; ++i) api.nextScreen(&screen); - if (!screen.rem || !screen.data) { result.status = error(EIO); return result; } - const auto resources = api.reply( - api.resources(api.connection, screen.data->root).sequence); - if (!resources) { result.status = error(api.errorNumber); return result; } - if (resources->num_outputs > outputLimit || resources->num_modes > textLimit) { result.status = error(EOVERFLOW); return result; } - xcb_randr_crtc_t selected = XCB_NONE; - int matches = 0; - bool disconnected = false; - const auto *outputs = api.outputs(resources.get()); - for (int i = 0; i < resources->num_outputs; ++i) { - const auto info = api.reply( - api.outputInfo(api.connection, outputs[i], resources->config_timestamp).sequence); - if (!info) { result.status = error(api.errorNumber); return result; } - if (info->status != XCB_RANDR_SET_CONFIG_SUCCESS) { result.status = error(EAGAIN); return result; } - if (info->name_len > textLimit) { result.status = error(EOVERFLOW); return result; } - const QString name = QString::fromUtf8(reinterpret_cast(api.outputName(info.get())), info->name_len); - if (canonicalName(name) != canonicalName(connector)) continue; - ++matches; - result.output = name; - selected = info->crtc; - disconnected = info->connection == XCB_RANDR_CONNECTION_DISCONNECTED; - } - if (matches != 1) { if (matches) result.status = error(ENOTUNIQ); return result; } - if (disconnected) { - result.status = available("disconnected"); - result.sources.insert("configuration", "RandR output connection status"); - return result; - } - result.status = available(selected ? "configured" : "inactive"); - const auto put = [&](const QString &key, const QString &value, const QString &source) { - result.values.insert(key, available(value)); - result.sources.insert(key, source); - }; - put("depth", QString::number(screen.data->root_depth), "X11 screen root depth; not panel/link bits per channel"); - if (!selected) { - for (const QString key : {"resolution", "refresh", "rotation", "reflection", "scale"}) - result.values.insert(key, {ReadState::NotApplicable, {}, 0}); - return result; - } - const auto crtc = api.reply( - api.crtcInfo(api.connection, selected, resources->config_timestamp).sequence); - if (!crtc || crtc->status != XCB_RANDR_SET_CONFIG_SUCCESS || !crtc->mode) { - result.status = error(crtc ? EAGAIN : api.errorNumber); - return result; - } - const xcb_randr_mode_info_t *modes = api.modes(resources.get()); - bool foundMode = false; - for (int i = 0; i < resources->num_modes; ++i) { - const auto &mode = modes[i]; - if (mode.id != crtc->mode) continue; - foundMode = true; - put("resolution", QStringLiteral("%1x%2").arg(mode.width).arg(mode.height), "RandR current mode, before rotation/scaling"); - if (mode.htotal && mode.vtotal && mode.dot_clock) { - double refresh = static_cast(mode.dot_clock) / mode.htotal / mode.vtotal; - if (mode.mode_flags & XCB_RANDR_MODE_FLAG_INTERLACE) refresh *= 2; - if (mode.mode_flags & XCB_RANDR_MODE_FLAG_DOUBLE_SCAN) refresh /= 2; - put("refresh", number(refresh), "RandR current mode pixel clock/totals; nominal rate, not measured/VRR rate"); - } - break; - } - if (!foundMode) { result.status = error(EAGAIN); return result; } - const unsigned int rotation = crtc->rotation & 15; - if (rotation == XCB_RANDR_ROTATION_ROTATE_0 || rotation == XCB_RANDR_ROTATION_ROTATE_90 - || rotation == XCB_RANDR_ROTATION_ROTATE_180 || rotation == XCB_RANDR_ROTATION_ROTATE_270) - put("rotation", QString::number(rotation == 1 ? 0 : rotation == 2 ? 90 : rotation == 4 ? 180 : 270), - "RandR CRTC rotation, counterclockwise"); - put("reflection", (crtc->rotation & XCB_RANDR_ROTATION_REFLECT_X) - ? ((crtc->rotation & XCB_RANDR_ROTATION_REFLECT_Y) ? "xy" : "x") - : ((crtc->rotation & XCB_RANDR_ROTATION_REFLECT_Y) ? "y" : "none"), "RandR CRTC reflection flags"); - const auto transform = api.reply(api.transform(api.connection, selected).sequence); - if (!transform) result.values.insert("scale", error(api.errorNumber)); - else if (!transform->has_transforms) { - result.sources.insert("scale", "RandR CRTC transforms are not supported by this output"); - } else { - const auto &matrix = transform->current_transform; - const bool diagonal = matrix.matrix12 == 0 && matrix.matrix21 == 0 && matrix.matrix31 == 0 - && matrix.matrix32 == 0 && matrix.matrix33 == 65536 && matrix.matrix11 > 0 && matrix.matrix22 > 0; - if (diagonal) put("scale", number(matrix.matrix11 / 65536.0) + ' ' + number(matrix.matrix22 / 65536.0), - "RandR transform: framebuffer pixels per output pixel (X, Y); excludes desktop DPI and Qt scaling"); - else put("scale", "custom", "RandR uses a non-axis-aligned transform; no single scale factor describes it"); - } - // Reject a result spanning a modeset/hotplug rather than showing mixed data. - const auto after = api.reply( - api.resources(api.connection, screen.data->root).sequence); - if (!after || after->config_timestamp != resources->config_timestamp || after->timestamp != resources->timestamp) { - result.values.clear(); - result.sources.clear(); - result.status = error(after ? EAGAIN : api.errorNumber); - } - return result; -} -} diff --git a/src/devices/efivariables.cpp b/src/devices/efivariables.cpp deleted file mode 100644 index b401fbf..0000000 --- a/src/devices/efivariables.cpp +++ /dev/null @@ -1,116 +0,0 @@ -#include "efivariables.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -bool splitEfiVariableFilename(const QString &filename, QString &name, QString &guid) -{ - name.clear(); - guid.clear(); - static const QRegularExpression pattern(QStringLiteral( - "^(.+)-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")); - const auto match = pattern.match(filename); - if (!match.hasMatch()) return false; - name = match.captured(1); - guid = match.captured(2).toLower(); - return true; -} -QString efiVariableLabel(const QString &name) -{ - // Preserve standard mixed-case spellings before applying case boundaries. - // Unknown capital runs remain intact; this does not decode vendor meanings. - static const QStringList preserved {QStringLiteral("WiMAX"), QStringLiteral("WiFi"), - QStringLiteral("NVMe"), QStringLiteral("PCIe"), QStringLiteral("IPv4"), QStringLiteral("IPv6")}; - const auto preservedAt = [&name](int offset) { - for (const QString &word : preserved) { - const int end = offset + static_cast(word.size()); - if (name.mid(offset, word.size()) == word && (end == name.size() || !name[end].isLower())) - return word; - } - return QString(); - }; - QStringList words; - int offset = 0; - while (offset < name.size()) { - if (name[offset].isSpace() || name[offset] == '_' || name[offset] == '-') { ++offset; continue; } - const QString preservedWord = preservedAt(offset); - if (!preservedWord.isEmpty()) { - words.append(preservedWord); - offset += static_cast(preservedWord.size()); - continue; - } - const int start = offset++; - while (offset < name.size()) { - const QChar previous = name[offset - 1], current = name[offset]; - if (current.isSpace() || current == '_' || current == '-' || !preservedAt(offset).isEmpty()) break; - const bool nextLower = offset + 1 < name.size() && name[offset + 1].isLower(); - if ((previous.isLower() && current.isUpper()) - || (previous.isUpper() && current.isUpper() && nextLower) - || (previous.isDigit() && current.isUpper() && nextLower)) break; - ++offset; - } - const QString word = name.mid(start, offset - start); - // This known compound joins an identifier and endorsement-key acronym. - // Preserve other runs, including EDID, UUID and ordinary words like IDLE. - if (word == "IDEK") { - words.append("ID"); - words.append(word.mid(2)); - } else words.append(word); - } - QString label = words.join(' '); - static const QRegularExpression boot(QStringLiteral("^(Boot|Driver|SysPrep)([0-9a-fA-F]{4})$")); - const auto match = boot.match(name); - if (match.hasMatch()) label = (match.captured(1) == "SysPrep" ? QStringLiteral("Sys Prep") : match.captured(1)) - + ' ' + match.captured(2); - return label.simplified(); -} -void appendEfiVariables(Inventory &inventory) -{ - const QString directory = QStringLiteral("/sys/firmware/efi/efivars"); - const QByteArray encoded = directory.toUtf8(); - const int fd = open(encoded.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); - if (fd < 0) return; // EFI/efivarfs may be absent or unavailable to this account. - DIR *raw = fdopendir(fd); - if (!raw) { close(fd); return; } - const auto closeDirectory = [](DIR *directory) { closedir(directory); }; - std::unique_ptr entries(raw, closeDirectory); - QVector variables; - int examined = 0; - while (!QThread::currentThread()->isInterruptionRequested()) { - const dirent *entry = readdir(entries.get()); - if (!entry) break; - const QByteArray filename(entry->d_name); - if (filename == "." || filename == "..") continue; - if (++examined > 4096) { ++inventory.skipped; break; } - struct stat info {}; - if (fstatat(dirfd(entries.get()), entry->d_name, &info, AT_SYMLINK_NOFOLLOW) != 0) { - ++inventory.skipped; - continue; - } - // Root execution must not broaden the public inventory. Never follow links. - if (!S_ISREG(info.st_mode) || !(info.st_mode & S_IROTH)) continue; - QString name, guid; - if (!splitEfiVariableFilename(QString::fromUtf8(filename), name, guid)) continue; - Device d; - d.path = directory + '/' + QString::fromUtf8(filename); - d.parentPath = directory; - d.sysname = QString::fromUtf8(filename); - d.subsystem = "efivarfs"; - d.category = "efi"; - d.hidden = true; // Firmware records follow the virtual/internal visibility toggle. - d.name = name; - d.nameSource = "UEFI variable filename; contents not read"; - d.properties.insert("EFI_VENDOR_GUID", guid); - d.incarnation = QStringLiteral("%1:%2").arg(static_cast(info.st_dev)) - .arg(static_cast(info.st_ino)); - variables.append(std::move(d)); - } - std::sort(variables.begin(), variables.end(), [](const Device &a, const Device &b) { return a.path < b.path; }); - for (auto &d : variables) inventory.devices.append(std::move(d)); -} diff --git a/src/devices/efivariables.h b/src/devices/efivariables.h deleted file mode 100644 index 44c7452..0000000 --- a/src/devices/efivariables.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once -#include "device.h" - -bool splitEfiVariableFilename(const QString &filename, QString &name, QString &guid); -QString efiVariableLabel(const QString &name); -// Worker-only, fixed efivarfs directory. Metadata enumeration; no value reads. -void appendEfiVariables(Inventory &inventory); diff --git a/src/devices/enumerator.cpp b/src/devices/enumerator.cpp deleted file mode 100644 index ce2342d..0000000 --- a/src/devices/enumerator.cpp +++ /dev/null @@ -1,349 +0,0 @@ -#include "enumerator.h" -#include "cpumetadata.h" -#include "devicepresentation.h" -#include "modulenames.h" -#include "efivariables.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "monitorstate.h" -#include "deviceresources.h" - -namespace { -QString text(const char *value) -{ - // All values are treated as plain text. Bound the owned copy. - return value ? QString::fromUtf8(value, static_cast(strnlen(value, 4096))) : QString(); -} -Attribute readAttribute(const QString &path) -{ - Attribute result; - const QByteArray encoded = path.toUtf8(); - const int fd = open(encoded.constData(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW); - if (fd < 0) { - result.error = errno; - result.state = errno == EACCES || errno == EPERM ? ReadState::PermissionDenied - : errno == ENOENT ? ReadState::Unavailable : ReadState::Error; - return result; - } - char buffer[4097]; - ssize_t count; - do { count = read(fd, buffer, sizeof(buffer)); } while (count < 0 && errno == EINTR); - const int readError = errno; - close(fd); - if (count < 0) { - result.error = readError; - result.state = readError == ENODEV ? ReadState::Removed - : readError == EACCES || readError == EPERM ? ReadState::PermissionDenied : ReadState::Error; - } else if (count > 4096) { - result.error = EOVERFLOW; - result.state = ReadState::Error; - } else { - result.state = ReadState::Available; - result.value = QString::fromUtf8(buffer, static_cast(count)).trimmed(); - } - return result; -} -} -Inventory Enumerator::collect() -{ - Inventory result; - result.request = request_.load(); - std::unique_ptr context(udev_new(), &udev_unref); - if (!context) { - result.error = tr("Could not create the udev context."); - return result; - } - std::unique_ptr scan( - udev_enumerate_new(context.get()), &udev_enumerate_unref); - if (!scan || udev_enumerate_scan_devices(scan.get()) < 0) { - result.error = tr("Could not enumerate kernel devices."); - return result; - } - // hwdb is an optional local naming database, still accessed through libudev. - std::unique_ptr hwdb(udev_hwdb_new(context.get()), &udev_hwdb_unref); - ModuleNames moduleNames; // Worker-owned context and per-refresh lookup cache. - Attribute cpuInfo; - QHash cpuModels; - bool cpuInfoRead = false; - udev_list_entry *entry = nullptr; - udev_list_entry_foreach(entry, udev_enumerate_get_list_entry(scan.get())) { - if (isInterruptionRequested()) break; - std::unique_ptr raw( - udev_device_new_from_syspath(context.get(), udev_list_entry_get_name(entry)), &udev_device_unref); - if (!raw) { ++result.skipped; continue; } - Device d; - d.path = text(udev_device_get_syspath(raw.get())); - if (!d.path.startsWith("/sys/devices/")) { ++result.skipped; continue; } - d.sysname = text(udev_device_get_sysname(raw.get())); - d.subsystem = text(udev_device_get_subsystem(raw.get())); - d.devtype = text(udev_device_get_devtype(raw.get())); - d.driver = text(udev_device_get_driver(raw.get())); - if (!d.driver.isEmpty()) { - const QString target = QFileInfo(d.path + "/driver/module").symLinkTarget(); - if (target.startsWith("/sys/module/")) d.driverModule = QFileInfo(target).fileName(); - } - if (auto *parent = udev_device_get_parent(raw.get())) - d.parentPath = text(udev_device_get_syspath(parent)); - // Explicit allowlist: no recursive sysfs reads, binary resources or hardware queries. - for (const char *key : {"ID_MODEL_FROM_DATABASE", "ID_MODEL", "NAME", "ID_V4L_PRODUCT", "MODALIAS", "PCI_CLASS", - "PCI_ID", "ID_VENDOR_FROM_DATABASE", "ID_VENDOR", "ID_BUS", - "ID_INPUT_KEYBOARD", "ID_INPUT_MOUSE", "ID_INPUT_TOUCHPAD", - "ID_INPUT_POINTINGSTICK", "ID_INPUT_SWITCH", "PRODUCT", "PHYS", "DEVNUM", - "USEC_INITIALIZED"}) { - const char *value = udev_device_get_property_value(raw.get(), key); - if (value) d.properties.insert(QLatin1String(key), text(value)); - } - if (hwdb && (d.subsystem == "pci" || d.subsystem == "usb") - && (d.properties.value("ID_MODEL_FROM_DATABASE").isEmpty() - || (d.subsystem == "usb" && d.properties.value("ID_VENDOR_FROM_DATABASE").isEmpty()))) { - const QByteArray alias = d.properties.value("MODALIAS").toUtf8(); - if (!alias.isEmpty()) { - udev_list_entry *property = nullptr; - udev_list_entry_foreach(property, udev_hwdb_get_properties_list_entry(hwdb.get(), alias.constData(), 0)) { - const QString key = text(udev_list_entry_get_name(property)); - if ((key == "ID_MODEL_FROM_DATABASE" || key == "ID_VENDOR_FROM_DATABASE") - && d.properties.value(key).isEmpty()) { - d.properties.insert(key, text(udev_list_entry_get_value(property))); - d.propertySources.insert(key, QStringLiteral("hwdb: %1 (%2)").arg(key, d.properties.value("MODALIAS"))); - } - } - } - } - if (d.subsystem == "cpu") { - if (!cpuInfoRead) { - cpuInfo = readCpuInfo(); - if (cpuInfo.state == ReadState::Available) cpuModels = parseCpuModels(cpuInfo.value); - cpuInfoRead = true; - cpuInfo.value.clear(); // Keep only the selected per-processor model fields. - } - Attribute model; - model.state = cpuInfo.state; - model.error = cpuInfo.error; - bool valid = false; - const int number = d.sysname.startsWith("cpu") ? d.sysname.mid(3).toInt(&valid) : -1; - if (valid && cpuModels.contains(number)) model.value = cpuModels.value(number); - else if (model.state == ReadState::Available) model.state = ReadState::Unavailable; - d.attributes.insert("cpu_model", model); - } - struct stat info {}; - const QByteArray path = d.path.toUtf8(); - if (stat(path.constData(), &info) != 0) { ++result.skipped; continue; } - d.incarnation = QStringLiteral("%1:%2:%3") - .arg(static_cast(info.st_dev)).arg(static_cast(info.st_ino)) - .arg(d.properties.value("USEC_INITIALIZED")); - // Only documented text metadata on this device, never on a guessed parent. - QString attribute; - if (d.subsystem == "input" || d.subsystem == "video4linux") attribute = "name"; - else if (d.subsystem == "sound" && d.sysname.startsWith("card")) attribute = "id"; - else if (d.subsystem == "power_supply") attribute = "model_name"; - else if (d.subsystem == "nvme") attribute = "model"; - else if (d.subsystem == "usb" && d.devtype == "usb_device") attribute = "product"; - if (d.subsystem == "hdaudio") { - d.attributes.insert("vendor_name", readAttribute(d.path + "/vendor_name")); - d.attributes.insert("chip_name", readAttribute(d.path + "/chip_name")); - } - if (d.subsystem == "power_supply") { - d.attributes.insert("type", readAttribute(d.path + "/type")); - d.attributes.insert("manufacturer", readAttribute(d.path + "/manufacturer")); - } - if (d.subsystem == "usb" && d.devtype == "usb_device") - d.attributes.insert("manufacturer", readAttribute(d.path + "/manufacturer")); - if (d.subsystem == "drm" && d.sysname.contains('-')) { - d.attributes.insert("status", readAttribute(d.path + "/status")); - d.attributes.insert("enabled", readAttribute(d.path + "/enabled")); - } - if (!attribute.isEmpty()) d.attributes.insert(attribute, readAttribute(d.path + '/' + attribute)); - if (d.subsystem == "pci" || d.subsystem == "pnp") { - DeviceResources resources; - const int fd = open(path.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); - if (fd >= 0) { - resources = collectDeviceResources(fd, d); - close(fd); - } else { - const int error = errno; - resources.issues.append({d.path, {error == EACCES || error == EPERM - ? ReadState::PermissionDenied : error == ENOENT ? ReadState::Unavailable - : error == ENODEV ? ReadState::Removed : ReadState::Error, {}, error}}); - } - d.resources = std::make_shared(std::move(resources)); - } - nameDevice(d); - moduleNames.supplement(d); - struct stat after {}; - if (stat(path.constData(), &after) != 0 || after.st_dev != info.st_dev || after.st_ino != info.st_ino) { - ++result.skipped; - continue; - } - classify(d); - result.devices.append(std::move(d)); - } - if (!isInterruptionRequested()) refinePresentation(result.devices); - if (!isInterruptionRequested()) appendEfiVariables(result); - return result; -} - -void Enumerator::run() -{ - Q_ASSERT(isCurrentThread()); - using Monitor = std::unique_ptr; - std::unique_ptr context(udev_new(), &udev_unref); - Monitor monitor(nullptr, &udev_monitor_unref); - MonitorState events; - DeviceEventHistory history; - bool historyGapPending = false; - QElapsedTimer clock; - clock.start(); - qint64 retryAt = 0; - qint64 firstEvent = -1; - qint64 lastEvent = 0; - qint64 periodicAt = 30000; - quint64 handledRequest = 0; - bool initial = true; - bool pending = true; - bool awaitingGui = false; - QString monitorNote; - const auto failed = [&] { - monitor.reset(); - history.lose(); - historyGapPending = true; - events.lose(); - pending = true; - retryAt = clock.elapsed() + 2000; - monitorNote = tr("Live monitoring unavailable; retrying (F5 remains available)"); - }; - const auto openMonitor = [&] { - if (!context) context.reset(udev_new()); - if (!context) { failed(); return; } - monitor.reset(udev_monitor_new_from_netlink(context.get(), "udev")); - if (!monitor) { failed(); return; } - // Best effort, unprivileged. Failure to enlarge the socket is harmless; - // ENOBUFS below triggers a fresh reconciliation and invalidates identities. - udev_monitor_set_receive_buffer_size(monitor.get(), 1024 * 1024); - if (udev_monitor_enable_receiving(monitor.get()) < 0 - || udev_monitor_get_fd(monitor.get()) < 0) { failed(); return; } - monitorNote.clear(); - events.lose(); // A reopened monitor cannot vouch for instances during its gap. - pending = true; - }; - const auto drain = [&] { - if (!monitor) return; - // Bound each drain, even under a continuous stream. On overflow reopen - // before scanning so an abandoned queue cannot masquerade as current state. - for (int count = 0; count < MonitorState::Limit; ++count) { - pollfd fd{udev_monitor_get_fd(monitor.get()), POLLIN, 0}; - const int ready = poll(&fd, 1, 0); - if (ready < 0) { if (errno == EINTR) return; failed(); return; } - if (fd.revents & (POLLERR | POLLHUP | POLLNVAL)) { failed(); return; } - if (!ready || !(fd.revents & POLLIN)) return; - errno = 0; - std::unique_ptr event( - udev_monitor_receive_device(monitor.get()), &udev_device_unref); - if (!event) { - if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return; - failed(); - return; - } - const QString action = text(udev_device_get_action(event.get())); - const QString path = text(udev_device_get_syspath(event.get())); - DeviceEvent observation; - observation.observed = QDateTime::currentDateTime(); - observation.elapsedMs = clock.elapsed(); - observation.sequence = udev_device_get_seqnum(event.get()); - observation.path = path; - observation.action = action; - observation.subsystem = text(udev_device_get_subsystem(event.get())); - observation.devtype = text(udev_device_get_devtype(event.get())); - observation.driver = text(udev_device_get_driver(event.get())); - observation.initialized = text(udev_device_get_property_value(event.get(), "USEC_INITIALIZED")); - // Do not read sysfs contents during event reception. An inode hint - // is usable only while this path is still present; removals use the - // already-observed instance, never a newly reused path's inode. - struct stat identity {}; - if (action != "remove" && stat(path.toUtf8().constData(), &identity) == 0) - observation.incarnation = QStringLiteral("%1:%2:%3") - .arg(static_cast(identity.st_dev)).arg(static_cast(identity.st_ino)) - .arg(observation.initialized); - const QString previous = text(udev_device_get_property_value(event.get(), "DEVPATH_OLD")); - if (previous.startsWith("/devices/")) observation.oldPath = "/sys" + previous; - history.observe(std::move(observation)); - events.observe(path, action == "remove"); - if (path.startsWith("/sys/devices/")) { - lastEvent = clock.elapsed(); - if (firstEvent < 0) firstEvent = lastEvent; - } - const QString oldPath = text(udev_device_get_property_value(event.get(), "DEVPATH_OLD")); - if (oldPath.startsWith("/devices/")) events.observe("/sys" + oldPath, true); - } - failed(); - }; - // Enable reception BEFORE the initial snapshot, and retain it across scans. - openMonitor(); - while (!isInterruptionRequested()) { - drain(); - const qint64 now = clock.elapsed(); - if (!monitor && now >= retryAt) openMonitor(); - if (events.dirty) { - pending = true; - if (firstEvent < 0) firstEvent = now; - // Quiet-time deadline is updated only when drain() receives events. - } - if (awaitingGui && acknowledged_.load()) { - awaitingGui = false; - } - const quint64 request = request_.load(); - const bool manual = request != handledRequest; - const bool due = initial || manual || now >= periodicAt - || (pending && (firstEvent < 0 || now - firstEvent >= 500 || now - lastEvent >= 100)); - if (!awaitingGui && due) { - Inventory inventory; - // At most two scans per publication. Continuous churn cannot keep - // restarting a scan forever; uncertain dependency branches are withheld. - for (int round = 0; round < 2; ++round) { - events.beginScan(); - inventory = collect(); - drain(); - if (!events.dirty || isInterruptionRequested() || !inventory.error.isEmpty()) break; - if (!monitor) openMonitor(); - } - if (isInterruptionRequested()) break; - events.excludeUnsettled(inventory); - inventory.removedPaths = events.removed; - inventory.identityLost = events.lost; - inventory.monitorNote = monitorNote; - // MonitorState can also detect bounded-hint loss independently of - // socket failure. It must end event associations as well as GUI generations. - if (inventory.identityLost && !initial && !historyGapPending) history.lose(); - history.reconcile(inventory.devices); - inventory.events = history.snapshot(static_cast(monitor)); - if (events.dirty && inventory.monitorNote.isEmpty()) - inventory.monitorNote = tr("Changing device branches are being reconciled"); - handledRequest = inventory.request; - pending = events.dirty; - events.removed.clear(); - events.lost = false; - historyGapPending = false; - initial = false; - firstEvent = pending ? clock.elapsed() : -1; - lastEvent = clock.elapsed(); - periodicAt = clock.elapsed() + (monitor && inventory.error.isEmpty() ? 30000 : 2000); - acknowledged_.store(false); - awaitingGui = true; - emit inventoryReady(inventory); - } - // Short interruptible polling also observes manual refresh and GUI ack. - pollfd fd{monitor ? udev_monitor_get_fd(monitor.get()) : -1, POLLIN, 0}; - const int ready = poll(&fd, monitor ? 1 : 0, 50); - if (ready > 0 && (fd.revents & POLLIN)) lastEvent = clock.elapsed(); - if (ready < 0 && errno != EINTR) failed(); - } -} diff --git a/src/devices/enumerator.h b/src/devices/enumerator.h deleted file mode 100644 index 18ca211..0000000 --- a/src/devices/enumerator.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once -#include "device.h" -#include -#include - -// A persistent, worker-owned udev monitor and scanner. No widget/model access. -// At most one snapshot is queued: the GUI acknowledges it after applying it. -class Enumerator final : public QThread { - Q_OBJECT -public: - explicit Enumerator(QObject *parent = nullptr) : QThread(parent) {} - quint64 requestRefresh() { return ++request_; } - void acknowledge() { acknowledged_.store(true); } -signals: - void inventoryReady(const Inventory &inventory); -protected: - void run() override; -private: - Inventory collect(); - std::atomic request_{0}; - std::atomic acknowledged_{true}; -}; diff --git a/src/devices/ethernetproperties.cpp b/src/devices/ethernetproperties.cpp deleted file mode 100644 index fd80c36..0000000 --- a/src/devices/ethernetproperties.cpp +++ /dev/null @@ -1,306 +0,0 @@ -#include "ethernetproperties.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -constexpr int replyLimit = 65536; -constexpr int genlHeaderSize = static_cast(GENL_HDRLEN); -constexpr unsigned int bitLimit = 4096; -constexpr int budgetMs = 2000; // Shared by family lookup and all replies for an interface. -Attribute available(const QString &value) { return {ReadState::Available, value, 0}; } -Attribute failure(int error) -{ - return {error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENODEV || error == ENXIO ? ReadState::Removed - : error == EOPNOTSUPP || error == ENOSYS || error == ENOENT - || error == EPROTONOSUPPORT || error == EAFNOSUPPORT ? ReadState::Unsupported - : error == ENODATA ? ReadState::Unavailable : ReadState::Error, {}, error}; -} -struct Item { int type; QByteArray data; }; -using Items = QVector; -bool attributes(const QByteArray &bytes, Items &items) -{ - for (int offset = 0; offset < bytes.size();) { - if (bytes.size() - offset < NLA_HDRLEN) return false; - nlattr header {}; - std::memcpy(&header, bytes.constData() + offset, sizeof(header)); - const int length = header.nla_len; - if (length < NLA_HDRLEN || length > bytes.size() - offset - || NLA_ALIGN(length) > bytes.size() - offset) return false; - items.append({header.nla_type & NLA_TYPE_MASK, bytes.mid(offset + NLA_HDRLEN, length - NLA_HDRLEN)}); - offset += NLA_ALIGN(length); - } - return true; -} -const QByteArray *item(const Items &items, int type, bool &valid) -{ - const QByteArray *found = nullptr; - for (const Item &entry : items) if (entry.type == type) { - if (found) { valid = false; return nullptr; } - found = &entry.data; - } - return found; -} -template bool number(const QByteArray *data, T &value) -{ - if (!data || data->size() != static_cast(sizeof(T))) return false; - std::memcpy(&value, data->constData(), sizeof(T)); - return true; -} -void appendAttribute(QByteArray &bytes, int type, const QByteArray &value) -{ - const int offset = static_cast(bytes.size()); - const int length = NLA_HDRLEN + static_cast(value.size()); - bytes.resize(offset + NLA_ALIGN(length)); - std::memset(bytes.data() + offset, 0, static_cast(NLA_ALIGN(length))); - const nlattr header {static_cast<__u16>(length), static_cast<__u16>(type)}; - std::memcpy(bytes.data() + offset, &header, sizeof(header)); - std::memcpy(bytes.data() + offset + NLA_HDRLEN, value.constData(), static_cast(value.size())); -} -QByteArray deviceHeader(unsigned int index) -{ - QByteArray header; - appendAttribute(header, ETHTOOL_A_HEADER_DEV_INDEX, - QByteArray(reinterpret_cast(&index), sizeof(index))); - return header; -} - -// A private, nonblocking request socket; no dumps or notification subscriptions. -class Query { -public: - Query() : fd_(socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC | SOCK_NONBLOCK, NETLINK_GENERIC)) - { - timer_.start(); - if (fd_ < 0) error_ = errno; - } - ~Query() { if (fd_ >= 0) close(fd_); } - Query(const Query &) = delete; - Query &operator=(const Query &) = delete; - int error() const { return error_; } - bool request(__u16 family, __u8 command, __u8 replyCommand, const QByteArray &attrs, Items &result) - { - if (fd_ < 0) return false; - error_ = 0; - if (timer_.elapsed() >= budgetMs) { error_ = ETIMEDOUT; return false; } - if (QThread::currentThread()->isInterruptionRequested()) { error_ = ECANCELED; return false; } - QByteArray message(NLMSG_HDRLEN + genlHeaderSize, '\0'); - message.append(attrs); - nlmsghdr header {}; - header.nlmsg_len = static_cast<__u32>(message.size()); - header.nlmsg_type = family; - header.nlmsg_flags = NLM_F_REQUEST; - header.nlmsg_seq = ++sequence_; - genlmsghdr generic {}; - generic.cmd = command; - generic.version = family == GENL_ID_CTRL ? 1 : ETHTOOL_GENL_VERSION; - std::memcpy(message.data(), &header, sizeof(header)); - std::memcpy(message.data() + NLMSG_HDRLEN, &generic, sizeof(generic)); - sockaddr_nl kernel {}; - kernel.nl_family = AF_NETLINK; - const ssize_t sent = sendto(fd_, message.constData(), static_cast(message.size()), 0, - reinterpret_cast(&kernel), sizeof(kernel)); - if (sent != message.size()) { - error_ = sent < 0 ? errno : EIO; - return false; - } - QByteArray buffer(replyLimit, '\0'); - while (timer_.elapsed() < budgetMs) { - if (QThread::currentThread()->isInterruptionRequested()) { error_ = ECANCELED; return false; } - pollfd ready {fd_, POLLIN, 0}; - const int remaining = budgetMs - static_cast(timer_.elapsed()); - const int status = poll(&ready, 1, qMin(50, qMax(0, remaining))); - if (status < 0) { if (errno == EINTR) continue; error_ = errno; return false; } - if (!status) continue; - if (ready.revents & (POLLERR | POLLHUP | POLLNVAL)) { error_ = EIO; return false; } - sockaddr_nl sender {}; - socklen_t size = sizeof(sender); - const ssize_t count = recvfrom(fd_, buffer.data(), replyLimit, MSG_TRUNC, - reinterpret_cast(&sender), &size); - if (count < 0) { - if (errno == EAGAIN || errno == EINTR) continue; - error_ = errno; return false; - } - if (count > replyLimit) { error_ = EOVERFLOW; return false; } - if (size != sizeof(sender) || sender.nl_family != AF_NETLINK || sender.nl_pid != 0) continue; - for (int offset = 0; offset < count;) { - if (count - offset < NLMSG_HDRLEN) { error_ = EPROTO; return false; } - nlmsghdr response {}; - std::memcpy(&response, buffer.constData() + offset, sizeof(response)); - if (response.nlmsg_len < NLMSG_HDRLEN || static_cast(response.nlmsg_len) > count - offset - || static_cast(NLMSG_ALIGN(response.nlmsg_len)) > count - offset) { error_ = EPROTO; return false; } - const char *data = buffer.constData() + offset + NLMSG_HDRLEN; - const int length = static_cast(response.nlmsg_len) - NLMSG_HDRLEN; - offset += static_cast(NLMSG_ALIGN(response.nlmsg_len)); - if (response.nlmsg_seq != sequence_) continue; - if (response.nlmsg_type == NLMSG_ERROR) { - int code = 0; - if (length < static_cast(sizeof(nlmsgerr))) { error_ = EPROTO; return false; } - std::memcpy(&code, data, sizeof(code)); - if (code > 0 || code < -4095) { error_ = EPROTO; return false; } - if (!code) continue; - error_ = -code; return false; - } - if (response.nlmsg_type != family || response.nlmsg_flags & (NLM_F_MULTI | NLM_F_DUMP_INTR) - || length < genlHeaderSize) { error_ = EPROTO; return false; } - genlmsghdr reply {}; - std::memcpy(&reply, data, sizeof(reply)); - if (reply.cmd != replyCommand || !attributes(QByteArray(data + genlHeaderSize, length - genlHeaderSize), result)) { - error_ = EPROTO; return false; - } - return true; - } - } - error_ = ETIMEDOUT; - return false; - } -private: - int fd_; - int error_ = 0; - __u32 sequence_ = 0; - QElapsedTimer timer_; -}; -bool matchesDevice(const Items &items, int headerType, unsigned int index) -{ - bool valid = true; - const QByteArray *header = item(items, headerType, valid); - Items fields; - if (!valid || !header || !attributes(*header, fields)) return false; - __u32 found = 0; - const QByteArray *value = item(fields, ETHTOOL_A_HEADER_DEV_INDEX, valid); - return valid && number(value, found) && found == index; -} -Attribute integer(const Items &items, int type, bool byte) -{ - bool valid = true; - const QByteArray *value = item(items, type, valid); - if (!valid) return failure(EPROTO); - if (!value) return failure(ENODATA); - __u8 small = 0; - __u32 large = 0; - if (byte ? !number(value, small) : !number(value, large)) return failure(EPROTO); - return available(QString::number(byte ? small : large)); -} -Attribute modes(const Items &items, int type, bool supported) -{ - bool valid = true; - const QByteArray *data = item(items, type, valid); - if (!valid) return failure(EPROTO); - if (!data) return failure(ENODATA); - Items fields; - if (!attributes(*data, fields)) return failure(EPROTO); - const QByteArray *nomask = item(fields, ETHTOOL_A_BITSET_NOMASK, valid); - const QByteArray *sizeData = item(fields, ETHTOOL_A_BITSET_SIZE, valid); - const QByteArray *bits = item(fields, ETHTOOL_A_BITSET_BITS, valid); - if (!valid || !bits || (nomask && !nomask->isEmpty()) || (supported && nomask)) return failure(EPROTO); - __u32 size = bitLimit; - if (sizeData && !number(sizeData, size)) return failure(EPROTO); - if (size > bitLimit) return failure(EOVERFLOW); - Items entries; - if (!attributes(*bits, entries)) return failure(EPROTO); - if (entries.size() > static_cast(bitLimit)) return failure(EOVERFLOW); - QSet<__u32> seen; - QStringList names; - for (const Item &entry : entries) { - if (QThread::currentThread()->isInterruptionRequested()) return failure(ECANCELED); - if (entry.type != ETHTOOL_A_BITSET_BITS_BIT) return failure(EPROTO); - Items bit; - if (!attributes(entry.data, bit)) return failure(EPROTO); - __u32 index = 0; - const QByteArray *indexData = item(bit, ETHTOOL_A_BITSET_BIT_INDEX, valid); - const QByteArray *name = item(bit, ETHTOOL_A_BITSET_BIT_NAME, valid); - const QByteArray *set = item(bit, ETHTOOL_A_BITSET_BIT_VALUE, valid); - if (!valid || !number(indexData, index) || index >= size || seen.contains(index) - || (set && !set->isEmpty())) return failure(EPROTO); - seen.insert(index); - QString label; - if (name) { - if (name->isEmpty() || name->size() > 128 || !name->endsWith('\0') - || name->indexOf('\0') != name->size() - 1) return failure(EPROTO); - label = QString::fromUtf8(name->constData(), static_cast(name->size()) - 1); - } - if (label.isEmpty()) label = QStringLiteral("bit %1").arg(index); - // OURS mask = supported bits (every listed entry); PEER NOMASK = - // advertised bits (every entry). In value/mask form only VALUE bits apply. - if (supported || nomask || set) names.append(label); - } - return names.isEmpty() ? failure(ENODATA) : available(names.join('\n')); -} -} - -EthernetProperties collectEthernetProperties(const QString &name, unsigned int index) -{ - EthernetProperties result; - result.applicable = true; - result.scope = available(QString()); - result.source = QStringLiteral("ethtool generic netlink: LINKSTATE_GET / LINKMODES_GET; ifindex %1 (%2)").arg(index).arg(name); - const QByteArray encoded = name.toUtf8(); - if (encoded.isEmpty() || encoded.size() >= IFNAMSIZ || encoded.contains('\0') || !index) { - result.invalidate(failure(EINVAL)); return result; - } - if (if_nametoindex(encoded.constData()) != index) { result.invalidate(failure(ENODEV)); return result; } - Query query; - QByteArray lookup; - appendAttribute(lookup, CTRL_ATTR_FAMILY_NAME, QByteArray(ETHTOOL_GENL_NAME, sizeof(ETHTOOL_GENL_NAME))); - Items family; - if (!query.request(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, CTRL_CMD_NEWFAMILY, lookup, family)) { - result.invalidate(failure(query.error())); return result; - } - bool valid = true; - __u16 id = 0; - const QByteArray *idData = item(family, CTRL_ATTR_FAMILY_ID, valid); - if (!valid || !number(idData, id) || id < GENL_MIN_ID) { result.invalidate(failure(EPROTO)); return result; } - QByteArray stateRequest, modesRequest; - appendAttribute(stateRequest, ETHTOOL_A_LINKSTATE_HEADER | NLA_F_NESTED, deviceHeader(index)); - appendAttribute(modesRequest, ETHTOOL_A_LINKMODES_HEADER | NLA_F_NESTED, deviceHeader(index)); - Items state; - if (query.request(id, ETHTOOL_MSG_LINKSTATE_GET, ETHTOOL_MSG_LINKSTATE_GET_REPLY, stateRequest, state)) { - result.carrier = matchesDevice(state, ETHTOOL_A_LINKSTATE_HEADER, index) - ? integer(state, ETHTOOL_A_LINKSTATE_LINK, true) : failure(EPROTO); - } else result.carrier = failure(query.error()); - // Settings and carrier support are independent; a failed carrier query - // must not hide available capabilities or autonegotiation configuration. - Items settings; - if (query.request(id, ETHTOOL_MSG_LINKMODES_GET, ETHTOOL_MSG_LINKMODES_GET_REPLY, modesRequest, settings)) { - if (!matchesDevice(settings, ETHTOOL_A_LINKMODES_HEADER, index)) { - result.speed = result.duplex = result.autoneg = result.supportedModes = result.partnerModes = failure(EPROTO); - } else { - result.speed = integer(settings, ETHTOOL_A_LINKMODES_SPEED, false); - result.duplex = integer(settings, ETHTOOL_A_LINKMODES_DUPLEX, true); - result.autoneg = integer(settings, ETHTOOL_A_LINKMODES_AUTONEG, true); - result.supportedModes = modes(settings, ETHTOOL_A_LINKMODES_OURS, true); - result.partnerModes = modes(settings, ETHTOOL_A_LINKMODES_PEER, false); - } - } else result.speed = result.duplex = result.autoneg = result.supportedModes = result.partnerModes = failure(query.error()); - if (result.carrier.state == ReadState::Available) { - Items after; - Attribute afterCarrier; - if (query.request(id, ETHTOOL_MSG_LINKSTATE_GET, ETHTOOL_MSG_LINKSTATE_GET_REPLY, stateRequest, after)) { - afterCarrier = matchesDevice(after, ETHTOOL_A_LINKSTATE_HEADER, index) - ? integer(after, ETHTOOL_A_LINKSTATE_LINK, true) : failure(EPROTO); - } else afterCarrier = failure(query.error()); - if (afterCarrier.state != ReadState::Available) result.invalidate(afterCarrier); - else if (afterCarrier.value != result.carrier.value) result.invalidate(failure(EAGAIN)); - } - if (result.carrier.state == ReadState::Available && result.carrier.value == "0") { - result.speed = result.duplex = result.partnerModes = {ReadState::NotApplicable, {}, 0}; - } else if (result.carrier.state != ReadState::Available) { - result.speed = result.duplex = result.partnerModes = result.carrier; - } - if (result.autoneg.state == ReadState::Available && result.autoneg.value == "0") - result.partnerModes = {ReadState::NotApplicable, {}, 0}; - if (if_nametoindex(encoded.constData()) != index) result.invalidate(failure(ENODEV)); - return result; -} diff --git a/src/devices/ethernetproperties.h b/src/devices/ethernetproperties.h deleted file mode 100644 index 8cccf37..0000000 --- a/src/devices/ethernetproperties.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "device.h" - -// Owned read-only ethtool snapshot for one Ethernet-style, non-wireless interface. -struct EthernetProperties { - bool applicable = false; - Attribute scope {ReadState::NotApplicable, {}, 0}; - Attribute carrier; - Attribute speed; // Mbps, not measured throughput. - Attribute duplex; // Linux DUPLEX_* value. - Attribute autoneg; // Linux AUTONEG_* value. - Attribute supportedModes; // Kernel-provided bit names, one per line. - Attribute partnerModes; - QString source; - - void invalidate(const Attribute &reason) - { - scope = carrier = speed = duplex = autoneg = supportedModes = partnerModes = reason; - } -}; - -EthernetProperties collectEthernetProperties(const QString &name, unsigned int index); diff --git a/src/devices/functionnames.cpp b/src/devices/functionnames.cpp deleted file mode 100644 index 3e16d58..0000000 --- a/src/devices/functionnames.cpp +++ /dev/null @@ -1,101 +0,0 @@ -#include "functionnames.h" -#include -#include - -FunctionName knownFunctionName(const Device &d) -{ - struct Entry { const char *id; const char *label; }; - if (d.subsystem == "usb" && d.devtype == "usb_device" && d.sysname.startsWith("usb")) { - bool busValid = false, addressValid = false, vendorValid = false, productValid = false; - const uint bus = d.sysname.mid(3).toUInt(&busValid); - const uint address = d.properties.value("DEVNUM").toUInt(&addressValid); - const QStringList product = d.properties.value("PRODUCT").split('/'); - if (product.size() == 3 && busValid && bus > 0 && addressValid && address == 1) { - const uint vendor = product[0].toUInt(&vendorValid, 16); - const uint id = product[1].toUInt(&productValid, 16); - // Linux USB core's root-hub descriptor IDs; external hubs have port names. - if (vendorValid && productValid && vendor == 0x1d6b) { - const char *label = nullptr; - if (id == 1) label = QT_TRANSLATE_NOOP("DeviceFunctions", "USB 1.1 root hub"); - else if (id == 2) label = QT_TRANSLATE_NOOP("DeviceFunctions", "USB 2.0 root hub"); - else if (id == 3) label = QT_TRANSLATE_NOOP("DeviceFunctions", "USB 3.x root hub"); - if (label) return {QString::fromLatin1(label), "Linux USB root-hub topology and descriptor identity"}; - } - } - } - // Standard ACPI/PNP identities, not vendor guesses or device-name prefixes. - // Semantic reference: open-acpica/acpica source/common/ahids.c. - static const Entry ids[] = { - {"ACPI0003", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI power source")}, - {"ACPI0007", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI processor device")}, - {"ACPI0008", QT_TRANSLATE_NOOP("DeviceFunctions", "Ambient light sensor")}, - {"ACPI000C", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI processor aggregator")}, - {"ACPI000E", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI time and alarm device")}, - {"ACPI000F", QT_TRANSLATE_NOOP("DeviceFunctions", "User presence detector")}, - {"PNP0103", QT_TRANSLATE_NOOP("DeviceFunctions", "High precision event timer")}, - {"PNP0303", QT_TRANSLATE_NOOP("DeviceFunctions", "PS/2 keyboard interface")}, - {"PNP0A03", QT_TRANSLATE_NOOP("DeviceFunctions", "PCI bus")}, - {"PNP0A08", QT_TRANSLATE_NOOP("DeviceFunctions", "PCI Express bus")}, - {"PNP0B00", QT_TRANSLATE_NOOP("DeviceFunctions", "Real-time clock")}, - {"PNP0C02", QT_TRANSLATE_NOOP("DeviceFunctions", "Motherboard resources")}, - {"PNP0C09", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI embedded controller")}, - {"PNP0C0A", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI control-method battery")}, - {"PNP0C0B", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI fan")}, - {"PNP0C0C", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI power button")}, - {"PNP0C0D", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI lid switch")}, - {"PNP0C0E", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI sleep button")}, - {"PNP0C14", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI WMI interface")}, - {"PNP0C50", QT_TRANSLATE_NOOP("DeviceFunctions", "I2C human interface device")}, - {"PNP0C60", QT_TRANSLATE_NOOP("DeviceFunctions", "Display sensor")}, - {"LNXTHERM", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI thermal zone")}, - {"LNXVIDEO", QT_TRANSLATE_NOOP("DeviceFunctions", "ACPI video controller")} - }; - const QString alias = d.properties.value("MODALIAS"); - QStringList identifiers; - if ((d.subsystem == "acpi" || d.subsystem == "platform" || d.subsystem == "i2c") && alias.startsWith("acpi:")) - identifiers = alias.mid(5).split(':', Qt::SkipEmptyParts); - else if (d.subsystem == "pnp" && alias.startsWith("pnp:")) { - // PNP modaliases encode each identifier as d, including compatible IDs. - for (const auto &id : alias.mid(4).split('d', Qt::SkipEmptyParts)) identifiers.append(id); - } - for (const auto &id : identifiers) - for (const auto &entry : ids) - if (id.compare(QLatin1String(entry.id), Qt::CaseInsensitive) == 0) - return {QString::fromLatin1(entry.label), QStringLiteral("standard ACPI/PNP ID: %1").arg(id)}; - static const Entry platformAliases[] = { - {"platform:coretemp", QT_TRANSLATE_NOOP("DeviceFunctions", "Intel CPU temperature monitor")}, - {"platform:rtc-efi", QT_TRANSLATE_NOOP("DeviceFunctions", "EFI real-time clock interface")}, - {"platform:alarmtimer", QT_TRANSLATE_NOOP("DeviceFunctions", "Kernel alarm timer")} - }; - if (d.subsystem == "platform") - for (const auto &entry : platformAliases) - if (alias == QLatin1String(entry.id)) - return {QString::fromLatin1(entry.label), QStringLiteral("documented kernel platform alias: %1").arg(alias)}; - static const Entry pciServices[] = { - {"aer", QT_TRANSLATE_NOOP("DeviceFunctions", "PCI Express advanced error reporting service")}, - {"pcie_bwctrl", QT_TRANSLATE_NOOP("DeviceFunctions", "PCI Express bandwidth control service")}, - {"pcie_pme", QT_TRANSLATE_NOOP("DeviceFunctions", "PCI Express power-management event service")}, - {"pciehp", QT_TRANSLATE_NOOP("DeviceFunctions", "PCI Express hot-plug service")} - }; - if (d.subsystem == "pci_express") - for (const auto &entry : pciServices) - if (d.driver == QLatin1String(entry.id)) - return {QString::fromLatin1(entry.label), QStringLiteral("PCI Express service; direct driver: %1").arg(d.driver)}; - static const Entry fauxFunctions[] = { - {"microcode", QT_TRANSLATE_NOOP("DeviceFunctions", "CPU microcode interface")}, - {"reg-dummy", QT_TRANSLATE_NOOP("DeviceFunctions", "Dummy voltage regulator")}, - {"regulatory", QT_TRANSLATE_NOOP("DeviceFunctions", "Wireless regulatory interface")}, - {"snd-soc-dummy", QT_TRANSLATE_NOOP("DeviceFunctions", "Dummy SoC audio component")} - }; - if (d.subsystem == "faux") - for (const auto &entry : fauxFunctions) - if (d.sysname == QLatin1String(entry.id)) - return {QString::fromLatin1(entry.label), QStringLiteral("documented kernel faux function: %1").arg(d.sysname)}; - if (d.subsystem == "serial-base" && d.driver == "port") - return {QStringLiteral(QT_TRANSLATE_NOOP("DeviceFunctions", "Serial port function")), "serial-base subsystem; direct port binding"}; - if (d.subsystem == "serial-base" && d.driver == "ctrl") - return {QStringLiteral(QT_TRANSLATE_NOOP("DeviceFunctions", "Serial controller function")), "serial-base subsystem; direct ctrl binding"}; - if (d.subsystem == "bluetooth" && d.sysname.startsWith("hci") && !d.sysname.contains(':')) - return {QStringLiteral(QT_TRANSLATE_NOOP("DeviceFunctions", "Bluetooth host controller")), "Bluetooth subsystem; host-controller interface"}; - return {}; -} diff --git a/src/devices/functionnames.h b/src/devices/functionnames.h deleted file mode 100644 index e7b3931..0000000 --- a/src/devices/functionnames.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once -#include "device.h" - -struct FunctionName { - QString label; // Untranslated source text; formatting belongs to the UI. - QString source; -}; -FunctionName knownFunctionName(const Device &device); diff --git a/src/devices/modulenames.cpp b/src/devices/modulenames.cpp deleted file mode 100644 index a3c86bf..0000000 --- a/src/devices/modulenames.cpp +++ /dev/null @@ -1,101 +0,0 @@ -#include "modulenames.h" -#include -#include -#include -#include -#include -#ifdef QLDM_HAVE_KMOD -#include -#endif - -ModuleNames::ModuleNames() -{ -#ifdef QLDM_HAVE_KMOD - // Consult the running kernel's indexes, without modprobe.d overrides/commands. - const char *const noConfiguration[] = {nullptr}; - context_ = kmod_new(nullptr, noConfiguration); -#endif -} -ModuleNames::~ModuleNames() -{ -#ifdef QLDM_HAVE_KMOD - if (context_) kmod_unref(context_); -#endif -} -ModuleNames::Result ModuleNames::lookup(const QString &key, bool exactName) -{ - const QString cacheKey = (exactName ? QStringLiteral("module:") : QStringLiteral("alias:")) + key; - const auto cached = cache_.constFind(cacheKey); - if (cached != cache_.cend()) return cached.value(); - Result result; -#ifdef QLDM_HAVE_KMOD - if (!context_ || cache_.size() >= 512 || QThread::currentThread()->isInterruptionRequested()) return result; - const QByteArray encoded = key.toUtf8(); - kmod_module *raw = nullptr; - if (exactName) { - const int status = kmod_module_new_from_name_lookup(context_, encoded.constData(), &raw); - if (status < 0) { result.description.state = ReadState::Error; result.description.error = -status; } - } else { - kmod_list *matches = nullptr; - const int status = kmod_module_new_from_lookup(context_, encoded.constData(), &matches); - // Never select an arbitrary first module from ambiguous aliases. - if (status == 0 && matches && !kmod_list_next(matches, matches)) raw = kmod_module_get_module(matches); - else if (status < 0) { result.description.state = ReadState::Error; result.description.error = -status; } - kmod_module_unref_list(matches); - } - std::unique_ptr module(raw, &kmod_module_unref); - if (module) { - const char *name = kmod_module_get_name(module.get()); - if (name) result.module = QString::fromUtf8(name); - kmod_list *info = nullptr; - const int status = kmod_module_get_info(module.get(), &info); - if (status < 0) { - result.description.error = -status; - result.description.state = status == -EACCES || status == -EPERM - ? ReadState::PermissionDenied : status == -ENOENT ? ReadState::Unavailable : ReadState::Error; - } else { - kmod_list *entry = nullptr; - kmod_list_foreach(entry, info) { - const char *field = kmod_module_info_get_key(entry); - const char *value = kmod_module_info_get_value(entry); - if (!field || std::strcmp(field, "description") != 0 || !value) continue; - const size_t length = strnlen(value, 4097); - if (length > 4096) { result.description.state = ReadState::Error; result.description.error = EOVERFLOW; break; } - const QString description = QString::fromUtf8(value, static_cast(length)).trimmed(); - if (description.isEmpty()) continue; - result.description.state = ReadState::Available; - result.description.value = description; - break; - } - } - kmod_module_info_free_list(info); - } -#else - Q_UNUSED(key) - Q_UNUSED(exactName) -#endif - if (cache_.size() < 512) cache_.insert(cacheKey, result); // Cache misses too. - return result; -} -void ModuleNames::supplement(Device &d) -{ -#ifndef QLDM_HAVE_KMOD - Q_UNUSED(d) - return; -#else - if (d.subsystem == "cpu" || (d.nameSource != "kernel name" && d.nameSource != "direct kernel driver and kernel name")) return; - const QFileInfo link(d.path + "/driver/module"); - QString module; - if (link.isSymLink()) module = QFileInfo(link.symLinkTarget()).fileName(); - const bool candidate = module.isEmpty(); - const QString key = candidate ? d.properties.value("MODALIAS") : module; - if (key.isEmpty()) return; // No module link does not prove a built-in driver. - const Result result = lookup(key, !candidate); - d.attributes.insert("module_description", result.description); - if (result.description.state != ReadState::Available) return; - d.name = result.description.value; - d.nameCandidate = candidate; - d.nameSource = QStringLiteral("installed module metadata: %1; %2: %3") - .arg(result.module, candidate ? QStringLiteral("unique modalias candidate") : QStringLiteral("direct driver/module link"), key); -#endif -} diff --git a/src/devices/modulenames.h b/src/devices/modulenames.h deleted file mode 100644 index 962fc43..0000000 --- a/src/devices/modulenames.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once -#include "device.h" - -struct kmod_ctx; -// Worker-only, refresh-scoped cache. No loading/unloading or subprocesses. -class ModuleNames final { -public: - ModuleNames(); - ~ModuleNames(); - ModuleNames(const ModuleNames &) = delete; - ModuleNames &operator=(const ModuleNames &) = delete; - void supplement(Device &device); -private: - struct Result { - Attribute description; - QString module; - }; - Result lookup(const QString &key, bool exactName); - kmod_ctx *context_ = nullptr; - QHash cache_; -}; diff --git a/src/devices/monitorproperties.cpp b/src/devices/monitorproperties.cpp deleted file mode 100644 index 58e4cd4..0000000 --- a/src/devices/monitorproperties.cpp +++ /dev/null @@ -1,207 +0,0 @@ -#include "monitorproperties.h" -#include -#include -#include -#ifdef QLDM_HAVE_DISPLAY_INFO -extern "C" { -#include -#include -} -#endif -#include -#include -#include -#include -#include -#include - -namespace { -struct Report { - Attribute state; - QByteArray bytes; -}; -Attribute failure(int error) -{ - return {error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENODEV ? ReadState::Removed - : error == ENOENT || error == ENODATA ? ReadState::Unavailable : ReadState::Error, {}, error}; -} -Attribute available(const QString &text) { return {ReadState::Available, text, 0}; } -Report readReport(int directory, const char *name, int limit) -{ - const int fd = openat(directory, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); - if (fd < 0) return {failure(errno), {}}; - Report result; - char buffer[4096]; - int error = 0; - while (result.bytes.size() <= limit) { - if (QThread::currentThread()->isInterruptionRequested()) { error = ECANCELED; break; } - const int wanted = qMin(static_cast(sizeof(buffer)), limit + 1 - static_cast(result.bytes.size())); - const ssize_t count = read(fd, buffer, wanted); - if (count < 0) { - if (errno == EINTR) continue; - error = errno; - break; - } - if (count == 0) break; - result.bytes.append(buffer, static_cast(count)); - } - close(fd); - if (!error && result.bytes.size() > limit) error = EOVERFLOW; - result.state = error ? failure(error) : Attribute {ReadState::Available, {}, 0}; - if (error) result.bytes.clear(); - return result; -} -bool sameReport(const Report &a, const Report &b) -{ - return a.state.state == b.state.state && a.state.error == b.state.error && a.bytes == b.bytes; -} -unsigned byte(const QByteArray &bytes, int index) { return static_cast(bytes.at(index)); } -#ifdef QLDM_HAVE_DISPLAY_INFO -Attribute ownedString(char *text) -{ - if (!text) return {}; - const size_t size = strnlen(text, 4097); - const Attribute result = size > 4096 ? failure(EOVERFLOW) - : size ? available(QString::fromUtf8(text, static_cast(size))) : Attribute {}; - std::free(text); - return result; -} -#endif -void decodeDescriptor(MonitorProperties &result, const QByteArray &bytes, const QString &path) -{ - if (bytes.isEmpty()) { result.descriptor = {}; return; } - // Reject truncated/corrupt framing before passing a bounded report to the parser. - const QByteArray header = QByteArray::fromHex("00ffffffffffff00"); - if (bytes.size() < 128 || bytes.left(8) != header || bytes.size() != 128 * (1 + byte(bytes, 126))) { - result.descriptor = failure(EPROTO); - return; - } - for (int block = 0; block < bytes.size(); block += 128) { - unsigned sum = 0; - for (int i = block; i < block + 128; ++i) sum += byte(bytes, i); - if (sum & 255) { result.descriptor = failure(EPROTO); return; } - } - const QString version = QString::number(byte(bytes, 18)) + '.' + QString::number(byte(bytes, 19)); - result.values.insert("version", available(version)); - result.sources.insert("version", path + "/edid: bytes 18–19"); - if (byte(bytes, 18) != 1 || byte(bytes, 19) > 4) { - result.descriptor = {ReadState::Unsupported, version, 0}; - return; - } -#ifndef QLDM_HAVE_DISPLAY_INFO - result.descriptor = {ReadState::Unsupported, {}, 0}; -#else - errno = 0; - std::unique_ptr info( - di_info_parse_edid(bytes.constData(), static_cast(bytes.size())), di_info_destroy); - if (!info) { result.descriptor = failure(errno == ENOMEM ? ENOMEM : EPROTO); return; } - const di_edid *edid = di_info_get_edid(info.get()); - if (!edid) { result.descriptor = {ReadState::Unsupported, version, 0}; return; } - result.descriptor = {ReadState::Available, {}, 0}; - const auto put = [&](const QString &key, const Attribute &value, const QString &source) { - result.values.insert(key, value); - result.sources.insert(key, path + "/edid: " + source + " (libdisplay-info)"); - }; - const char *warnings = di_info_get_failure_msg(info.get()); - if (warnings) { - const size_t length = strnlen(warnings, 16385); - put("warnings", length > 16384 ? failure(EOVERFLOW) : available(QString::fromUtf8(warnings, static_cast(length)).trimmed()), - "parser conformance diagnostics; decoded values remain reported information"); - } - put("manufacturer", ownedString(di_info_get_make(info.get())), "reported manufacturer, resolved by the library's PNP database"); - put("model", ownedString(di_info_get_model(info.get())), "product name, or product code if no name was supplied"); - put("serial", ownedString(di_info_get_serial(info.get())), "serial text, or numeric serial if available"); - const di_edid_vendor_product *product = di_edid_get_vendor_product(edid); - if (product) { - put("manufacturer_code", available(QString::fromLatin1(product->manufacturer, 3)), "PNP manufacturer code"); - put("product", available(QString("0x%1").arg(product->product, 4, 16, QChar('0'))), "reported product code"); - } - const di_edid_screen_size *size = di_edid_get_screen_size(edid); - put("size", size && size->width_cm > 0 && size->height_cm > 0 - ? available(QString::number(size->width_cm) + ' ' + QString::number(size->height_cm)) : Attribute {}, - "reported image dimensions in cm; unspecified/aspect-ratio encodings are not physical dimensions"); - const di_edid_video_input_digital *digital = di_edid_get_video_input_digital(edid); - put("digital", available(digital ? "1" : "0"), "base-block video input type"); - if (digital) { - put("depth", digital->color_bit_depth > 0 ? available(QString::number(digital->color_bit_depth)) : Attribute {}, - "declared bits per color component, not active link or framebuffer depth"); - // Use stable semantic names, not enum ordinals, in the owned snapshot. - QString interface; - switch (digital->interface) { - case DI_EDID_VIDEO_INPUT_DIGITAL_UNDEFINED: break; - case DI_EDID_VIDEO_INPUT_DIGITAL_DVI: interface = "DVI"; break; - case DI_EDID_VIDEO_INPUT_DIGITAL_HDMI_A: interface = "HDMI-A"; break; - case DI_EDID_VIDEO_INPUT_DIGITAL_HDMI_B: interface = "HDMI-B"; break; - case DI_EDID_VIDEO_INPUT_DIGITAL_MDDI: interface = "MDDI"; break; - case DI_EDID_VIDEO_INPUT_DIGITAL_DISPLAYPORT: interface = "DisplayPort"; break; - } - put("interface", interface.isEmpty() ? Attribute {} : available(interface), "reported digital input interface"); - } -#endif -} -Attribute modeNames(const Report &report) -{ - if (report.state.state != ReadState::Available) return report.state; - if (report.bytes.isEmpty()) return {}; - const QList lines = report.bytes.split('\n'); - if (lines.size() > 2049) return failure(EOVERFLOW); - QStringList names; - QSet seen; - int count = 0; - for (const QByteArray &line : lines) { - if (QThread::currentThread()->isInterruptionRequested()) return failure(ECANCELED); - if (line.isEmpty()) continue; - if (++count > 2048) return failure(EOVERFLOW); - if (line.size() > 128) return failure(EOVERFLOW); - for (char c : line) if (static_cast(c) < 32 || static_cast(c) > 126) return failure(EILSEQ); - const QString name = QString::fromLatin1(line); - if (!seen.contains(name)) { seen.insert(name); names.append(name); } - } - return names.isEmpty() ? Attribute {} : available(names.join('\n')); -} -} // namespace - -MonitorProperties collectMonitorProperties(int directory, const QString &path) -{ - MonitorProperties result; - const Report status = readReport(directory, "status", 64); - if (status.state.state == ReadState::Available && status.bytes.trimmed() == "disconnected") return result; - result.applicable = true; - result.sources.insert("descriptor", path + "/edid: cached kernel descriptor; header, length and every block checksum checked. " - "EDID 1.0–1.4 identity/input fields are decoded with optional libdisplay-info. Overrides, adapters or KVMs may supply this report."); - result.sources.insert("modes", path + "/modes: cached kernel mode names, with duplicate names combined. " - "These may include driver additions or restrictions; names do not distinguish refresh rates. Not a guaranteed capability list or active configuration."); - if (status.state.state != ReadState::Available) { - result.descriptor = status.state; - result.sources["descriptor"] = path + "/status: connection status could not be read"; - result.values.insert("modes", status.state); - return result; - } - if (status.bytes.trimmed() != "connected" && status.bytes.trimmed() != "unknown") { - result.descriptor = failure(EINVAL); - result.sources["descriptor"] = path + "/status: malformed connection status"; - result.values.insert("modes", result.descriptor); - return result; - } - const Report edid = readReport(directory, "edid", 32768); - const Report modes = readReport(directory, "modes", 65536); - // A monitor may be replaced without the connector directory changing identity. - const Report afterEdid = readReport(directory, "edid", 32768); - const Report afterStatus = readReport(directory, "status", 64); - if (afterStatus.state.state != ReadState::Available - || (afterEdid.state.state != ReadState::Available && !sameReport(edid, afterEdid))) { - result.descriptor = afterStatus.state.state != ReadState::Available ? afterStatus.state : afterEdid.state; - result.values.insert("modes", result.descriptor); - return result; - } - if (!sameReport(status, afterStatus) || !sameReport(edid, afterEdid)) { - result.descriptor = failure(EAGAIN); - result.values.insert("modes", result.descriptor); - return result; - } - if (edid.state.state == ReadState::Available) decodeDescriptor(result, edid.bytes, path); - else result.descriptor = edid.state; - result.values.insert("modes", modeNames(modes)); - return result; -} diff --git a/src/devices/monitorproperties.h b/src/devices/monitorproperties.h deleted file mode 100644 index 1fbc3a8..0000000 --- a/src/devices/monitorproperties.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include "device.h" - -// Cached hardware reports from this DRM connector, separate from session settings. -struct MonitorProperties { - bool applicable = false; - Attribute descriptor; - QHash values; - QHash sources; -}; - -// Explicit Properties snapshots only. The caller owns the selected connector fd. -MonitorProperties collectMonitorProperties(int directory, const QString &path); diff --git a/src/devices/monitorstate.cpp b/src/devices/monitorstate.cpp deleted file mode 100644 index 583e29c..0000000 --- a/src/devices/monitorstate.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "monitorstate.h" -#include - -namespace { -bool related(const QString &a, const QString &b) -{ - return a == b || a.startsWith(b + '/') || b.startsWith(a + '/'); -} -} -void MonitorState::observe(const QString &path, bool removal) -{ - if (!path.startsWith("/sys/devices/")) return; - dirty = true; - if (changed.size() >= Limit || removed.size() >= Limit) { lose(); return; } - changed.insert(path); - if (removal) removed.insert(path); -} -void MonitorState::lose() -{ - dirty = true; - lost = true; - changed.clear(); - removed.clear(); -} -void MonitorState::beginScan() -{ - changed.clear(); - dirty = false; - // Removal/identity-loss evidence survives all scans until GUI publication. -} -void MonitorState::excludeUnsettled(Inventory &inventory) const -{ - if (!dirty) return; - if (lost) { - inventory.skipped += static_cast(inventory.devices.size()); - inventory.devices.clear(); - return; - } - QSet excluded = changed; - // Include real ancestry and presentation dependencies. An ancestor's metadata - // may supply a child's name, grouping or driver relationship, and vice versa. - bool extended; - do { - extended = false; - for (const Device &d : inventory.devices) { - if (excluded.contains(d.path)) { - if (!d.representedByPath.isEmpty() && !excluded.contains(d.representedByPath)) { - excluded.insert(d.representedByPath); - extended = true; - } - continue; - } - bool affected = !d.representedByPath.isEmpty() && excluded.contains(d.representedByPath); - if (!affected) { - for (const QString &path : changed) - if (related(d.path, path)) { affected = true; break; } - } - if (affected) { excluded.insert(d.path); extended = true; } - } - } while (extended); - const auto end = std::remove_if(inventory.devices.begin(), inventory.devices.end(), - [&](const Device &d) { return excluded.contains(d.path); }); - inventory.skipped += static_cast(std::distance(end, inventory.devices.end())); - inventory.devices.erase(end, inventory.devices.end()); -} diff --git a/src/devices/monitorstate.h b/src/devices/monitorstate.h deleted file mode 100644 index 33ab8e7..0000000 --- a/src/devices/monitorstate.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include "device.h" - -// Owned, bounded hints. Event payloads never become inventory records. -struct MonitorState { - static constexpr int Limit = 4096; - QSet changed; - QSet removed; - bool lost = false; - bool dirty = false; - void observe(const QString &path, bool removal); - void lose(); - void beginScan(); - void excludeUnsettled(Inventory &inventory) const; -}; diff --git a/src/devices/networkproperties.cpp b/src/devices/networkproperties.cpp deleted file mode 100644 index 1420fcc..0000000 --- a/src/devices/networkproperties.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "networkproperties.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -Attribute failure(int error) -{ - return {error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENODEV || error == ENXIO ? ReadState::Removed - : error == EOPNOTSUPP || error == ENODATA ? ReadState::Unavailable : ReadState::Error, {}, error}; -} -Attribute ipText(const sockaddr *address) -{ - if (!address) return {}; - const void *bytes = nullptr; - if (address->sa_family == AF_INET) - bytes = &reinterpret_cast(address)->sin_addr; - else if (address->sa_family == AF_INET6) - bytes = &reinterpret_cast(address)->sin6_addr; - else return failure(EAFNOSUPPORT); - char text[INET6_ADDRSTRLEN]; - if (!inet_ntop(address->sa_family, bytes, text, sizeof(text))) return failure(errno); - return {ReadState::Available, QString::fromLatin1(text), 0}; -} -Attribute permanentAddress(const QByteArray &name) -{ - // ETHTOOL_GPERMADDR is a read-only, unprivileged query. Drivers may report - // all zeroes when no permanent address is known; that is unavailable evidence. - const int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); - if (fd < 0) return failure(errno); - constexpr unsigned int addressLimit = 32; // Linux MAX_ADDR_LEN. - std::unique_ptr query( - static_cast(std::calloc(1, sizeof(ethtool_perm_addr) + addressLimit)), &std::free); - if (!query) { close(fd); return failure(ENOMEM); } - query->cmd = ETHTOOL_GPERMADDR; - query->size = addressLimit; - ifreq request {}; - std::memcpy(request.ifr_name, name.constData(), static_cast(name.size())); - request.ifr_data = reinterpret_cast(query.get()); - const int status = ioctl(fd, SIOCETHTOOL, &request); - const int error = errno; - close(fd); - if (status < 0) return failure(error); - // This collector requests MAC addresses only for Ethernet-style interfaces. - if (query->size != 6) return {}; - bool nonzero = false; - QStringList bytes; - for (unsigned int i = 0; i < query->size; ++i) { - nonzero = nonzero || query->data[i] != 0; - bytes.append(QStringLiteral("%1").arg(query->data[i], 2, 16, QLatin1Char('0'))); - } - return nonzero ? Attribute {ReadState::Available, bytes.join(':'), 0} : Attribute {}; -} -} - -NetworkProperties collectNetworkProperties(const QString &name, unsigned int index, bool ethernet) -{ - NetworkProperties result; - const QByteArray encoded = name.toUtf8(); - if (encoded.isEmpty() || encoded.size() >= IFNAMSIZ || encoded.contains('\0') || !index) { - result.addressState = result.permanentAddress = failure(EINVAL); - return result; - } - if (if_nametoindex(encoded.constData()) != index) { - result.addressState = result.permanentAddress = failure(ENODEV); - return result; - } - ifaddrs *raw = nullptr; - if (getifaddrs(&raw) != 0) result.addressState = failure(errno); - else { - std::unique_ptr addresses(raw, &freeifaddrs); - result.addressState = {ReadState::Available, {}, 0}; - for (const ifaddrs *entry = addresses.get(); entry; entry = entry->ifa_next) { - if (QThread::currentThread()->isInterruptionRequested()) { - result.addresses.clear(); - result.addressState = failure(ECANCELED); - break; - } - if (!entry->ifa_name || !entry->ifa_addr) continue; - // getifaddrs may use the IPv4 address label (e.g. eth0:1) instead - // of the base interface name. Include these secondary addresses. - const size_t length = static_cast(encoded.size()); - if (std::strcmp(entry->ifa_name, encoded.constData()) != 0 - && !(std::strncmp(entry->ifa_name, encoded.constData(), length) == 0 - && entry->ifa_name[length] == ':')) continue; - const int family = entry->ifa_addr->sa_family; - if (family != AF_INET && family != AF_INET6) continue; - const Attribute address = ipText(entry->ifa_addr); - if (address.state != ReadState::Available) { - result.addresses.clear(); - result.addressState = address; - break; - } - QString text = address.value; - if (family == AF_INET6) { - const unsigned int scope = reinterpret_cast(entry->ifa_addr)->sin6_scope_id; - if (scope) text += '%' + QString::number(scope); - } - const Attribute mask = entry->ifa_netmask && entry->ifa_netmask->sa_family == family - ? ipText(entry->ifa_netmask) : Attribute {}; - result.addresses.append({family == AF_INET ? QStringLiteral("IPv4") : QStringLiteral("IPv6"), text, mask}); - } - } - if (!QThread::currentThread()->isInterruptionRequested()) - result.permanentAddress = ethernet ? permanentAddress(encoded) - : Attribute {ReadState::NotApplicable, {}, 0}; - if (if_nametoindex(encoded.constData()) != index) { - result.addresses.clear(); - result.addressState = result.permanentAddress = failure(ENODEV); - } - return result; -} diff --git a/src/devices/networkproperties.h b/src/devices/networkproperties.h deleted file mode 100644 index 3e29610..0000000 --- a/src/devices/networkproperties.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "device.h" -#include "ethernetproperties.h" - -struct NetworkAddress { - QString family; - QString address; - Attribute netmask; -}; - -// Owned worker-thread results for one interface in the application's network namespace. -struct NetworkProperties { - QString name; - QString path; - Attribute currentAddress; - Attribute assignmentType; - Attribute linkType; - Attribute mtu; - QVector addresses; - Attribute addressState; - Attribute permanentAddress; - EthernetProperties ethernet; -}; - -NetworkProperties collectNetworkProperties(const QString &name, unsigned int index, bool ethernet); diff --git a/src/devices/propertytext.cpp b/src/devices/propertytext.cpp deleted file mode 100644 index e5abcb1..0000000 --- a/src/devices/propertytext.cpp +++ /dev/null @@ -1,331 +0,0 @@ -#include "propertytext.h" -#include "devicelabel.h" -#include -#include -#include -#include -#include -#include - -QString propertyReadValue(const Attribute &a) -{ - switch (a.state) { - case ReadState::Available: - return a.value.isEmpty() ? QCoreApplication::translate("PropertiesDialog", "Empty value") : a.value; - case ReadState::Unavailable: return QCoreApplication::translate("PropertiesDialog", "Unavailable"); - case ReadState::PermissionDenied: return QCoreApplication::translate("PropertiesDialog", "Permission denied"); - case ReadState::Removed: return QCoreApplication::translate("PropertiesDialog", "Device removed"); - case ReadState::Unsupported: return QCoreApplication::translate("PropertiesDialog", "Unsupported"); - case ReadState::NotApplicable: return QCoreApplication::translate("PropertiesDialog", "Not applicable"); - case ReadState::Error: - switch (a.error) { - case ETIMEDOUT: return QCoreApplication::translate("PropertiesDialog", "Query timed out (errno %1)").arg(a.error); - case EOVERFLOW: return QCoreApplication::translate("PropertiesDialog", "Data exceeds supported limits (errno %1)").arg(a.error); - case EAGAIN: return QCoreApplication::translate("PropertiesDialog", "Temporarily unavailable; retry the query (errno %1)").arg(a.error); - case EINVAL: - case EPROTO: return QCoreApplication::translate("PropertiesDialog", "Invalid data or query (errno %1)").arg(a.error); - case ECANCELED: return QCoreApplication::translate("PropertiesDialog", "Query cancelled (errno %1)").arg(a.error); - default: return QCoreApplication::translate("PropertiesDialog", "Read error (errno %1)").arg(a.error); - } - } - return {}; -} -QString detailsPropertyStatus(const PropertyEntry &entry) -{ - if (entry.state == ReadState::Available) - return entry.value.isEmpty() ? QCoreApplication::translate("PropertiesDialog", "Empty value").toLower() : QString(); - return propertyReadValue({entry.state, {}, entry.error}).toLower(); -} -QString storageFieldValue(const StorageField &field) -{ - const Attribute &value = field.value; - if (field.format == StorageField::Format::HealthQueryStatus - && value.state == ReadState::Unavailable && value.error == ETIMEDOUT) - return QCoreApplication::translate("Storage", "Not queried: shared health-query time limit reached"); - if (value.state == ReadState::Unsupported) { - if (field.id == "service") - return QCoreApplication::translate("Storage", "UDisks2 support is not enabled in this build"); - if (field.id == "health") - return QCoreApplication::translate("Storage", "Drive health is not exposed through a supported UDisks2 interface"); - if (field.id == "org.freedesktop.UDisks2.Drive.Ata/SmartGetAttributes" - || field.id == "org.freedesktop.UDisks2.NVMe.Controller/SmartGetAttributes") - return QCoreApplication::translate("Storage", "Detailed health query is not supported by UDisks2"); - } - if (value.state != ReadState::Available) return propertyReadValue(value); - using Format = StorageField::Format; - switch (field.format) { - case Format::HealthQueryStatus: - case Format::Text: - if (field.id == "service" && value.value == "cached") return QCoreApplication::translate("Storage", "Available; cached properties only"); - if (field.id == "mountinfo" && value.value == "application-namespace") - return QCoreApplication::translate("Storage", "Application mount namespace only; other namespaces may differ. No filesystem is mounted or unlocked."); - if (field.id == "mounts" && value.value == "no-observed-mount") - return QCoreApplication::translate("Storage", "No mount observed in the application namespace"); - return propertyReadValue(value); - case Format::Bytes: { - bool valid = false; - const quint64 bytes = value.value.toULongLong(&valid); - if (!valid) return QCoreApplication::translate("Storage", "Invalid capacity or sector-size metadata"); - if (bytes > static_cast(std::numeric_limits::max())) return QCoreApplication::translate("Storage", "%1 bytes").arg(QLocale().toString(bytes)); - return QCoreApplication::translate("Storage", "%1 bytes (%2)").arg(QLocale().toString(bytes), QLocale().formattedDataSize(static_cast(bytes))); - } - case Format::Boolean: return value.value == "1" ? QCoreApplication::translate("Storage", "Yes") : value.value == "0" ? QCoreApplication::translate("Storage", "No") : QCoreApplication::translate("Storage", "Invalid boolean metadata"); - case Format::Removable: return value.value == "1" ? QCoreApplication::translate("Storage", "Removable (UDisks2 hint)") : value.value == "0" ? QCoreApplication::translate("Storage", "Fixed (UDisks2 hint)") : QCoreApplication::translate("Storage", "Invalid removable metadata"); - case Format::Epoch: { - bool valid = false; - const quint64 seconds = value.value.toULongLong(&valid); - if (!valid || !seconds || seconds > static_cast(std::numeric_limits::max())) return QCoreApplication::translate("Storage", "Unavailable"); - const QDateTime time = QDateTime::fromSecsSinceEpoch(static_cast(seconds)); - return time.isValid() ? time.toString(Qt::ISODate) : QCoreApplication::translate("Storage", "Unavailable"); - } - case Format::Kelvin: { - bool valid = false; - const double kelvin = value.value.toDouble(&valid); - const int precision = field.id.startsWith("org.freedesktop.UDisks2.NVMe.Controller/") ? 0 : 1; - return valid && kelvin > 0 ? QCoreApplication::translate("Storage", "%1 °C").arg(QLocale().toString(kelvin - 273.15, 'f', precision)) : QCoreApplication::translate("Storage", "Unavailable"); - } - case Format::Percent: - case Format::Hours: - case Format::Seconds: - case Format::Minutes: - case Format::Count: { - bool valid = false; - const quint64 number = value.value.toULongLong(&valid); - if (!valid) return QCoreApplication::translate("Storage", "Invalid health metadata"); - const QString text = QLocale().toString(number); - if (field.format == Format::Percent) return QCoreApplication::translate("Storage", "%1%").arg(text); - if (field.format == Format::Seconds) return QCoreApplication::translate("Storage", "%1 seconds (%2 hours)").arg(text, QLocale().toString(number / 3600.0, 'f', 1)); - if (field.format == Format::Hours) return QCoreApplication::translate("Storage", "%1 hours").arg(text); - if (field.format == Format::Minutes) return QCoreApplication::translate("Storage", "%1 minutes").arg(text); - return text; - } - case Format::AtaHealth: return value.value == "1" ? QCoreApplication::translate("Storage", "Failure predicted by drive") - : value.value == "0" ? QCoreApplication::translate("Storage", "No failure predicted by drive (not a guarantee of health)") : QCoreApplication::translate("Storage", "Unavailable"); - case Format::NvmeHealth: { - if (value.value.isEmpty()) return QCoreApplication::translate("Storage", "No critical warnings reported (not a guarantee of health)"); - QStringList warnings; - for (const QString &warning : value.value.split('\n', Qt::SkipEmptyParts)) { - const char *text = nullptr; - if (warning == "spare") text = QT_TRANSLATE_NOOP("Storage", "Available spare is below its threshold"); - else if (warning == "temperature") text = QT_TRANSLATE_NOOP("Storage", "Temperature is outside a permitted threshold"); - else if (warning == "degraded") text = QT_TRANSLATE_NOOP("Storage", "NVM subsystem reliability is degraded"); - else if (warning == "readonly") text = QT_TRANSLATE_NOOP("Storage", "Media is in read-only mode"); - else if (warning == "volatile_mem") text = QT_TRANSLATE_NOOP("Storage", "Volatile-memory backup has failed"); - else if (warning == "pmr_readonly") text = QT_TRANSLATE_NOOP("Storage", "Persistent memory region is read-only or unreliable"); - warnings.append(text ? QCoreApplication::translate("Storage", text) - : QCoreApplication::translate("Storage", "Unrecognized warning: %1").arg(warning)); - } - return warnings.join('\n'); - } - } - return {}; -} -namespace { -QString hexDump(const QByteArray &bytes) -{ - QStringList lines; - for (int offset = 0; offset < bytes.size(); offset += 16) { - QStringList hex; - QString printable; - for (int i = offset; i < qMin(offset + 16, static_cast(bytes.size())); ++i) { - const unsigned char byte = static_cast(bytes.at(i)); - hex.append(QStringLiteral("%1").arg(byte, 2, 16, QLatin1Char('0'))); - printable += byte >= 32 && byte <= 126 ? QLatin1Char(static_cast(byte)) : QLatin1Char('.'); - } - lines.append(QStringLiteral("%1 %2 %3").arg(offset, 8, 16, QLatin1Char('0')) - .arg(hex.join(' ').leftJustified(47), printable)); - } - return lines.join('\n'); -} - -} - -QVector propertyEntries(const DeviceProperties &snapshot, bool removed) -{ - QVector entries; - const Device &d = snapshot.device; - const bool efi = d.subsystem == "efivarfs"; - const Attribute notApplicable {ReadState::NotApplicable, {}, 0}; - const auto add = [&](const QString &id, const QString &label, const QString &value, - const QString &source = QString(), bool advanced = false, int tab = 2, - ReadState state = ReadState::Available, int error = 0) { - entries.append({id, label, value, source, advanced, tab, state, error}); - }; - const auto raw = [&](const QString &id) { return snapshot.values.value(id); }; - const auto addRaw = [&](const QString &id, const QString &label, int tab, const Attribute &fallback = Attribute()) { - const Attribute a = snapshot.values.value(id, fallback); - add(id, label, propertyReadValue(a), snapshot.sources.value(id), false, tab, a.state, a.error); - }; - if (snapshot.storage.applicable) { - add("storage/scope", QCoreApplication::translate("Storage", "Storage snapshot"), - QCoreApplication::translate("Storage", "Each entity is a separate storage layer; capacities are not added. Health is cached UDisks2 evidence, separate from operational status. Reload never requests SMART updates or self-tests. Deep search includes native metadata only."), {}, false, 3); - for (const StorageField ¬e : snapshot.storage.notes) - add("storage/note/" + note.id, QCoreApplication::translate("Storage", note.label), storageFieldValue(note), note.source, false, 3); - for (const StorageEntity &entity : snapshot.storage.entities) { - const QString prefix = "storage/entity/" + entity.id; - add(prefix, QCoreApplication::translate("Storage", "Storage entity"), entity.id, {}, false, 3); - for (const StorageField &field : entity.fields) - add(prefix + '/' + field.id, QCoreApplication::translate("Storage", field.label), storageFieldValue(field), field.source, false, 3); - } - int index = 0; - for (const StorageLink &edge : snapshot.storage.links) - add("storage/link/" + QString::number(index++), QCoreApplication::translate("Storage", edge.label), - edge.from + "\n→ " + edge.to, edge.source, false, 3); - } - add("name", QCoreApplication::translate("PropertiesDialog", "Device name"), deviceDisplayName(d), d.nameSource, false, 0); - add("category", QCoreApplication::translate("PropertiesDialog", "Category"), categoryLabel(d.category), QCoreApplication::translate("PropertiesDialog", "Application grouping"), false, 0); - Attribute manufacturer; - QString manufacturerSource; - if (efi) manufacturer = notApplicable; - else { - for (const QString &id : {QStringLiteral("sysfs/manufacturer"), QStringLiteral("sysfs/vendor_name"), - QStringLiteral("udev/ID_VENDOR_FROM_DATABASE"), QStringLiteral("udev/ID_VENDOR")}) { - const Attribute candidate = raw(id); - if (candidate.state == ReadState::Available && !candidate.value.isEmpty()) { - manufacturer = candidate; manufacturerSource = snapshot.sources.value(id); break; - } - if (candidate.state == ReadState::PermissionDenied || candidate.state == ReadState::Error) { - manufacturer = candidate; manufacturerSource = snapshot.sources.value(id); break; - } - } - if (manufacturer.state == ReadState::Unavailable) { - for (const QString &key : {QStringLiteral("ID_VENDOR_FROM_DATABASE"), QStringLiteral("ID_VENDOR")}) { - if (d.properties.value(key).isEmpty()) continue; - manufacturer = {ReadState::Available, d.properties.value(key), 0}; - manufacturerSource = d.propertySources.value(key, QStringLiteral("udev: %1 (%2)").arg(key, d.path)); - break; - } - } - } - add("manufacturer", QCoreApplication::translate("PropertiesDialog", "Manufacturer"), propertyReadValue(manufacturer), manufacturerSource, false, 0, manufacturer.state, manufacturer.error); - Attribute bus = raw("udev/ID_BUS"); - if (bus.state != ReadState::Available) bus = raw("driver/bus"); - if (efi) bus = notApplicable; - add("bus", QCoreApplication::translate("PropertiesDialog", "Bus / subsystem"), efi ? propertyReadValue(bus) - : bus.state == ReadState::Available ? bus.value + " / " + d.subsystem : d.subsystem, - QCoreApplication::translate("PropertiesDialog", "Device udev metadata and direct driver bus"), false, 0, efi ? ReadState::NotApplicable : ReadState::Available); - add("location", QCoreApplication::translate("PropertiesDialog", "Location"), efi ? d.parentPath : d.path, QCoreApplication::translate("PropertiesDialog", "Kernel path"), false, 0); - QString status; - if (removed) status = QCoreApplication::translate("PropertiesDialog", "Removed or replaced; showing a snapshot."); - else if (snapshot.state != ReadState::Available) status = QCoreApplication::translate("PropertiesDialog", "Present in the last inventory; properties have not been verified."); - else { - status = QCoreApplication::translate("PropertiesDialog", "Present at the last property read."); - if (!efi) { - const Attribute binding = raw("driver/name"); - status += '\n' + (binding.state == ReadState::Available - ? binding.value.isEmpty() ? QCoreApplication::translate("PropertiesDialog", "No directly bound driver; this alone is not a fault.") : QCoreApplication::translate("PropertiesDialog", "Driver bound: %1").arg(binding.value) - : QCoreApplication::translate("PropertiesDialog", "Driver binding: %1").arg(propertyReadValue(binding))); - const Attribute authorization = raw("sysfs/authorized"); - if (authorization.state == ReadState::Available && authorization.value == "0") status += '\n' + QCoreApplication::translate("PropertiesDialog", "USB device/interface is unauthorized."); - else if (authorization.state == ReadState::Available && authorization.value == "1") status += '\n' + QCoreApplication::translate("PropertiesDialog", "USB device/interface is authorized."); - else if (authorization.state == ReadState::PermissionDenied || authorization.state == ReadState::Error) - status += '\n' + QCoreApplication::translate("PropertiesDialog", "USB authorization: %1").arg(propertyReadValue(authorization)); - } - } - add("status", QCoreApplication::translate("PropertiesDialog", "Device status"), status, QCoreApplication::translate("PropertiesDialog", "Observed presence, binding and USB authorization only"), false, 0); - Attribute binding = snapshot.values.value("driver/name", d.driver.isEmpty() ? Attribute() - : Attribute{ReadState::Available, d.driver, 0}); - if (efi) binding = notApplicable; - const QString driverName = !efi && binding.state == ReadState::Available && binding.value.isEmpty() - ? QCoreApplication::translate("PropertiesDialog", "No directly bound driver") : propertyReadValue(binding); - add("driver/name", QCoreApplication::translate("PropertiesDialog", "Bound kernel driver"), driverName, efi ? QString() : d.path + "/driver", false, 0, binding.state, binding.error); - QStringList identifiers; - for (const QString &key : {QStringLiteral("PCI_ID"), QStringLiteral("PCI_SUBSYS_ID"), QStringLiteral("PRODUCT"), - QStringLiteral("ID_VENDOR_ID"), QStringLiteral("ID_MODEL_ID"), QStringLiteral("ID_SERIAL_SHORT")}) { - const Attribute a = snapshot.values.value("udev/" + key, - d.properties.contains(key) ? Attribute{ReadState::Available, d.properties.value(key), 0} : Attribute()); - if (a.state == ReadState::Available && !a.value.isEmpty()) identifiers.append(key + ": " + a.value); - } - for (const QString &key : {QStringLiteral("vendor"), QStringLiteral("device"), QStringLiteral("idVendor"), - QStringLiteral("idProduct"), QStringLiteral("serial"), QStringLiteral("hid"), QStringLiteral("uid")}) { - const Attribute a = raw("sysfs/" + key); - if (a.state != ReadState::Unavailable && !(a.state == ReadState::Available && a.value.isEmpty())) - identifiers.append(key + ": " + propertyReadValue(a)); - } - add("identifiers", QCoreApplication::translate("PropertiesDialog", "Hardware identifiers"), efi ? propertyReadValue(notApplicable) - : identifiers.isEmpty() ? QCoreApplication::translate("PropertiesDialog", "Unavailable") : identifiers.join('\n'), QCoreApplication::translate("PropertiesDialog", "Selected udev and direct sysfs identifiers"), false, 0, - efi ? ReadState::NotApplicable : identifiers.isEmpty() ? ReadState::Unavailable : ReadState::Available); - add("driver/binding", QCoreApplication::translate("PropertiesDialog", "Bound kernel driver"), driverName, efi ? QString() : d.path + "/driver", false, 1, binding.state, binding.error); - const Attribute moduleFallback = efi || (binding.state == ReadState::Available && binding.value.isEmpty()) - ? notApplicable : Attribute(); - addRaw("driver/bus", QCoreApplication::translate("PropertiesDialog", "Driver bus"), 1, moduleFallback); - addRaw("module/name", QCoreApplication::translate("PropertiesDialog", "Owning kernel module"), 1, moduleFallback); - const Attribute type = snapshot.values.value("module/type", moduleFallback); - add("module/type", QCoreApplication::translate("PropertiesDialog", "Built-in or modular"), type.state == ReadState::Available ? QCoreApplication::translate("PropertiesDialog", "Modular (kernel initstate evidence)") - : type.state == ReadState::NotApplicable || type.state == ReadState::PermissionDenied || type.state == ReadState::Error - ? propertyReadValue(type) : QCoreApplication::translate("PropertiesDialog", "Undetermined; a missing module link/version does not prove a built-in driver."), - snapshot.sources.value("module/type"), false, 1, - type.state == ReadState::NotApplicable || type.state == ReadState::PermissionDenied || type.state == ReadState::Error - ? type.state : ReadState::Available, type.error); - addRaw("module/runtime_version", QCoreApplication::translate("PropertiesDialog", "Running module version"), 1, moduleFallback); - const QStringList fields {"filename", "version", "description", "author", "license", "firmware"}; - const QStringList labels {QCoreApplication::translate("PropertiesDialog", "Installed module filename"), QCoreApplication::translate("PropertiesDialog", "Installed module version"), QCoreApplication::translate("PropertiesDialog", "Installed module description"), - QCoreApplication::translate("PropertiesDialog", "Installed module author"), QCoreApplication::translate("PropertiesDialog", "Installed module license"), QCoreApplication::translate("PropertiesDialog", "Declared firmware names")}; - for (int i = 0; i < fields.size(); ++i) addRaw("module/" + fields[i], labels[i], 1, moduleFallback); - addRaw("sysfs/firmware_rev", QCoreApplication::translate("PropertiesDialog", "Reported device firmware revision"), 1, - d.subsystem == "nvme" ? Attribute() : notApplicable); - add("module/note", QCoreApplication::translate("PropertiesDialog", "Metadata scope"), efi ? propertyReadValue(notApplicable) - : QCoreApplication::translate("PropertiesDialog", "Installed metadata may differ from code already loaded before an update. Declared firmware names do not prove firmware is loaded or report its version."), {}, false, 1, - efi ? ReadState::NotApplicable : ReadState::Available); - // Interpreted facts plus curated raw values, keeping provenance beside each. - add("parent", QCoreApplication::translate("PropertiesDialog", "Parent device path"), d.parentPath.isEmpty() ? QCoreApplication::translate("PropertiesDialog", "Unavailable") : d.parentPath, QCoreApplication::translate("PropertiesDialog", "Recorded ancestry"), efi, 2, d.parentPath.isEmpty() ? ReadState::Unavailable : ReadState::Available); - add("raw_name", QCoreApplication::translate("PropertiesDialog", "Original name"), d.name, d.nameSource, true); - add("name_source", QCoreApplication::translate("PropertiesDialog", "Name source"), d.nameSource, {}, true); - add("sysname", QCoreApplication::translate("PropertiesDialog", "Kernel name"), d.sysname, {}, true); - add("path", QCoreApplication::translate("PropertiesDialog", "Full kernel path"), d.path, {}, true); - if (!efi) add("driver/path", QCoreApplication::translate("PropertiesDialog", "Direct driver path"), propertyReadValue(raw("driver/path")), - snapshot.sources.value("driver/path"), true, 2, raw("driver/path").state, raw("driver/path").error); - QStringList keys = snapshot.values.keys(); - keys.sort(); - for (const QString &key : keys) { - if (!key.startsWith("udev/") && !key.startsWith("sysfs/")) continue; - if (key == "sysfs/firmware_rev") continue; // Already exposed on the Driver tab. - add(key, key, propertyReadValue(raw(key)), snapshot.sources.value(key), true, 2, raw(key).state, raw(key).error); - } - keys = d.properties.keys(); - keys.sort(); - for (const QString &key : keys) { - if (snapshot.values.contains("udev/" + key)) continue; - add("inventory/" + key, QCoreApplication::translate("PropertiesDialog", "Inventory: %1").arg(key), d.properties.value(key), - d.propertySources.value(key, QStringLiteral("inventory udev metadata")), true); - } - keys = d.attributes.keys(); - keys.sort(); - for (const QString &key : keys) - add("inventory/sysfs/" + key, QCoreApplication::translate("PropertiesDialog", "Inventory attribute: %1").arg(key), propertyReadValue(d.attributes.value(key)), QCoreApplication::translate("PropertiesDialog", "Last inventory; see name source for derived naming metadata"), true, 2, d.attributes.value(key).state, d.attributes.value(key).error); - if (efi) { - const Attribute hex = raw("efi/hex"); - QString dump = propertyReadValue(hex); - if (hex.state == ReadState::Available) { - dump = snapshot.efiBytes.isEmpty() ? QCoreApplication::translate("PropertiesDialog", "Empty file") : hexDump(snapshot.efiBytes); - if (snapshot.efiTruncated) dump += '\n' + QCoreApplication::translate("PropertiesDialog", "Truncated: first 65536 bytes shown."); - } - add("efi/hex", QCoreApplication::translate("PropertiesDialog", "UEFI variable — hex dump"), dump, - QCoreApplication::translate("PropertiesDialog", "Complete efivarfs file bytes, including the attribute prefix; contents are not decoded."), false, 2, hex.state, hex.error); - } - return entries; -} - -// Storage identity and content belong to the storage tabs even in advanced mode. -// Generic device/bus/driver facts remain available for all device classes. -bool isDetailsProperty(const PropertyEntry &entry, const Device &device) -{ - if (entry.tab == 3 || entry.id.startsWith("storage/")) return false; - if (device.subsystem != "block" && device.subsystem != "nvme") return true; - QString key = entry.id; - for (const QString &prefix : {QStringLiteral("inventory/sysfs/"), QStringLiteral("inventory/"), - QStringLiteral("udev/"), QStringLiteral("sysfs/")}) { - if (key.startsWith(prefix)) { key.remove(0, prefix.size()); break; } - } - for (const QString &prefix : {QStringLiteral("ID_FS_"), QStringLiteral("ID_PART_"), - QStringLiteral("ID_ATA"), QStringLiteral("ID_NVME"), - QStringLiteral("ID_SCSI"), QStringLiteral("ID_DRIVE_"), QStringLiteral("ID_CDROM"), - QStringLiteral("DM_"), QStringLiteral("MD_"), - QStringLiteral("queue/"), QStringLiteral("dm/"), QStringLiteral("md/")}) - if (key.startsWith(prefix)) return false; - const QStringList keys {"ID_MODEL", "ID_MODEL_ENC", "ID_VENDOR", "ID_VENDOR_ENC", - "ID_SERIAL", "ID_SERIAL_SHORT", "ID_REVISION", "ID_BUS", "ID_WWN", "ID_WWN_WITH_EXTENSION", - "DEVNAME", "DISKSEQ", "size", "dev", "partition", "start", "ro", "removable", - "model", "vendor", "serial", "rev", "firmware_rev", "wwid"}; - return !keys.contains(key); -} diff --git a/src/devices/propertytext.h b/src/devices/propertytext.h deleted file mode 100644 index a5d0470..0000000 --- a/src/devices/propertytext.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once -#include "deviceproperties.h" - -// Pure text presentation shared by Properties and deep search; no widgets. -struct PropertyEntry { - QString id; - QString label; - QString value; - QString source; - bool advanced = false; - int tab = 2; - ReadState state = ReadState::Available; - int error = 0; -}; -// Empty suffix means this entry contains an available value. -QString detailsPropertyStatus(const PropertyEntry &entry); -QString storageFieldValue(const StorageField &field); -// UI boundary: storage entries remain searchable, but never enter Details or its copy output. -bool isDetailsProperty(const PropertyEntry &entry, const Device &device); -QString propertyReadValue(const Attribute &attribute); -QVector propertyEntries(const DeviceProperties &snapshot, bool removed = false); diff --git a/src/devices/resourceformat.cpp b/src/devices/resourceformat.cpp deleted file mode 100644 index f7e97d2..0000000 --- a/src/devices/resourceformat.cpp +++ /dev/null @@ -1,93 +0,0 @@ -#include "resourceformat.h" -#include "propertytext.h" -#include -#include - -namespace { -QString tr(const char *text) { return QCoreApplication::translate("PropertiesDialog", text); } -QString resourceKey(const DeviceResource &r) -{ - return QStringLiteral("%1:%2:%3:%4:%5:%6") - .arg(r.source).arg(static_cast(r.type)).arg(r.index).arg(r.start).arg(r.end).arg(r.mode); -} -} -QVector resourceRows(const DeviceResources &data) -{ - QVector result; - for (const DeviceResource &resource : data.items) { - QString type; - switch (resource.type) { - case DeviceResource::Type::Memory: type = tr("Memory range"); break; - case DeviceResource::Type::Io: type = tr("I/O range"); break; - case DeviceResource::Type::Irq: type = tr("IRQ"); break; - case DeviceResource::Type::Dma: type = tr("DMA channel"); break; - case DeviceResource::Type::Bus: type = tr("Bus range"); break; - } - QString setting; - switch (resource.allocation) { - case DeviceResource::Allocation::Assigned: - if (resource.type == DeviceResource::Type::Irq || resource.type == DeviceResource::Type::Dma) - setting = QString::number(resource.start); - else { - const int width = resource.type == DeviceResource::Type::Memory ? 16 : 4; - setting = QStringLiteral("%1 – %2").arg(resource.start, width, 16, QLatin1Char('0')) - .arg(resource.end, width, 16, QLatin1Char('0')).toUpper(); - } - break; - case DeviceResource::Allocation::Unassigned: setting = tr("Unassigned"); break; - case DeviceResource::Allocation::Disabled: setting = tr("Disabled"); break; - case DeviceResource::Allocation::Unavailable: setting = tr("Address unavailable (zeroed or masked)"); break; - } - QStringList details; - if (resource.pci) { - if (resource.index < 6) details.append(tr("BAR %1").arg(resource.index)); - else if (resource.index == 6) details.append(tr("Expansion ROM")); - else details.append(tr("PCI resource %1").arg(resource.index)); - } - // Decode only stable flags; never interpret overlap or BUSY as a conflict. - if (resource.flags & 0x00002000) details.append(tr("Prefetchable")); - if (resource.flags & 0x00004000) details.append(tr("Read-only")); - if (resource.flags & 0x00100000) details.append(tr("64-bit memory")); - if (resource.flags & 0x00200000) details.append(tr("Bridge window")); - if (resource.pci) - details.append(tr("Flags: %1").arg(QStringLiteral("0x%1").arg(resource.flags, 0, 16))); - if (resource.mode == "msi") details.append(QStringLiteral("MSI")); - else if (resource.mode == "msix") details.append(QStringLiteral("MSI-X")); - else if (resource.mode == "reported") details.append(tr("Reported irq attribute; active mode not established")); - if (data.pnpState == "disabled") details.append(tr("PnP device disabled")); - result.append({resourceKey(resource), type, setting, details.join("; "), resource.source, - QString::number(static_cast(resource.type))}); - } - for (const ResourceIssue &problem : data.issues) - result.append({QStringLiteral("issue:") + problem.source, tr("Resource metadata"), - propertyReadValue(problem.error), tr("Read or parse failed"), problem.source, QStringLiteral("issues")}); - // Preserve multiple identical reported rows without colliding projection keys. - QHash duplicates; - for (ResourceRow &row : result) { - const QString key = row.key; - row.key += ':' + QString::number(duplicates[key]++); - } - return result; -} -bool sameDeviceResources(const std::shared_ptr &a, - const std::shared_ptr &b) -{ - if (a == b) return true; - static const DeviceResources empty; - const DeviceResources &left = a ? *a : empty; - const DeviceResources &right = b ? *b : empty; - if (left.pnpState != right.pnpState || left.items.size() != right.items.size() - || left.issues.size() != right.issues.size()) return false; - for (qsizetype i = 0; i < left.items.size(); ++i) { - const auto &x = left.items.at(i), &y = right.items.at(i); - if (x.type != y.type || x.allocation != y.allocation || x.start != y.start || x.end != y.end - || x.flags != y.flags || x.index != y.index || x.pci != y.pci || x.mode != y.mode - || x.source != y.source) return false; - } - for (qsizetype i = 0; i < left.issues.size(); ++i) { - const auto &x = left.issues.at(i), &y = right.issues.at(i); - if (x.source != y.source || x.error.state != y.error.state || x.error.value != y.error.value - || x.error.error != y.error.error) return false; - } - return true; -} diff --git a/src/devices/resourceformat.h b/src/devices/resourceformat.h deleted file mode 100644 index 90f8022..0000000 --- a/src/devices/resourceformat.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include "deviceresources.h" - -struct ResourceRow { - QString key; - QString type; - QString setting; - QString details; - QString source; - QString group; // Untranslated resource type ID, or "issues". -}; -// Shared Qt Core-only presentation for the Resources tab and tree projections. -QVector resourceRows(const DeviceResources &resources); -bool sameDeviceResources(const std::shared_ptr &a, - const std::shared_ptr &b); diff --git a/src/devices/secureboot.cpp b/src/devices/secureboot.cpp deleted file mode 100644 index 09c785f..0000000 --- a/src/devices/secureboot.cpp +++ /dev/null @@ -1,143 +0,0 @@ -#include "secureboot.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -constexpr int Limit = 1024 * 1024; -Attribute failure(int error) -{ - return {error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENOENT ? ReadState::Unavailable : ReadState::Error, {}, error}; -} -Attribute readBinary(const QString &path, QByteArray &bytes, bool efivar) -{ - bytes.clear(); - if (QThread::currentThread()->isInterruptionRequested()) return failure(ECANCELED); - const int fd = open(path.toUtf8().constData(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW); - if (fd < 0) return failure(errno); - int error = 0; - while (bytes.size() <= Limit) { - char buffer[4096]; - const ssize_t n = read(fd, buffer, qMin(sizeof(buffer), Limit + 1 - bytes.size())); - if (n < 0) { if (errno == EINTR) continue; error = errno; break; } - if (!n) break; - bytes.append(buffer, static_cast(n)); - if (QThread::currentThread()->isInterruptionRequested()) { error = ECANCELED; break; } - } - close(fd); - if (!error && bytes.size() > Limit) error = EOVERFLOW; - if (!error && efivar && bytes.size() < 4) error = EINVAL; - if (error) { bytes.clear(); return failure(error); } - if (efivar) bytes.remove(0, 4); - return {ReadState::Available, {}, 0}; -} -QString certificateDisplayName(const QSslCertificate &certificate, bool issuer) -{ - QStringList parts; - // Organization/unit identify distro certificates better than a generic CN. - for (auto attribute : {QSslCertificate::OrganizationalUnitName, QSslCertificate::Organization, - QSslCertificate::CommonName}) { - const QStringList values = issuer ? certificate.issuerInfo(attribute) : certificate.subjectInfo(attribute); - for (const QString &value : values) { - const QString text = value.simplified(); - if (!text.isEmpty() && !parts.contains(text)) parts.append(text); - } - } - return parts.join(" — "); -} -QString certificateName(const QSslCertificate &certificate, bool issuer) -{ - QStringList fields; - const auto attributes = issuer ? certificate.issuerInfoAttributes() : certificate.subjectInfoAttributes(); - for (const QByteArray &attribute : attributes) { - const QStringList values = issuer ? certificate.issuerInfo(attribute) : certificate.subjectInfo(attribute); - for (const QString &value : values) - fields.append(QString::fromLatin1(attribute) + '=' + value.simplified()); - } - return fields.join(", "); -} -} -Attribute parseSecureBoot(const QByteArray &payload) -{ - if (payload.size() != 1 || (payload[0] != 0 && payload[0] != 1)) return failure(EINVAL); - return {ReadState::Available, payload[0] == 1 ? QStringLiteral("enabled") : QStringLiteral("disabled"), 0}; -} -MokCertificates parseMokCertificates(const QByteArray &payload) -{ - MokCertificates result; - const auto invalid = [](int error) { MokCertificates r; r.status = failure(error); return r; }; - if (payload.size() > Limit) return invalid(EOVERFLOW); - // EFI_CERT_X509_GUID in EFI's little-endian GUID representation. - const QByteArray x509 = QByteArray::fromHex("a159c0a5e494a74a87b5ab155c2bf072"); - qsizetype offset = 0; - int lists = 0, signatures = 0; - while (offset < payload.size()) { - if (QThread::currentThread()->isInterruptionRequested()) return invalid(ECANCELED); - if (++lists > 256) return invalid(EOVERFLOW); - if (payload.size() - offset < 28) return invalid(EINVAL); - const auto *header = reinterpret_cast(payload.constData() + offset); - const quint32 size = qFromLittleEndian(header + 16); - const quint32 headerSize = qFromLittleEndian(header + 20); - const quint32 signatureSize = qFromLittleEndian(header + 24); - if (size < 28 || size > payload.size() - offset || headerSize > size - 28 || signatureSize <= 16) - return invalid(EINVAL); - const quint32 dataSize = size - 28 - headerSize; - if (dataSize % signatureSize) return invalid(EINVAL); - for (qsizetype position = offset + 28 + headerSize; position < offset + size; position += signatureSize) { - if (++signatures > 1024) return invalid(EOVERFLOW); - if (payload.mid(offset, 16) != x509) { ++result.otherSignatures; continue; } - if (result.certificates.size() >= 256 || signatureSize > 65536) return invalid(EOVERFLOW); - if (QSslSocket::availableBackends().isEmpty()) { - MokCertificates unavailable; - unavailable.status = {ReadState::Unsupported, {}, 0}; - return unavailable; - } - const QByteArray der = payload.mid(position + 16, signatureSize - 16); // Skip signature-owner GUID. - const auto certificates = QSslCertificate::fromData(der, QSsl::Der); - if (certificates.size() != 1 || certificates.first().isNull() || certificates.first().toDer() != der) - return invalid(EINVAL); - const QSslCertificate &certificate = certificates.first(); - result.certificates.append({certificateName(certificate, false), certificateName(certificate, true), - certificate.expiryDate().toUTC().toString(Qt::ISODate), - certificateDisplayName(certificate, false), certificateDisplayName(certificate, true)}); - } - offset += size; - } - result.status = {ReadState::Available, {}, 0}; - return result; -} -SecureBootInformation collectSecureBootInformation() -{ - SecureBootInformation result; - QByteArray bytes; - const QString base = "/sys/firmware/efi/efivars/"; - result.stateSource = base + "SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c"; - const Attribute state = readBinary(result.stateSource, bytes, true); - result.state = state.state == ReadState::Available ? parseSecureBoot(bytes) : state; - // The kernel's MOK configuration-table copy contains the complete runtime list. - result.mokSource = "/sys/firmware/efi/mok-variables/MokListRT"; - Attribute mok = readBinary(result.mokSource, bytes, false); - if (mok.state != ReadState::Available && (mok.error == ENOENT || mok.state == ReadState::PermissionDenied)) { - const Attribute table = mok; - result.mokSource = base + "MokListRT-605dab50-e046-4300-abb6-3dd810dd8b23"; - mok = readBinary(result.mokSource, bytes, true); - if (mok.error == ENOENT && table.state == ReadState::PermissionDenied) { - mok = table; - result.mokSource = "/sys/firmware/efi/mok-variables/MokListRT"; - } - // A split runtime list cannot be presented as a complete certificate list. - QByteArray extra; - const Attribute split = readBinary(base + "MokListRT1-605dab50-e046-4300-abb6-3dd810dd8b23", extra, true); - if (mok.state == ReadState::Available && (split.state == ReadState::Available || split.error != ENOENT)) - mok = failure(EOVERFLOW); - } - result.mok = mok.state == ReadState::Available ? parseMokCertificates(bytes) : MokCertificates {mok, {}, 0}; - return result; -} diff --git a/src/devices/secureboot.h b/src/devices/secureboot.h deleted file mode 100644 index 2a0c95b..0000000 --- a/src/devices/secureboot.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once -#include "device.h" -#include -#include - -struct MokCertificate { - QString subject, issuer, expires; - QString subjectDisplay, issuerDisplay; -}; -struct MokCertificates { - Attribute status; - QVector certificates; - int otherSignatures = 0; -}; -// Payload parsers: efivarfs's four-byte attributes must already be removed. -Attribute parseSecureBoot(const QByteArray &payload); -MokCertificates parseMokCertificates(const QByteArray &payload); -struct SecureBootInformation { - Attribute state; - MokCertificates mok; - QString stateSource, mokSource; -}; -SecureBootInformation collectSecureBootInformation(); diff --git a/src/devices/storagepresentation.cpp b/src/devices/storagepresentation.cpp deleted file mode 100644 index bd4560e..0000000 --- a/src/devices/storagepresentation.cpp +++ /dev/null @@ -1,350 +0,0 @@ -#include "storagepresentation.h" -#include "propertytext.h" -#include -#include -#include -#include -#include -#include -#include - -namespace { -QString tr(const char *text) { return QCoreApplication::translate("Storage", text); } -bool healthField(const StorageField &field) -{ - return field.id == "health" || field.id.contains(".Drive.Ata/") || field.id.contains(".NVMe.Controller/"); -} -bool driveEntity(const StorageEntity &entity) -{ - for (const StorageField &field : entity.fields) - if (field.id.startsWith("org.freedesktop.UDisks2.Drive/") || healthField(field)) return true; - return false; -} -QString value(const StorageEntity &entity, const QStringList &keys) -{ - for (const QString &key : keys) - for (const StorageField &field : entity.fields) - if (field.id == key && field.value.state == ReadState::Available && !field.value.value.isEmpty()) - return storageFieldValue(field); - return tr("Unavailable"); -} -QString name(const StorageEntity &entity) -{ - for (const QString &key : {QStringLiteral("DEVNAME"), QStringLiteral("dm/name"), - QStringLiteral("org.freedesktop.UDisks2.LogicalVolume/Name"), - QStringLiteral("org.freedesktop.UDisks2.VolumeGroup/Name"), - QStringLiteral("org.freedesktop.UDisks2.MDRaid/Name"), - QStringLiteral("org.freedesktop.UDisks2.Drive/Model")}) { - for (const StorageField &field : entity.fields) - if (field.id == key && field.value.state == ReadState::Available && !field.value.value.isEmpty()) - return field.value.value; - } - // Kernel names/object basenames are display fallbacks, never identity keys. - return QFileInfo(entity.id).fileName(); -} -QString kind(const StorageEntity &entity) -{ - for (const StorageField &field : entity.fields) { - if (field.id == "partition" || field.id.contains(".Partition/")) return tr("Partition"); - if (field.id == "dm/name") return tr("Device mapper"); - if (field.id == "md/level" || field.id.contains(".MDRaid/")) return tr("RAID"); - if (field.id.contains(".LogicalVolume/")) return tr("Logical volume"); - if (field.id.contains(".VolumeGroup/")) return tr("Volume group"); - } - return tr("Block device"); -} -StorageDisplayField display(const StorageField &field) -{ - return {tr(field.label), storageFieldValue(field), field.source}; -} -// Aliases describe the same fact on this exact block entity. Preserve all sources; -// disagreeing available values are visible rather than silently overwritten. -void fact(QVector &output, const StorageEntity &entity, - const char *caption, const QStringList &keys) -{ - QStringList values, sources; - const StorageField *fallback = nullptr; - for (const QString &key : keys) for (const StorageField &field : entity.fields) { - if (field.id != key) continue; - if (!fallback) fallback = &field; - if (!sources.contains(field.source)) sources.append(field.source); - if (field.value.state == ReadState::Available && !field.value.value.isEmpty()) { - const QString text = storageFieldValue(field); - if (!values.contains(text)) values.append(text); - } - } - if (!fallback) return; - QString text = values.isEmpty() ? storageFieldValue(*fallback) : values.join("\n"); - if (values.size() > 1) text = tr("Conflicting metadata:") + '\n' + text; - output.append({tr(caption), text, sources.join('\n')}); -} -StorageDisplayAtaAttribute ataAttribute(const AtaSmartAttribute &attribute, const QString &drive) -{ - const auto normalized = [](qint32 value) { return value < 0 ? tr("Unavailable") : QLocale().toString(value); }; - QString interpreted; - if (attribute.unit == 0 || (attribute.unit == 4 && attribute.interpreted == 0)) interpreted = tr("Unavailable"); - else if (attribute.interpreted < 0) interpreted = tr("Invalid health metadata"); - else switch (attribute.unit) { - case 1: interpreted = QLocale().toString(attribute.interpreted); break; - case 2: interpreted = tr("%1 milliseconds").arg(QLocale().toString(attribute.interpreted)); break; - case 3: interpreted = tr("%1 sectors").arg(QLocale().toString(attribute.interpreted)); break; - case 4: interpreted = tr("%1 °C").arg(QLocale().toString(attribute.interpreted / 1000.0 - 273.15, 'f', 1)); break; - default: interpreted = tr("Unavailable (unknown unit %1)").arg(attribute.unit); break; - } - return {QString::number(attribute.id), attribute.name.isEmpty() ? tr("Unnamed attribute") : attribute.name, - normalized(attribute.current), normalized(attribute.worst), normalized(attribute.threshold), interpreted, - QStringLiteral("0x%1").arg(attribute.flags, 4, 16, QLatin1Char('0')), - drive + " : org.freedesktop.UDisks2.Drive.Ata.SmartGetAttributes : ID " + QString::number(attribute.id)}; -} -void healthGroups(StorageDisplayRow &row, const StorageEntity &entity) -{ - QVector summary, usage, errors, temperatures; - static const QMap captions { - {"health", QT_TRANSLATE_NOOP("Storage", "Health reporting")}, - {"SmartUpdated", QT_TRANSLATE_NOOP("Storage", "Last health update")}, - {"SmartSupported", QT_TRANSLATE_NOOP("Storage", "ATA SMART supported")}, - {"SmartEnabled", QT_TRANSLATE_NOOP("Storage", "ATA SMART enabled")}, - {"SmartFailing", QT_TRANSLATE_NOOP("Storage", "Drive failure prediction")}, - {"SmartCriticalWarning", QT_TRANSLATE_NOOP("Storage", "Critical warnings")}, - {"SmartPowerOnSeconds", QT_TRANSLATE_NOOP("Storage", "Power-on time")}, - {"SmartPowerOnHours", QT_TRANSLATE_NOOP("Storage", "Power-on time")}, - {"SmartTemperature", QT_TRANSLATE_NOOP("Storage", "Temperature")}, - {"SmartNumBadSectors", QT_TRANSLATE_NOOP("Storage", "Bad sectors")}, - {"SmartNumAttributesFailing", QT_TRANSLATE_NOOP("Storage", "Failing attributes")}, - {"SmartNumAttributesFailedInThePast", QT_TRANSLATE_NOOP("Storage", "Attributes failed in the past")}, - {"avail_spare", QT_TRANSLATE_NOOP("Storage", "Available spare")}, - {"spare_thresh", QT_TRANSLATE_NOOP("Storage", "Spare threshold")}, - {"percent_used", QT_TRANSLATE_NOOP("Storage", "Estimated endurance used")}, - {"total_data_read", QT_TRANSLATE_NOOP("Storage", "Total data read")}, - {"total_data_written", QT_TRANSLATE_NOOP("Storage", "Total data written")}, - {"ctrl_busy_time", QT_TRANSLATE_NOOP("Storage", "Controller busy time")}, - {"power_cycles", QT_TRANSLATE_NOOP("Storage", "Power cycles")}, - {"unsafe_shutdowns", QT_TRANSLATE_NOOP("Storage", "Unsafe shutdowns")}, - {"media_errors", QT_TRANSLATE_NOOP("Storage", "Media and data integrity errors")}, - {"num_err_log_entries", QT_TRANSLATE_NOOP("Storage", "Error information log entries")}, - {"wctemp", QT_TRANSLATE_NOOP("Storage", "Warning temperature threshold")}, - {"cctemp", QT_TRANSLATE_NOOP("Storage", "Critical temperature threshold")}, - {"warning_temp_time", QT_TRANSLATE_NOOP("Storage", "Time above warning temperature")}, - {"critical_temp_time", QT_TRANSLATE_NOOP("Storage", "Time above critical temperature")} - }; - bool nvme = false, sensorErrorShown = false; - for (const StorageField &field : entity.fields) { - if (!healthField(field)) continue; - nvme = nvme || field.id.contains(".NVMe.Controller/"); - const QString key = field.id.section('/', -1); - StorageDisplayField shown = display(field); - if (const char *caption = captions.value(key, nullptr)) shown.label = tr(caption); - if (key == "SmartGetAttributes") shown.label = field.id.contains(".Drive.Ata/") - ? tr("ATA SMART attributes") : tr("NVMe health details"); - if (field.id.contains("/temp_sensors/")) { - // Zero/unreported slots are absent evidence, not failed reads. - if (field.value.state == ReadState::Unavailable && !field.value.error) continue; - if (field.value.state != ReadState::Available) { - if (sensorErrorShown) continue; - sensorErrorShown = true; - shown.label = tr("Temperature sensors"); - } else shown.label = tr("Temperature sensor %1").arg(key); - temperatures.append(shown); - } else if (field.format == StorageField::Format::Kelvin || key.endsWith("temp_time")) - temperatures.append(shown); - else if (key == "SmartPowerOnSeconds" || key == "SmartPowerOnHours" || key == "total_data_read" - || key == "total_data_written" || key == "ctrl_busy_time" || key == "power_cycles" || key == "unsafe_shutdowns") - usage.append(shown); - else if (key == "SmartNumBadSectors" || key == "SmartNumAttributesFailing" - || key == "SmartNumAttributesFailedInThePast" || key == "media_errors" || key == "num_err_log_entries") - errors.append(shown); - else if (key == "SmartUpdated") summary.prepend(shown); - else summary.append(shown); - } - if (!summary.isEmpty()) row.healthGroups.append({nvme ? tr("Health and endurance") : tr("Health"), summary}); - if (!usage.isEmpty()) row.healthGroups.append({tr("Usage"), usage}); - if (!errors.isEmpty()) row.healthGroups.append({tr("Errors"), errors}); - if (!temperatures.isEmpty()) row.healthGroups.append({tr("Temperatures"), temperatures}); - QVector attributes = entity.ataAttributes; - std::sort(attributes.begin(), attributes.end(), [](const AtaSmartAttribute &a, const AtaSmartAttribute &b) { - return a.id < b.id; - }); - for (const AtaSmartAttribute &attribute : attributes) row.ataAttributes.append(ataAttribute(attribute, entity.id)); -} -QString relation(const StorageLink &link) -{ - if (link.source.endsWith(".Table")) return tr("Partition of"); - if (link.source.endsWith(".CryptoBackingDevice")) return tr("Backed by"); - if (link.source.endsWith(".Drive")) return tr("Drive"); - if (link.source.endsWith(".MDRaidMember")) return tr("RAID member of"); - if (link.source.endsWith(".MDRaid")) return tr("RAID device"); - if (link.source.endsWith(".LogicalVolume")) return tr("Logical volume"); - if (link.source.endsWith(".VolumeGroup")) return tr("Volume group"); - return tr(link.label); -} -void overview(QVector &fields, const StorageEntity &entity) -{ - fields.append({tr("Device"), name(entity), entity.id}); - fact(fields, entity, "Model", {"model", "hardware/model", "ID_MODEL", "org.freedesktop.UDisks2.Drive/Model"}); - fact(fields, entity, "Manufacturer", {"hardware/vendor", "ID_VENDOR", "org.freedesktop.UDisks2.Drive/Vendor"}); - fact(fields, entity, "Serial number", {"serial", "hardware/serial", "ID_SERIAL_SHORT", "org.freedesktop.UDisks2.Drive/Serial"}); - fact(fields, entity, "Firmware revision", {"firmware_rev", "hardware/firmware_rev", "hardware/rev", "ID_REVISION", "org.freedesktop.UDisks2.Drive/Revision"}); - fact(fields, entity, "Transport", {"ID_BUS", "org.freedesktop.UDisks2.Drive/ConnectionBus"}); - fact(fields, entity, "Native transport", {"native-transport"}); - // A Drive.Size is a physical-drive fact, not a namespace/partition capacity alias. - fact(fields, entity, "Block capacity", {"capacity"}); - fact(fields, entity, "Drive capacity", {"org.freedesktop.UDisks2.Drive/Size"}); - fact(fields, entity, "Logical sector size", {"queue/logical_block_size"}); - fact(fields, entity, "Physical sector size", {"queue/physical_block_size"}); - fact(fields, entity, "Kernel removable media", {"removable"}); - fact(fields, entity, "Block read-only", {"ro"}); - for (const StorageField &field : entity.fields) - if (field.id.endsWith("/Removable") || field.id.endsWith("/MediaRemovable") || field.id.endsWith("/MediaAvailable")) - fields.append(display(field)); -} -} - -StoragePresentation storagePresentation(const StorageProperties &snapshot, const QString &selectedPath) -{ - StoragePresentation result; - QMap canonical; - QMap entities; - for (const StorageEntity &entity : snapshot.entities) entities.insert(entity.id, entity); - for (const StorageLink &link : snapshot.links) { - if (link.source != "UDisks2 Block.DeviceNumber / sysfs dev") continue; - if (!entities.contains(link.from) || !entities.contains(link.to)) continue; - canonical.insert(link.to, link.from); - entities[link.from].fields += entities.value(link.to).fields; - } - for (auto it = canonical.cbegin(); it != canonical.cend(); ++it) entities.remove(it.key()); - const auto resolve = [&canonical](const QString &id) { return canonical.value(id, id); }; - - QSet selectedDrives; - for (const StorageLink &link : snapshot.links) - if (resolve(link.from) == selectedPath && link.source.endsWith(".Drive")) selectedDrives.insert(resolve(link.to)); - // Controller dialogs cover their namespaces; this is an explicit descendant relation. - if (selectedPath.startsWith("/sys/")) for (const StorageLink &link : snapshot.links) - if (resolve(link.from).startsWith(selectedPath + '/') && link.source.endsWith(".Drive")) - selectedDrives.insert(resolve(link.to)); - StorageEntity selected = entities.value(selectedPath); - // Identity aliases from a single explicitly associated drive share the overview. - // Drive capacity retains its own caption: it is never a block capacity alias. - if (selectedDrives.size() == 1) { - const QString drive = *selectedDrives.cbegin(); - if (entities.contains(drive)) { - selected.fields += entities.value(drive).fields; - result.overview.append({tr("Associated drive"), name(entities.value(drive)), drive}); - } - } - if (entities.contains(selectedPath)) overview(result.overview, selected); - else result.overview.append({tr("Storage snapshot"), tr("Unavailable"), selectedPath}); - for (auto it = entities.cbegin(); it != entities.cend(); ++it) { - const StorageEntity &entity = it.value(); - StorageDisplayRow row; - row.id = entity.id; - row.name = name(entity); - row.source = entity.id; - for (auto mapping = canonical.cbegin(); mapping != canonical.cend(); ++mapping) - if (mapping.value() == entity.id) row.source += '\n' + mapping.key(); - if (driveEntity(entity)) { - QStringList nodes; - for (const StorageLink &link : snapshot.links) { - if (resolve(link.to) == entity.id && link.source.endsWith(".Drive") && entities.contains(resolve(link.from))) { - const StorageEntity block = entities.value(resolve(link.from)); - bool partition = false; - for (const StorageField &field : block.fields) - if (field.id == "partition" || field.id.contains(".Partition/")) partition = true; - if (!partition) { - const QString node = name(block); - if (!nodes.contains(node)) nodes.append(node); - } - } - } - if (!nodes.isEmpty()) row.name += " — " + nodes.join(", "); - row.kind = tr("Cached drive health"); - healthGroups(row, entity); - if (row.healthGroups.isEmpty() && row.ataAttributes.isEmpty()) - row.fields.append({tr("Cached health"), tr("Unavailable"), entity.id}); - result.health.append(row); - if (selectedDrives.size() == 1 && selectedDrives.contains(entity.id)) - result.preferredHealthDrive = entity.id; - if (selectedDrives.size() > 1 && selectedDrives.contains(entity.id)) { - result.overview.append({tr("Associated drive"), row.name, entity.id}); - overview(result.overview, entity); - } - continue; - } - // The selected NVMe controller is an overview, not another volume. - bool block = false; - for (const StorageField &field : entity.fields) if (field.id == "dev") block = true; - if (entity.id == selectedPath && entity.id.startsWith("/sys/") && !block) { - bool controller = false; - for (const StorageField &field : entity.fields) if (field.id == "firmware_rev") controller = true; - if (controller) continue; - } - row.kind = kind(entity); - row.capacity = value(entity, {"capacity", "org.freedesktop.UDisks2.Partition/Size"}); - row.filesystem = value(entity, {"ID_FS_TYPE", "org.freedesktop.UDisks2.Block/IdType"}); - row.mounts = value(entity, {"mounts"}); - const QString label = value(entity, {"ID_FS_LABEL", "org.freedesktop.UDisks2.Block/IdLabel"}); - if (label != tr("Unavailable")) row.name += " — " + label; - const QVector> facts { - {"Block capacity", {"capacity", "org.freedesktop.UDisks2.Partition/Size"}}, - {"Filesystem / content type", {"ID_FS_TYPE", "org.freedesktop.UDisks2.Block/IdType"}}, - {"Content usage", {"ID_FS_USAGE", "org.freedesktop.UDisks2.Block/IdUsage"}}, - {"Filesystem / content label", {"ID_FS_LABEL", "org.freedesktop.UDisks2.Block/IdLabel"}}, - {"Filesystem / content UUID", {"ID_FS_UUID", "org.freedesktop.UDisks2.Block/IdUUID"}}, - {"Partition number", {"partition", "org.freedesktop.UDisks2.Partition/Number"}}, - {"Partition table type", {"ID_PART_TABLE_TYPE", "org.freedesktop.UDisks2.PartitionTable/Type"}} - }; - QSet grouped; - for (const auto &group : facts) { - fact(row.fields, entity, group.first, group.second); - for (const QString &key : group.second) grouped.insert(key); - } - for (const StorageField &field : entity.fields) - if (!healthField(field) && !grouped.contains(field.id)) row.fields.append(display(field)); - for (const StorageLink &link : snapshot.links) { - if (resolve(link.from) != entity.id || resolve(link.to) == entity.id) continue; - const QString target = resolve(link.to); - row.fields.append({relation(link), entities.contains(target) ? name(entities.value(target)) : QFileInfo(target).fileName(), - link.source + '\n' + target}); - } - result.volumes.append(row); - } - if (result.health.isEmpty()) { - StorageDisplayRow row; - row.id = selectedPath; - row.name = entities.contains(selectedPath) ? name(entities.value(selectedPath)) : QFileInfo(selectedPath).fileName(); - row.fields.append({tr("Cached health"), tr("Unavailable"), tr("No supported cached drive health in this snapshot")}); - result.health.append(row); - } - QStringList notes; - for (const StorageField ¬e : snapshot.notes) - notes.append(tr(note.label) + ": " + storageFieldValue(note) + '\n' + note.source); - result.notes = notes.join("\n\n"); - return result; -} -QString storageFieldsText(const QVector &fields) -{ - QStringList text; - for (const StorageDisplayField &field : fields) - text.append(field.label + ": " + field.value + '\n' + QCoreApplication::translate("Storage", "Source: %1").arg(field.source)); - return text.join("\n\n"); -} -QString storageRowsText(const QVector &rows) -{ - QStringList text; - for (const StorageDisplayRow &row : rows) { - QStringList sections {row.name}; - if (!row.healthGroups.isEmpty() || !row.ataAttributes.isEmpty()) sections.append(tr("Cached drive health")); - if (!row.fields.isEmpty()) sections.append(storageFieldsText(row.fields)); - for (const StorageDisplayGroup &group : row.healthGroups) - sections.append(group.title + '\n' + storageFieldsText(group.fields)); - if (!row.ataAttributes.isEmpty()) { - QStringList attributes {tr("ATA SMART attributes")}; - for (const StorageDisplayAtaAttribute &attribute : row.ataAttributes) - attributes.append(tr("%1 (ID %2)\nNormalized value: %3\nWorst normalized value: %4\nThreshold: %5\nInterpreted value: %6\nFlags: %7\nSource: %8") - .arg(attribute.name, attribute.id, attribute.current, attribute.worst, attribute.threshold, - attribute.interpreted, attribute.flags, attribute.source)); - sections.append(attributes.join("\n\n")); - } - text.append(sections.join("\n\n")); - } - return text.join("\n\n"); -} diff --git a/src/devices/storagepresentation.h b/src/devices/storagepresentation.h deleted file mode 100644 index dc3e763..0000000 --- a/src/devices/storagepresentation.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once -#include "storageproperties.h" - -// Owned, widget-free presentation of one bounded collector snapshot. -struct StorageDisplayField { QString label, value, source; }; -struct StorageDisplayGroup { QString title; QVector fields; }; -struct StorageDisplayAtaAttribute { - QString id, name, current, worst, threshold, interpreted, flags, source; -}; -struct StorageDisplayRow { - QString id, name, kind, capacity, filesystem, mounts; - QString source; - QVector fields; - QVector healthGroups; - QVector ataAttributes; -}; -struct StoragePresentation { - QVector overview; - QVector volumes; - QVector health; - QString preferredHealthDrive; // Unique explicit association with the selected device, if any. - QString notes; -}; -StoragePresentation storagePresentation(const StorageProperties &snapshot, const QString &selectedPath); -QString storageFieldsText(const QVector &fields); -QString storageRowsText(const QVector &rows); diff --git a/src/devices/storageproperties.cpp b/src/devices/storageproperties.cpp deleted file mode 100644 index ad93d11..0000000 --- a/src/devices/storageproperties.cpp +++ /dev/null @@ -1,651 +0,0 @@ -#include "storageproperties.h" -#include "directoryscan.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#ifdef QLDM_HAVE_DBUS -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -using StorageInterfaces = QMap; -using StorageObjects = QMap; -Q_DECLARE_METATYPE(StorageInterfaces) -Q_DECLARE_METATYPE(StorageObjects) -#endif - -namespace { -constexpr int recordLimit = 4096; -constexpr int relatedLimit = 256; -constexpr int textLimit = 4096; -#ifdef QLDM_HAVE_DBUS -constexpr int healthQueryBudgetMs = 2500; -#endif -using Format = StorageField::Format; -Attribute ok(const QString &text) { return {ReadState::Available, text, 0}; } -Attribute failure(int error) -{ - return {error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENOENT || error == ENODATA ? ReadState::Unavailable - : error == ENODEV ? ReadState::Removed : ReadState::Error, {}, error}; -} -Attribute textFile(const QString &path, int limit = textLimit) -{ - QFile file(path); - errno = 0; - if (!file.open(QIODevice::ReadOnly)) return failure(errno ? errno : EIO); - const QByteArray bytes = file.read(limit + 1); - if (file.error() != QFileDevice::NoError) return failure(EIO); - if (bytes.size() > limit) return failure(EOVERFLOW); - return ok(QString::fromUtf8(bytes).trimmed()); -} -void field(QVector &fields, const QString &id, const char *label, - const Attribute &value, const QString &source, Format format = Format::Text) -{ - fields.append({id, label, value, source, format}); -} -void native(StorageEntity &entity, const QString &key, const char *label, Format format = Format::Text) -{ - field(entity.fields, key, label, textFile(entity.id + '/' + key), entity.id + '/' + key, format); -} -void metadata(StorageEntity &entity, udev_device *raw, const char *key, const char *label) -{ - const char *value = raw ? udev_device_get_property_value(raw, key) : nullptr; - const Attribute data = !value ? Attribute() : strnlen(value, textLimit + 1) > textLimit - ? failure(EOVERFLOW) : ok(QString::fromUtf8(value)); - field(entity.fields, QLatin1String(key), label, data, - QStringLiteral("udev: %1 on %2").arg(QLatin1String(key), entity.id)); -} -QString unescapeMount(QString value) -{ - // Decode once: replacing \\134 first would double-decode literal escapes. - value.replace("\\040", " ").replace("\\011", "\t").replace("\\012", "\n").replace("\\134", "\\"); - return value; -} -void link(StorageProperties &result, const QString &from, const QString &to, - const char *label, const QString &source) -{ - if (from.isEmpty() || to.isEmpty() || from == to || to == "/") return; - for (const StorageLink &existing : result.links) - if (existing.from == from && existing.to == to && existing.label == label) return; - result.links.append({from, to, label, source}); -} -#ifdef QLDM_HAVE_DBUS -QString objectPath(const QVariant &value) -{ - return value.value().path(); -} -StorageObjects serviceSnapshot(StorageProperties &result) -{ - static const int registered = [] { - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - return 0; - }(); - Q_UNUSED(registered); - const auto bus = QDBusConnection::systemBus(); - const QString source = QStringLiteral("org.freedesktop.UDisks2: ObjectManager.GetManagedObjects"); - if (!bus.isConnected()) { - field(result.notes, "service", QT_TRANSLATE_NOOP("Storage", "UDisks2 metadata"), failure(ENOTCONN), source); - return {}; - } - auto message = QDBusMessage::createMethodCall("org.freedesktop.UDisks2", "/org/freedesktop/UDisks2", - "org.freedesktop.DBus.ObjectManager", "GetManagedObjects"); - message.setAutoStartService(false); // Read the existing cache; do not start a storage daemon. - QDBusPendingCallWatcher watcher(bus.asyncCall(message, 2500)); - QEventLoop loop; // Runs only in the existing bounded property worker. - QTimer cancellation; - cancellation.setInterval(50); - QObject::connect(&watcher, &QDBusPendingCallWatcher::finished, &loop, &QEventLoop::quit); - QObject::connect(&cancellation, &QTimer::timeout, &loop, [&loop] { - if (QThread::currentThread()->isInterruptionRequested()) loop.quit(); - }); - cancellation.start(); - if (!watcher.isFinished()) loop.exec(); - if (QThread::currentThread()->isInterruptionRequested()) return {}; - QDBusPendingReply reply = watcher; - if (reply.isError()) { - const QDBusError error = reply.error(); - const ReadState state = error.type() == QDBusError::AccessDenied ? ReadState::PermissionDenied - : error.type() == QDBusError::ServiceUnknown || error.type() == QDBusError::UnknownObject - ? ReadState::Unavailable : ReadState::Error; - field(result.notes, "service", QT_TRANSLATE_NOOP("Storage", "UDisks2 metadata"), - {state, {}, error.type() == QDBusError::NoReply || error.type() == QDBusError::Timeout || error.type() == QDBusError::TimedOut ? ETIMEDOUT : EIO}, source); - return {}; - } - StorageObjects objects = reply.value(); - if (objects.size() > recordLimit) { - field(result.notes, "service", QT_TRANSLATE_NOOP("Storage", "UDisks2 metadata"), failure(EOVERFLOW), source); - return {}; - } - field(result.notes, "service", QT_TRANSLATE_NOOP("Storage", "UDisks2 metadata"), ok("cached"), source); - return objects; -} -void serviceField(StorageEntity &entity, const QVariantMap &properties, const QString &interface, - const char *key, const char *label, Format format = Format::Text, bool zeroUnknown = false) -{ - const QVariant value = properties.value(QLatin1String(key)); - Attribute data; - if (value.isValid()) { - QString text = format == Format::NvmeHealth ? value.toStringList().join('\n') : value.toString(); - if (format == Format::Boolean || format == Format::Removable || format == Format::AtaHealth) text = value.toBool() ? "1" : "0"; - if (text.size() > textLimit) data = failure(EOVERFLOW); - else if (!(format == Format::Text && text.isEmpty()) && (!zeroUnknown || value.toDouble() != 0)) data = ok(text); - } - field(entity.fields, interface + '/' + QLatin1String(key), label, data, - entity.id + " : " + interface + '.' + QLatin1String(key), format); -} -// Shared cache-method wait budget; no service activation or authorization dialog. -QDBusMessage healthReply(const QString &path, const QString &iface, QElapsedTimer &budget, Attribute &state) -{ - const auto fail = [&](const Attribute &error) { state = error; return QDBusMessage(); }; - if (QThread::currentThread()->isInterruptionRequested()) return fail(failure(ECANCELED)); - if (!budget.isValid()) budget.start(); // Begin at the first detailed health query. - const qint64 remaining = healthQueryBudgetMs - budget.elapsed(); - // Unavailable + ETIMEDOUT on HealthQueryStatus means no request was sent. - if (remaining <= 0) return fail({ReadState::Unavailable, {}, ETIMEDOUT}); - auto message = QDBusMessage::createMethodCall("org.freedesktop.UDisks2", path, iface, "SmartGetAttributes"); - message.setAutoStartService(false); - message.setInteractiveAuthorizationAllowed(false); - message << QVariant::fromValue(QVariantMap()); - QDBusPendingCallWatcher watcher(QDBusConnection::systemBus().asyncCall(message, static_cast(remaining))); - QEventLoop loop; - QTimer cancellation; - cancellation.setInterval(50); - QObject::connect(&watcher, &QDBusPendingCallWatcher::finished, &loop, &QEventLoop::quit); - QObject::connect(&cancellation, &QTimer::timeout, &loop, [&] { - if (QThread::currentThread()->isInterruptionRequested() || budget.elapsed() >= healthQueryBudgetMs) loop.quit(); - }); - cancellation.start(); - if (!watcher.isFinished()) loop.exec(); - if (QThread::currentThread()->isInterruptionRequested()) return fail(failure(ECANCELED)); - if (!watcher.isFinished()) return fail(failure(ETIMEDOUT)); - const QDBusMessage reply = watcher.reply(); - if (reply.type() == QDBusMessage::ErrorMessage) { - const QDBusError error(reply); - const ReadState readState = error.type() == QDBusError::AccessDenied ? ReadState::PermissionDenied - : error.type() == QDBusError::UnknownMethod || error.type() == QDBusError::UnknownInterface ? ReadState::Unsupported - : error.type() == QDBusError::ServiceUnknown || error.type() == QDBusError::UnknownObject ? ReadState::Unavailable - : ReadState::Error; - return fail({readState, {}, error.type() == QDBusError::NoReply || error.type() == QDBusError::Timeout - || error.type() == QDBusError::TimedOut ? ETIMEDOUT : EIO}); - } - if (reply.type() != QDBusMessage::ReplyMessage || reply.arguments().size() != 1) return fail(failure(EINVAL)); - state = ok(QString()); - return reply; -} -// Cache-only method: UDisks2 returns its stored log, without SmartUpdate or tests. -void nvmeAttributes(StorageEntity &entity, QElapsedTimer &budget) -{ - const QString iface = QStringLiteral("org.freedesktop.UDisks2.NVMe.Controller"); - const QString source = entity.id + " : " + iface + ".SmartGetAttributes"; - const auto note = [&](const Attribute &state) { - field(entity.fields, iface + "/SmartGetAttributes", QT_TRANSLATE_NOOP("Storage", "Cached NVMe health details"), state, source, Format::HealthQueryStatus); - }; - Attribute state; - const QDBusMessage message = healthReply(entity.id, iface, budget, state); - if (state.state != ReadState::Available) { note(state); return; } - const QDBusReply reply(message); - if (!reply.isValid()) { note(failure(EINVAL)); return; } - const QVariantMap values = reply.value(); - if (values.size() > 64) { note(failure(EOVERFLOW)); return; } - const auto add = [&](const char *key, const char *label, int type, Format format, bool zeroUnknown = false) { - const QVariant value = values.value(QLatin1String(key)); - Attribute data; - if (value.isValid()) { - if (value.metaType().id() != type) data = failure(EINVAL); - else if (!zeroUnknown || value.toULongLong() != 0) data = ok(QString::number(value.toULongLong())); - } - field(entity.fields, iface + "/SmartGetAttributes/" + QLatin1String(key), label, data, source + " : " + QLatin1String(key), format); - }; - add("avail_spare", QT_TRANSLATE_NOOP("Storage", "Cached available spare"), QMetaType::UChar, Format::Percent); - add("spare_thresh", QT_TRANSLATE_NOOP("Storage", "Cached spare threshold"), QMetaType::UChar, Format::Percent); - add("percent_used", QT_TRANSLATE_NOOP("Storage", "Cached estimated endurance used"), QMetaType::UChar, Format::Percent); - add("total_data_read", QT_TRANSLATE_NOOP("Storage", "Cached total data read"), QMetaType::ULongLong, Format::Bytes); - add("total_data_written", QT_TRANSLATE_NOOP("Storage", "Cached total data written"), QMetaType::ULongLong, Format::Bytes); - add("ctrl_busy_time", QT_TRANSLATE_NOOP("Storage", "Cached controller busy time"), QMetaType::ULongLong, Format::Minutes); - add("power_cycles", QT_TRANSLATE_NOOP("Storage", "Cached power cycles"), QMetaType::ULongLong, Format::Count); - add("unsafe_shutdowns", QT_TRANSLATE_NOOP("Storage", "Cached unsafe shutdowns"), QMetaType::ULongLong, Format::Count); - add("media_errors", QT_TRANSLATE_NOOP("Storage", "Cached media and data integrity errors"), QMetaType::ULongLong, Format::Count); - add("num_err_log_entries", QT_TRANSLATE_NOOP("Storage", "Cached error information log entries"), QMetaType::ULongLong, Format::Count); - add("wctemp", QT_TRANSLATE_NOOP("Storage", "Cached warning temperature threshold"), QMetaType::UShort, Format::Kelvin, true); - add("cctemp", QT_TRANSLATE_NOOP("Storage", "Cached critical temperature threshold"), QMetaType::UShort, Format::Kelvin, true); - add("warning_temp_time", QT_TRANSLATE_NOOP("Storage", "Cached time above warning temperature"), QMetaType::UInt, Format::Minutes); - add("critical_temp_time", QT_TRANSLATE_NOOP("Storage", "Cached time above critical temperature"), QMetaType::UInt, Format::Minutes); - - const QVariant sensors = values.value("temp_sensors"); - const char *const labels[] = { - QT_TRANSLATE_NOOP("Storage", "Cached temperature sensor 1"), QT_TRANSLATE_NOOP("Storage", "Cached temperature sensor 2"), - QT_TRANSLATE_NOOP("Storage", "Cached temperature sensor 3"), QT_TRANSLATE_NOOP("Storage", "Cached temperature sensor 4"), - QT_TRANSLATE_NOOP("Storage", "Cached temperature sensor 5"), QT_TRANSLATE_NOOP("Storage", "Cached temperature sensor 6"), - QT_TRANSLATE_NOOP("Storage", "Cached temperature sensor 7"), QT_TRANSLATE_NOOP("Storage", "Cached temperature sensor 8") - }; - // Accept Qt's typed list or a lazy D-Bus argument; decode at most eight items. - Attribute sensorState; - QVector temperatures; - if (sensors.isValid()) { - if (sensors.metaType().id() == qMetaTypeId>()) { - temperatures = sensors.value>(); - sensorState = temperatures.size() > 8 ? failure(EOVERFLOW) : ok(QString()); - } else if (sensors.metaType().id() != qMetaTypeId()) sensorState = failure(EINVAL); - else { - const QDBusArgument array = sensors.value(); - if (array.currentSignature() != "aq") sensorState = failure(EINVAL); - else { - array.beginArray(); - while (!array.atEnd() && temperatures.size() < 8) { - quint16 kelvin = 0; - array >> kelvin; - temperatures.append(kelvin); - } - if (!array.atEnd()) sensorState = failure(EOVERFLOW); - else sensorState = ok(QString()); - array.endArray(); - } - } - } - for (int i = 0; i < 8; ++i) { - Attribute data = sensorState.state == ReadState::Available ? Attribute() : sensorState; - if (sensorState.state == ReadState::Available && i < temperatures.size() && temperatures.at(i) != 0) - data = ok(QString::number(temperatures.at(i))); - field(entity.fields, iface + "/SmartGetAttributes/temp_sensors/" + QString::number(i + 1), labels[i], data, - source + QStringLiteral(" : temp_sensors[%1]").arg(i), Format::Kelvin); - } -} -void ataAttributes(StorageEntity &entity, QElapsedTimer &budget) -{ - const QString iface = QStringLiteral("org.freedesktop.UDisks2.Drive.Ata"); - const QString source = entity.id + " : " + iface + ".SmartGetAttributes"; - const auto note = [&](const Attribute &state) { - field(entity.fields, iface + "/SmartGetAttributes", QT_TRANSLATE_NOOP("Storage", "Cached ATA SMART attributes"), state, source, Format::HealthQueryStatus); - }; - Attribute state; - const QDBusMessage reply = healthReply(entity.id, iface, budget, state); - if (state.state != ReadState::Available) { note(state); return; } - const QVariant value = reply.arguments().constFirst(); - if (value.metaType().id() != qMetaTypeId()) { note(failure(EINVAL)); return; } - const QDBusArgument array = value.value(); - if (array.currentSignature() != "a(ysqiiixia{sv})") { note(failure(EINVAL)); return; } - QVector attributes; - QSet ids; - array.beginArray(); - while (!array.atEnd()) { - if (QThread::currentThread()->isInterruptionRequested()) { state = failure(ECANCELED); break; } - if (attributes.size() >= 256) { state = failure(EOVERFLOW); break; } - AtaSmartAttribute attribute; - QVariantMap expansion; - array.beginStructure(); - array >> attribute.id >> attribute.name >> attribute.flags >> attribute.current >> attribute.worst - >> attribute.threshold >> attribute.interpreted >> attribute.unit >> expansion; - array.endStructure(); - const auto normalized = [](qint32 number) { return number >= -1 && number <= 255; }; - if (attribute.name.toUtf8().size() > 128 || expansion.size() > 64) { state = failure(EOVERFLOW); break; } - if (ids.contains(attribute.id) || !normalized(attribute.current) || !normalized(attribute.worst) - || !normalized(attribute.threshold) || attribute.unit < 0) { state = failure(EINVAL); break; } - ids.insert(attribute.id); - attributes.append(std::move(attribute)); - } - array.endArray(); - if (state.state != ReadState::Available) { note(state); return; } - if (attributes.isEmpty()) { note({}); return; } - entity.ataAttributes = std::move(attributes); // Publish complete, validated list only. -} -void addService(StorageProperties &result, const QSet &nativePaths, const QString &selectedPath) -{ - const StorageObjects objects = serviceSnapshot(result); - if (objects.isEmpty()) return; - const QString prefix = QStringLiteral("org.freedesktop.UDisks2."); - QSet wanted; - QSet selectedDrives; - QMap blocks; - for (auto it = objects.cbegin(); it != objects.cend(); ++it) { - const QVariant value = it.value().value(prefix + "Block").value("DeviceNumber"); - if (!value.isValid()) continue; - const dev_t number = static_cast(value.toULongLong()); - const QString path = QFileInfo(QStringLiteral("/sys/dev/block/%1:%2").arg(major(number)).arg(minor(number))).canonicalFilePath(); - if (nativePaths.contains(path)) { - wanted.insert(it.key().path()); - blocks.insert(it.key().path(), path); - if (path == selectedPath || path.startsWith(selectedPath + '/')) { - const QString drive = objectPath(it.value().value(prefix + "Block").value("Drive")); - if (!drive.isEmpty() && drive != "/") selectedDrives.insert(drive); - } - } - } - // No guessed model/serial matching; each drive object is retained once. - for (int pass = 0; pass < relatedLimit; ++pass) { - const int before = wanted.size(); - for (auto it = objects.cbegin(); it != objects.cend(); ++it) { - if (!wanted.contains(it.key().path())) continue; - for (const auto &pair : {qMakePair(QStringLiteral("Block"), QStringLiteral("Drive")), - qMakePair(QStringLiteral("Block"), QStringLiteral("CryptoBackingDevice")), - qMakePair(QStringLiteral("Block"), QStringLiteral("MDRaid")), - qMakePair(QStringLiteral("Block"), QStringLiteral("MDRaidMember")), - qMakePair(QStringLiteral("Partition"), QStringLiteral("Table")), - qMakePair(QStringLiteral("Block.LVM2"), QStringLiteral("LogicalVolume")), - qMakePair(QStringLiteral("LogicalVolume"), QStringLiteral("VolumeGroup"))}) { - const QString target = objectPath(it.value().value(prefix + pair.first).value(pair.second)); - if (!target.isEmpty() && target != "/" && objects.contains(QDBusObjectPath(target))) wanted.insert(target); - } - } - if (wanted.size() > relatedLimit) { - field(result.notes, "service-limit", QT_TRANSLATE_NOOP("Storage", "Related storage metadata"), failure(EOVERFLOW), "UDisks2"); - return; - } - if (wanted.size() == before) break; - } - QList ordered; - for (auto it = objects.cbegin(); it != objects.cend(); ++it) { - if (wanted.contains(it.key().path())) ordered.append(it.key()); - } - // Prioritize explicit selected-device associations; retain object-path order - // within each tier and do not guess a single drive for spanning volumes. - std::stable_sort(ordered.begin(), ordered.end(), [&](const QDBusObjectPath &a, const QDBusObjectPath &b) { - return selectedDrives.contains(a.path()) && !selectedDrives.contains(b.path()); - }); - QElapsedTimer healthBudget; // Shared by all related ATA and NVMe cache-method calls. - for (const QDBusObjectPath &path : ordered) { - if (QThread::currentThread()->isInterruptionRequested()) return; - StorageEntity entity; - entity.id = path.path(); - if (blocks.contains(entity.id)) link(result, blocks.value(entity.id), entity.id, - QT_TRANSLATE_NOOP("Storage", "UDisks2 block metadata"), "UDisks2 Block.DeviceNumber / sysfs dev"); - const auto object = objects.constFind(path); - const auto &interfaces = object.value(); - const auto copy = [&](const char *iface, const char *key, const char *label, - Format format = Format::Text, bool zeroUnknown = false) { - serviceField(entity, interfaces.value(prefix + QLatin1String(iface)), prefix + QLatin1String(iface), key, label, format, zeroUnknown); - }; - if (interfaces.contains(prefix + "Drive")) { - copy("Drive", "Vendor", QT_TRANSLATE_NOOP("Storage", "Drive manufacturer")); - copy("Drive", "Model", QT_TRANSLATE_NOOP("Storage", "Drive model")); - copy("Drive", "Serial", QT_TRANSLATE_NOOP("Storage", "Drive serial number")); - copy("Drive", "Revision", QT_TRANSLATE_NOOP("Storage", "Drive firmware revision")); - copy("Drive", "ConnectionBus", QT_TRANSLATE_NOOP("Storage", "Drive transport")); - copy("Drive", "Size", QT_TRANSLATE_NOOP("Storage", "Drive capacity"), Format::Bytes, true); - copy("Drive", "Removable", QT_TRANSLATE_NOOP("Storage", "Removable/fixed hint"), Format::Removable); - copy("Drive", "MediaRemovable", QT_TRANSLATE_NOOP("Storage", "Drive media removable"), Format::Boolean); - copy("Drive", "MediaAvailable", QT_TRANSLATE_NOOP("Storage", "Drive media available"), Format::Boolean); - } - if (interfaces.contains(prefix + "Block")) { - copy("Block", "IdUsage", QT_TRANSLATE_NOOP("Storage", "Content usage")); - copy("Block", "IdType", QT_TRANSLATE_NOOP("Storage", "Filesystem / content type")); - copy("Block", "IdVersion", QT_TRANSLATE_NOOP("Storage", "Filesystem / content version")); - copy("Block", "IdLabel", QT_TRANSLATE_NOOP("Storage", "Filesystem / content label")); - copy("Block", "IdUUID", QT_TRANSLATE_NOOP("Storage", "Filesystem / content UUID")); - } - if (interfaces.contains(prefix + "Partition")) { - copy("Partition", "Number", QT_TRANSLATE_NOOP("Storage", "Partition number")); - copy("Partition", "Type", QT_TRANSLATE_NOOP("Storage", "Partition type")); - copy("Partition", "Offset", QT_TRANSLATE_NOOP("Storage", "Partition offset"), Format::Bytes); - copy("Partition", "Size", QT_TRANSLATE_NOOP("Storage", "Partition capacity"), Format::Bytes); - copy("Partition", "Name", QT_TRANSLATE_NOOP("Storage", "Partition name")); - copy("Partition", "UUID", QT_TRANSLATE_NOOP("Storage", "Partition UUID")); - } - if (interfaces.contains(prefix + "PartitionTable")) copy("PartitionTable", "Type", QT_TRANSLATE_NOOP("Storage", "Partition table type")); - if (interfaces.contains(prefix + "MDRaid")) { - copy("MDRaid", "Name", QT_TRANSLATE_NOOP("Storage", "RAID name")); - copy("MDRaid", "Level", QT_TRANSLATE_NOOP("Storage", "RAID level")); - copy("MDRaid", "UUID", QT_TRANSLATE_NOOP("Storage", "RAID UUID")); - copy("MDRaid", "NumDevices", QT_TRANSLATE_NOOP("Storage", "RAID member count")); - } - if (interfaces.contains(prefix + "LogicalVolume")) { - copy("LogicalVolume", "Name", QT_TRANSLATE_NOOP("Storage", "Logical volume name")); - copy("LogicalVolume", "Type", QT_TRANSLATE_NOOP("Storage", "Logical volume type")); - } - if (interfaces.contains(prefix + "VolumeGroup")) copy("VolumeGroup", "Name", QT_TRANSLATE_NOOP("Storage", "Volume group name")); - const bool ata = interfaces.contains(prefix + "Drive.Ata"); - const bool nvme = interfaces.contains(prefix + "NVMe.Controller"); - if (ata || nvme) { - const QString iface = ata ? "Drive.Ata" : "NVMe.Controller"; - const QVariantMap health = interfaces.value(prefix + iface); - if (ata) { - copy("Drive.Ata", "SmartSupported", QT_TRANSLATE_NOOP("Storage", "ATA SMART supported"), Format::Boolean); - copy("Drive.Ata", "SmartEnabled", QT_TRANSLATE_NOOP("Storage", "ATA SMART enabled"), Format::Boolean); - } - serviceField(entity, health, prefix + iface, "SmartUpdated", QT_TRANSLATE_NOOP("Storage", "Cached health updated"), Format::Epoch, true); - // Missing/zero timestamps invalidate every health assessment field. - if (health.value("SmartUpdated").toULongLong() != 0) { - serviceField(entity, health, prefix + iface, "SmartTemperature", QT_TRANSLATE_NOOP("Storage", "Cached temperature"), Format::Kelvin, true); - if (ata) { - copy("Drive.Ata", "SmartPowerOnSeconds", QT_TRANSLATE_NOOP("Storage", "Cached power-on time"), Format::Seconds, true); - copy("Drive.Ata", "SmartFailing", QT_TRANSLATE_NOOP("Storage", "Cached ATA failure prediction"), Format::AtaHealth); - for (const auto &pair : {qMakePair("SmartNumBadSectors", QT_TRANSLATE_NOOP("Storage", "Cached bad sectors")), - qMakePair("SmartNumAttributesFailing", QT_TRANSLATE_NOOP("Storage", "Cached failing attributes")), - qMakePair("SmartNumAttributesFailedInThePast", QT_TRANSLATE_NOOP("Storage", "Cached attributes failed in the past"))}) { - const QVariant count = health.value(QLatin1String(pair.first)); - const int type = QLatin1String(pair.first) == QLatin1String("SmartNumBadSectors") ? QMetaType::LongLong : QMetaType::Int; - Attribute data; - if (count.isValid()) { - if (count.metaType().id() != type || count.toLongLong() < -1) data = failure(EINVAL); - else if (count.toLongLong() >= 0) data = ok(QString::number(count.toLongLong())); - } - field(entity.fields, prefix + "Drive.Ata/" + QLatin1String(pair.first), pair.second, data, - entity.id + " : " + prefix + "Drive.Ata." + QLatin1String(pair.first), Format::Count); - } - ataAttributes(entity, healthBudget); - } else { - copy("NVMe.Controller", "SmartCriticalWarning", QT_TRANSLATE_NOOP("Storage", "Cached NVMe critical warnings"), Format::NvmeHealth); - copy("NVMe.Controller", "SmartPowerOnHours", QT_TRANSLATE_NOOP("Storage", "Cached power-on time"), Format::Hours, true); - nvmeAttributes(entity, healthBudget); - } - } else { - field(entity.fields, "health", QT_TRANSLATE_NOOP("Storage", "Cached health"), {}, entity.id + " : " + prefix + iface + ".SmartUpdated (never updated or unavailable)"); - } - } else if (interfaces.contains(prefix + "Drive")) { - field(entity.fields, "health", QT_TRANSLATE_NOOP("Storage", "Cached health"), {ReadState::Unsupported, {}, 0}, entity.id + " : UDisks2 (no supported ATA or NVMe health interface)"); - } - for (auto iface = interfaces.cbegin(); iface != interfaces.cend(); ++iface) { - for (const char *key : {"Drive", "CryptoBackingDevice", "MDRaid", "MDRaidMember", "Table", "LogicalVolume", "VolumeGroup"}) { - const QString target = objectPath(iface.value().value(QLatin1String(key))); - if (wanted.contains(target)) link(result, entity.id, target, QT_TRANSLATE_NOOP("Storage", "Storage relationship"), iface.key() + '.' + QLatin1String(key)); - } - } - result.entities.append(std::move(entity)); - } -} -#endif -} - -StorageProperties collectStorageProperties(const Device &device, bool cachedService) -{ - StorageProperties result; - result.applicable = device.subsystem == "block" || device.subsystem == "nvme"; - if (!result.applicable) return result; - std::unique_ptr context(udev_new(), &udev_unref); - const QDir directory("/sys/class/block"); - const DirectoryNames scanned = boundedDirectoryNames(directory.path(), recordLimit); - if (scanned.error) { - field(result.notes, "limit", QT_TRANSLATE_NOOP("Storage", "Storage inventory"), failure(scanned.error), "/sys/class/block"); - return result; - } - const QStringList &names = scanned.names; - QMap paths; - for (const QString &name : names) { - const QString path = QFileInfo(directory.filePath(name)).canonicalFilePath(); - if (path.startsWith("/sys/devices/")) paths.insert(name, path); - } - const QList pathValues = paths.values(); - const QSet allPaths(pathValues.cbegin(), pathValues.cend()); - QSet wanted; - if (device.subsystem == "block") wanted.insert(device.path); - else for (const QString &path : paths) - if (path.startsWith(device.path + '/')) wanted.insert(path); - QVector nativeLinks; - for (const QString &path : paths) { - if (QThread::currentThread()->isInterruptionRequested()) return result; - if (QFileInfo::exists(path + "/partition")) - nativeLinks.append({path, QFileInfo(path).dir().canonicalPath(), QT_TRANSLATE_NOOP("Storage", "Partition of"), path + "/partition (kernel ancestry)"}); - for (const auto &pair : {qMakePair("slaves", QT_TRANSLATE_NOOP("Storage", "Backed by")), qMakePair("holders", QT_TRANSLATE_NOOP("Storage", "Used by"))}) { - const QDir links(path + '/' + QLatin1String(pair.first)); - const DirectoryNames related = boundedDirectoryNames(links.path(), recordLimit); - if (related.error) { - field(result.notes, "limit", QT_TRANSLATE_NOOP("Storage", "Storage relationships"), failure(related.error), links.path()); - return result; - } - for (const QString &name : related.names) { - const QString target = QFileInfo(links.filePath(name)).canonicalFilePath(); - if (allPaths.contains(target)) nativeLinks.append({path, target, pair.second, links.filePath(name)}); - if (nativeLinks.size() > recordLimit * 4) { - field(result.notes, "limit", QT_TRANSLATE_NOOP("Storage", "Storage relationships"), failure(EOVERFLOW), "/sys/class/block"); - return result; - } - } - } - } - for (int pass = 0; pass < relatedLimit; ++pass) { - const int before = wanted.size(); - for (const StorageLink &edge : nativeLinks) - if (wanted.contains(edge.from) || wanted.contains(edge.to)) { wanted.insert(edge.from); wanted.insert(edge.to); } - if (wanted.size() > relatedLimit) { - field(result.notes, "limit", QT_TRANSLATE_NOOP("Storage", "Related storage metadata"), failure(EOVERFLOW), "/sys/class/block"); - return result; - } - if (wanted.size() == before) break; - } - QMap identities; - const auto identity = [](const QString &path) { - struct stat info {}; - if (lstat(path.toUtf8().constData(), &info) != 0) return QString(); - return QStringLiteral("%1:%2").arg(static_cast(info.st_dev)).arg(static_cast(info.st_ino)); - }; - for (const QString &path : wanted) identities.insert(path, identity(path)); - const Attribute mounts = textFile("/proc/self/mountinfo", 4 * 1024 * 1024); - field(result.notes, "mountinfo", QT_TRANSLATE_NOOP("Storage", "Mount metadata"), - mounts.state == ReadState::Available ? ok("application-namespace") : mounts, "/proc/self/mountinfo"); - QMap mountPoints; - if (mounts.state == ReadState::Available) { - for (const QString &line : mounts.value.split('\n')) { - const QStringList parts = line.split(' '); - const int separator = parts.indexOf("-"); - if (parts.size() < 6 || separator < 6 || parts.size() <= separator + 2) continue; - mountPoints[parts[2]].append(unescapeMount(parts[4]) + " [" + parts[separator + 1] + "; root=" + unescapeMount(parts[3]) + ']'); - } - } - // Sorting is deterministic and each canonical block path appears once. - QStringList selected = wanted.values(); - selected.sort(); - for (const QString &path : selected) { - if (QThread::currentThread()->isInterruptionRequested()) return result; - StorageEntity entity; - entity.id = path; - const QByteArray encoded = path.toUtf8(); - std::unique_ptr raw( - context ? udev_device_new_from_syspath(context.get(), encoded.constData()) : nullptr, &udev_device_unref); - metadata(entity, raw.get(), "DEVNAME", QT_TRANSLATE_NOOP("Storage", "Device node")); - const Attribute number = textFile(path + "/dev"); - field(entity.fields, "dev", QT_TRANSLATE_NOOP("Storage", "Major:minor"), number, path + "/dev"); - Attribute capacity = textFile(path + "/size"); - if (capacity.state == ReadState::Available) { - bool valid = false; - const quint64 sectors = capacity.value.toULongLong(&valid); - capacity = !valid || sectors > std::numeric_limits::max() / 512 - ? failure(EOVERFLOW) : ok(QString::number(sectors * 512)); - } - field(entity.fields, "capacity", QT_TRANSLATE_NOOP("Storage", "Block capacity"), capacity, path + "/size (512-byte units)", Format::Bytes); - const bool partition = QFileInfo::exists(path + "/partition"); - const QString queueOwner = partition ? QFileInfo(path).dir().canonicalPath() : path; - for (const auto &pair : {qMakePair("logical_block_size", QT_TRANSLATE_NOOP("Storage", "Logical sector size")), - qMakePair("physical_block_size", QT_TRANSLATE_NOOP("Storage", "Physical sector size"))}) { - const QString source = queueOwner + "/queue/" + pair.first; - field(entity.fields, QStringLiteral("queue/") + pair.first, pair.second, textFile(source), source, Format::Bytes); - } - native(entity, "removable", QT_TRANSLATE_NOOP("Storage", "Kernel removable media"), Format::Boolean); - native(entity, "ro", QT_TRANSLATE_NOOP("Storage", "Block read-only"), Format::Boolean); - if (QFileInfo::exists(path + "/partition")) { - native(entity, "partition", QT_TRANSLATE_NOOP("Storage", "Partition number")); - native(entity, "start", QT_TRANSLATE_NOOP("Storage", "Partition start (512-byte sectors)")); - } - for (const auto &pair : {qMakePair("ID_MODEL", QT_TRANSLATE_NOOP("Storage", "Disk model")), qMakePair("ID_VENDOR", QT_TRANSLATE_NOOP("Storage", "Manufacturer")), - qMakePair("ID_SERIAL_SHORT", QT_TRANSLATE_NOOP("Storage", "Serial number")), qMakePair("ID_REVISION", QT_TRANSLATE_NOOP("Storage", "Firmware revision")), - qMakePair("ID_BUS", QT_TRANSLATE_NOOP("Storage", "Transport")), qMakePair("ID_FS_TYPE", QT_TRANSLATE_NOOP("Storage", "Filesystem / content type")), - qMakePair("ID_FS_USAGE", QT_TRANSLATE_NOOP("Storage", "Content usage")), qMakePair("ID_FS_LABEL", QT_TRANSLATE_NOOP("Storage", "Filesystem / content label")), - qMakePair("ID_FS_UUID", QT_TRANSLATE_NOOP("Storage", "Filesystem / content UUID")), qMakePair("ID_PART_TABLE_TYPE", QT_TRANSLATE_NOOP("Storage", "Partition table type"))}) - metadata(entity, raw.get(), pair.first, pair.second); - if (QFileInfo::exists(path + "/dm")) { - native(entity, "dm/name", QT_TRANSLATE_NOOP("Storage", "Device-mapper name")); - native(entity, "dm/uuid", QT_TRANSLATE_NOOP("Storage", "Device-mapper UUID")); - } - if (QFileInfo::exists(path + "/md")) { - native(entity, "md/level", QT_TRANSLATE_NOOP("Storage", "RAID level")); - native(entity, "md/array_state", QT_TRANSLATE_NOOP("Storage", "RAID state")); - native(entity, "md/degraded", QT_TRANSLATE_NOOP("Storage", "RAID missing members")); - } - // Read only selected SCSI/NVMe identity metadata, with parent source kept explicit. - const QString hardware = QFileInfo(path + "/device").canonicalFilePath(); - if (hardware.startsWith("/sys/devices/")) { - const QString subsystem = QFileInfo(hardware + "/subsystem").canonicalFilePath(); - if (QFileInfo(subsystem).fileName() == "nvme") - field(entity.fields, "native-transport", QT_TRANSLATE_NOOP("Storage", "Native transport"), ok("NVMe"), hardware + "/subsystem"); - else if (raw && udev_device_get_property_value(raw.get(), "ID_ATA_SATA") - && QByteArray(udev_device_get_property_value(raw.get(), "ID_ATA_SATA")) == "1") - field(entity.fields, "native-transport", QT_TRANSLATE_NOOP("Storage", "Native transport"), ok("SATA"), "udev: ID_ATA_SATA on " + path); - for (const auto &pair : {qMakePair("model", QT_TRANSLATE_NOOP("Storage", "Hardware model")), qMakePair("vendor", QT_TRANSLATE_NOOP("Storage", "Hardware vendor")), - qMakePair("rev", QT_TRANSLATE_NOOP("Storage", "Hardware revision")), qMakePair("serial", QT_TRANSLATE_NOOP("Storage", "Hardware serial")), - qMakePair("firmware_rev", QT_TRANSLATE_NOOP("Storage", "Hardware firmware revision"))}) - field(entity.fields, QStringLiteral("hardware/") + pair.first, pair.second, - textFile(hardware + '/' + pair.first), hardware + '/' + pair.first); - } - const QStringList points = mountPoints.value(number.value); - field(entity.fields, "mounts", QT_TRANSLATE_NOOP("Storage", "Mount points (application namespace)"), - mounts.state != ReadState::Available ? mounts : number.state != ReadState::Available ? number - : !points.isEmpty() ? (points.join('\n').size() > textLimit ? failure(EOVERFLOW) : ok(points.join('\n'))) - : ok("no-observed-mount"), "/proc/self/mountinfo : " + number.value); - result.entities.append(std::move(entity)); - } - for (const StorageLink &edge : nativeLinks) - if (wanted.contains(edge.from) && wanted.contains(edge.to)) link(result, edge.from, edge.to, edge.label, edge.source); - if (device.subsystem == "nvme") { - StorageEntity controller; - controller.id = device.path; - native(controller, "model", QT_TRANSLATE_NOOP("Storage", "Controller model")); - native(controller, "serial", QT_TRANSLATE_NOOP("Storage", "Controller serial number")); - native(controller, "firmware_rev", QT_TRANSLATE_NOOP("Storage", "Controller firmware revision")); - result.entities.prepend(controller); - for (const QString &path : selected) - if (path.startsWith(device.path + '/')) - link(result, device.path, path, QT_TRANSLATE_NOOP("Storage", "Controller namespace / partition"), "sysfs ancestry"); - } -#ifdef QLDM_HAVE_DBUS - if (cachedService && !QThread::currentThread()->isInterruptionRequested()) addService(result, wanted, device.path); -#else - Q_UNUSED(cachedService); - field(result.notes, "service", QT_TRANSLATE_NOOP("Storage", "UDisks2 metadata"), {ReadState::Unsupported, {}, 0}, "QtDBus not enabled in this build"); -#endif - // Related paths and device numbers can also be reused. Reject the whole - // storage snapshot if a native entity changed while the service was queried. - for (auto it = identities.cbegin(); it != identities.cend(); ++it) { - if (it.value().isEmpty() || identity(it.key()) != it.value()) { - result.entities.clear(); - result.links.clear(); - result.notes.clear(); - field(result.notes, "changed", QT_TRANSLATE_NOOP("Storage", "Storage topology changed during collection"), - failure(EAGAIN), it.key()); - break; - } - } - return result; -} diff --git a/src/devices/storageproperties.h b/src/devices/storageproperties.h deleted file mode 100644 index 8b49055..0000000 --- a/src/devices/storageproperties.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include "device.h" - -// Owned metadata only. Relationships are explicit edges, never capacity totals. -struct StorageField { - enum class Format { Text, Bytes, Boolean, Removable, Epoch, Kelvin, Percent, Hours, Seconds, Minutes, Count, AtaHealth, NvmeHealth, HealthQueryStatus }; - QString id; - const char *label = nullptr; // Static translatable caption, not device input. - Attribute value; - QString source; - Format format = Format::Text; -}; -struct AtaSmartAttribute { - quint8 id = 0; - QString name; // UDisks2's drive-specific identifier, not guessed from the ID. - quint16 flags = 0; - qint32 current = -1, worst = -1, threshold = -1; - qint64 interpreted = 0; - qint32 unit = 0; // UDisks2 unit; zero means no interpreted value. -}; -struct StorageEntity { - QString id; // Canonical sysfs path or UDisks object path, snapshot-local. - QVector fields; - QVector ataAttributes; -}; -struct StorageLink { - QString from; - QString to; - const char *label = nullptr; - QString source; -}; -struct StorageProperties { - bool applicable = false; - QVector entities; - QVector links; - QVector notes; -}; - -// Worker only. cachedService=false is used by deep search to avoid repeated -// service snapshots; it still searches native metadata and storage relations. -StorageProperties collectStorageProperties(const Device &device, bool cachedService); diff --git a/src/devices/systemproperties.cpp b/src/devices/systemproperties.cpp deleted file mode 100644 index 63b1835..0000000 --- a/src/devices/systemproperties.cpp +++ /dev/null @@ -1,634 +0,0 @@ -#include "systemproperties.h" -#include "secureboot.h" -#include "cpumetadata.h" -#include "directoryscan.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#if defined(__i386__) || defined(__x86_64__) -#include -#endif - -namespace { -Attribute available(const QString &value) { return {ReadState::Available, value, 0}; } -Attribute failed(int error) -{ - return {error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENOENT ? ReadState::Unavailable : ReadState::Error, {}, error}; -} -Attribute readText(const QString &path, int limit = 4096) -{ - if (QThread::currentThread()->isInterruptionRequested()) return failed(ECANCELED); - const int fd = open(path.toUtf8().constData(), O_RDONLY | O_CLOEXEC); - if (fd < 0) return failed(errno); - QByteArray bytes; - int error = 0; - while (bytes.size() <= limit) { - char buffer[4096]; - const ssize_t n = read(fd, buffer, qMin(sizeof(buffer), limit + 1 - bytes.size())); - if (n < 0) { if (errno == EINTR) continue; error = errno; break; } - if (!n) break; - bytes.append(buffer, static_cast(n)); - if (QThread::currentThread()->isInterruptionRequested()) { error = ECANCELED; break; } - } - close(fd); - if (error) return failed(error); - if (bytes.size() > limit) return failed(EOVERFLOW); - const QString text = QString::fromUtf8(bytes).trimmed(); - return text.isEmpty() ? Attribute() : available(text); -} -bool number(const Attribute &value, qlonglong &result) -{ - bool ok = false; - result = value.value.toLongLong(&ok); - return value.state == ReadState::Available && ok && result >= 0; -} -bool unsignedDecimal(const QString &text, qulonglong &value) -{ - if (text.isEmpty() || text.size() > 20) return false; - for (QChar c : text) if (c < QLatin1Char('0') || c > QLatin1Char('9')) return false; - bool ok = false; - value = text.toULongLong(&ok); - return ok; -} -constexpr int maxMemoryDevices = 4096; -constexpr int maxDetailedMemoryDevices = 256; -constexpr int maxMemoryString = 256; -constexpr int maxMemoryBytes = 1024 * 1024; -const char *const memorySuffixes[] = { - "SIZE", "PRESENT", "NON_VOLATILE_SIZE", "VOLATILE_SIZE", "LOCATOR", "BANK_LOCATOR", - "TYPE", "FORM_FACTOR", "SPEED_MTS", "CONFIGURED_SPEED_MTS", "MANUFACTURER", "PART_NUMBER" -}; -void installedMemory(SystemProperties &result) -{ - const auto failure = [&](const Attribute &status) { - result.values["physical_memory"] = status; - result.memoryDevices.status = status; - }; - std::unique_ptr context(udev_new(), &udev_unref); - if (!context) { failure(failed(ENOMEM)); return; } - errno = 0; - std::unique_ptr device( - udev_device_new_from_subsystem_sysname(context.get(), "dmi", "id"), &udev_device_unref); - if (!device) { failure(errno ? failed(errno) : Attribute()); return; } - QHash properties; - int copiedBytes = 0; - const auto copy = [&](const QString &key, int limit = maxMemoryString) { - const char *raw = udev_device_get_property_value(device.get(), key.toUtf8().constData()); - if (!raw) return true; - const size_t length = strnlen(raw, static_cast(limit + 1)); - if (length > static_cast(limit) || copiedBytes > maxMemoryBytes - static_cast(length)) return false; - copiedBytes += static_cast(length); - properties.insert(key, QString::fromUtf8(raw, static_cast(length))); - return true; - }; - const QString countKey = "MEMORY_ARRAY_NUM_DEVICES"; - if (!copy(countKey, 64)) { failure(failed(EOVERFLOW)); return; } - qulonglong count = 0; - if (!properties.contains(countKey)) return; - if (!unsignedDecimal(properties.value(countKey), count)) { failure(failed(EINVAL)); return; } - if (count > maxMemoryDevices) { failure(failed(EOVERFLOW)); return; } - // Capacity and metadata share one owned udev database snapshot. Optional - // metadata bounds must not invalidate the existing installed-RAM calculation. - for (qulonglong index = 0; index < count; ++index) { - if (QThread::currentThread()->isInterruptionRequested()) { failure(failed(ECANCELED)); return; } - const QString prefix = QStringLiteral("MEMORY_DEVICE_%1_").arg(index); - for (const char *suffix : {"SIZE", "PRESENT", "NON_VOLATILE_SIZE"}) - if (!copy(prefix + QLatin1String(suffix), 64)) { failure(failed(EOVERFLOW)); return; } - } - result.values["physical_memory"] = parseInstalledMemory(properties); - if (count > maxDetailedMemoryDevices) { result.memoryDevices.status = failed(EOVERFLOW); return; } - for (qulonglong index = 0; index < count; ++index) { - if (QThread::currentThread()->isInterruptionRequested()) { result.memoryDevices.status = failed(ECANCELED); return; } - const QString prefix = QStringLiteral("MEMORY_DEVICE_%1_").arg(index); - for (const char *suffix : memorySuffixes) { - const QString key = prefix + QLatin1String(suffix); - if (!properties.contains(key) && !copy(key)) { result.memoryDevices.status = failed(EOVERFLOW); return; } - } - } - result.memoryDevices = parseMemoryDevices(properties); -} -void gpuResizableBar(SystemProperties &result) -{ - const QString base = "/sys/bus/pci/devices"; - const int fd = open(base.toUtf8().constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (fd < 0) { result.gpuRebarStatus = failed(errno); return; } - close(fd); - const DirectoryNames devices = boundedDirectoryNames(base, 4096); - if (devices.error) { result.gpuRebarStatus = failed(devices.error); return; } - for (const QString &name : devices.names) { - if (QThread::currentThread()->isInterruptionRequested()) { - result.gpuRebar.clear(); result.gpuRebarStatus = failed(ECANCELED); return; - } - const QString path = base + '/' + name; - struct stat before {}; - if (stat(path.toUtf8().constData(), &before) != 0) { - result.gpuRebar.clear(); result.gpuRebarStatus = failed(errno); return; - } - const Attribute cls = readText(path + "/class", 32); - bool ok = false; - const uint code = cls.value.toUInt(&ok, 16); - if (cls.state != ReadState::Available || !ok || code > 0xffffff) { - result.gpuRebar.clear(); result.gpuRebarStatus = cls.state == ReadState::Available ? failed(EINVAL) : cls; return; - } - if (code >> 16 != 0x03) continue; // PCI display controllers only; no GPU-ID database. - if (result.gpuRebar.size() >= 64) { - result.gpuRebar.clear(); result.gpuRebarStatus = failed(EOVERFLOW); return; - } - GpuResizableBar gpu; - gpu.pciAddress = name; - Attribute resources; - bool haveCapability = false; - for (int bar = 0; bar < 6; ++bar) { - const QString bitmapPath = path + QStringLiteral("/resource%1_resize").arg(bar); - struct stat attribute {}; - if (stat(bitmapPath.toUtf8().constData(), &attribute) != 0) { - if (errno == ENOENT) continue; - gpu.status = failed(errno); - break; - } - const Attribute bitmap = readText(bitmapPath, 32); - haveCapability = true; // Existing sysfs attribute is positive evidence, even if unreadable. - gpu.supportedSizes[bar] = bitmap.state == ReadState::Available ? parseResizableBarSizes(bitmap.value) - : bitmap.state == ReadState::Unavailable ? failed(ENODATA) : bitmap; - if (resources.state == ReadState::Unavailable && resources.error == 0) - resources = readText(path + "/resource", 65536); - gpu.currentSizes[bar] = resources.state == ReadState::Available - ? parseResizableBarAllocation(resources.value, bar) : resources; - } - if (gpu.status.state == ReadState::Unavailable && gpu.status.error == 0) - gpu.status = haveCapability ? available("reported") : Attribute(); - // Catch removal/replacement or allocation changes observed during collection. - struct stat after {}; - const bool same = stat(path.toUtf8().constData(), &after) == 0 - && before.st_dev == after.st_dev && before.st_ino == after.st_ino; - bool stable = same; - if (stable && haveCapability) { - const Attribute finalResources = readText(path + "/resource", 65536); - stable = resources.state == finalResources.state && resources.error == finalResources.error - && resources.value == finalResources.value; - } - if (stable && haveCapability) { - for (auto it = gpu.supportedSizes.cbegin(); it != gpu.supportedSizes.cend(); ++it) { - Attribute bitmap = readText(path + QStringLiteral("/resource%1_resize").arg(it.key()), 32); - if (bitmap.state == ReadState::Available) bitmap = parseResizableBarSizes(bitmap.value); - else if (bitmap.state == ReadState::Unavailable && bitmap.error == 0) bitmap = failed(ENODATA); - if (bitmap.state != it.value().state || bitmap.error != it.value().error || bitmap.value != it.value().value) - stable = false; - } - } - if (!stable) { - gpu.status = failed(EAGAIN); - gpu.supportedSizes.clear(); gpu.currentSizes.clear(); - } - result.gpuRebar.append(std::move(gpu)); - } - const DirectoryNames after = boundedDirectoryNames(base, 4096); - if (after.error || after.names != devices.names) { - result.gpuRebar.clear(); result.gpuRebarStatus = failed(after.error ? after.error : EAGAIN); return; - } - result.gpuRebarStatus = available(QString::number(result.gpuRebar.size())); -} -void cpuTopology(SystemProperties &result) -{ - const QString base = "/sys/devices/system/cpu"; - const Attribute present = readText(base + "/present"); - const Attribute online = readText(base + "/online"); - QSet cpus, active; - const bool havePresent = present.state == ReadState::Available && parseCpuList(present.value, cpus); - const bool haveOnline = online.state == ReadState::Available && parseCpuList(online.value, active); - result.values["logical"] = havePresent ? available(QString::number(cpus.size())) - : present.state == ReadState::Available ? failed(EINVAL) : present; - result.values["online"] = haveOnline ? available(QString::number(active.size())) - : online.state == ReadState::Available ? failed(EINVAL) : online; - QSet packages, cores; - Attribute topologyError; - bool topologyComplete = havePresent; - if (havePresent) { - for (int cpu : cpus) { - const QString path = base + QStringLiteral("/cpu%1/topology/").arg(cpu); - const Attribute package = readText(path + "physical_package_id"); - const Attribute core = readText(path + "core_id"); - const Attribute die = readText(path + "die_id"); - qlonglong packageId, coreId, dieId; - if (!number(package, packageId) || !number(core, coreId)) { - topologyError = package.state != ReadState::Available ? package - : core.state != ReadState::Available ? core : failed(ENODATA); - topologyComplete = false; - break; // No apparently complete count from partial topology. - } - if (die.state == ReadState::PermissionDenied || die.state == ReadState::Error) { - topologyError = die; - topologyComplete = false; - break; - } - packages.insert(QString::number(packageId)); - cores.insert(QStringLiteral("%1:%2:%3").arg(packageId) - .arg(number(die, dieId) ? QString::number(dieId) : QStringLiteral("unknown")) - .arg(coreId)); - } - } else topologyError = result.values.value("logical"); - result.values["sockets"] = topologyComplete ? available(QString::number(packages.size())) : topologyError; - result.values["cores"] = topologyComplete ? available(QString::number(cores.size())) : topologyError; - for (const char *key : {"logical", "online", "sockets", "cores"}) - result.sources[QLatin1String(key)] = base + "/present, online and cpu*/topology"; - - if (!haveOnline) { result.values["caches"] = result.values.value("online"); return; } - // Deduplicate by level/type and canonical sharing set, never by CPU number. - QHash seen; - QMap> totals; - int entries = 0; - Attribute cacheError; - bool complete = true; - for (int cpu : active) { - const QString path = base + QStringLiteral("/cpu%1/cache").arg(cpu); - struct stat st {}; - if (stat(path.toUtf8().constData(), &st) != 0) { cacheError = failed(errno); complete = false; break; } - const int cacheFd = open(path.toUtf8().constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (cacheFd < 0) { cacheError = failed(errno); complete = false; break; } - close(cacheFd); - const DirectoryNames indexes = boundedDirectoryNames(path, 16384 - entries, {"index*"}); - if (indexes.error) { cacheError = failed(indexes.error); complete = false; break; } - if (indexes.names.isEmpty()) { complete = false; break; } - for (const QString &index : indexes.names) { - ++entries; - const QString prefix = path + '/' + index + '/'; - const Attribute level = readText(prefix + "level"), type = readText(prefix + "type"), - size = readText(prefix + "size"), sharing = readText(prefix + "shared_cpu_list"); - for (const Attribute &value : {level, type, size, sharing}) - if (value.state != ReadState::Available) { cacheError = value; complete = false; break; } - if (!complete) break; - qlonglong levelNumber; - QSet siblings; - QString sizeText = size.value; - qulonglong scale = 1; - if (sizeText.endsWith('K')) { scale = 1024; sizeText.chop(1); } - else if (sizeText.endsWith('M')) { scale = 1024 * 1024; sizeText.chop(1); } - bool ok = false; - const qulonglong amount = sizeText.toULongLong(&ok); - if (!number(level, levelNumber) || !levelNumber || !ok || !amount - || amount > std::numeric_limits::max() / scale - || !parseCpuList(sharing.value, siblings) || !siblings.contains(cpu) - || (type.value != "Data" && type.value != "Instruction" && type.value != "Unified")) { - cacheError = failed(EINVAL); complete = false; break; - } - QList sorted = siblings.values(); - std::sort(sorted.begin(), sorted.end()); - QStringList ids; - for (int id : sorted) ids.append(QString::number(id)); - const QString kind = QStringLiteral("L%1 %2").arg(levelNumber).arg(type.value); - const QString key = kind + ':' + ids.join(','); - const qulonglong bytes = amount * scale; - if (seen.contains(key)) { - if (seen.value(key) != bytes) { cacheError = failed(EAGAIN); complete = false; break; } - } else { - seen.insert(key, bytes); - auto &total = totals[kind]; - if (bytes > static_cast(std::numeric_limits::max()) - || total.first > static_cast(std::numeric_limits::max()) - bytes) { - cacheError = failed(EOVERFLOW); complete = false; break; - } - total.first += bytes; - ++total.second; - } - } - if (!complete) break; - } - QStringList lines; - for (auto it = totals.cbegin(); it != totals.cend(); ++it) - lines.append(QStringLiteral("%1\t%2\t%3").arg(it.key()).arg(it.value().first).arg(it.value().second)); - result.values["caches"] = complete && !lines.isEmpty() ? available(lines.join('\n')) : cacheError; - result.sources["caches"] = base + "/cpu*/cache/index*/{level,type,size,shared_cpu_list}; online CPUs only"; - // Detect observed hotplug during collection rather than publish mixed counts. - if (readText(base + "/present").value != present.value || readText(base + "/online").value != online.value) - for (const char *key : {"logical", "online", "sockets", "cores", "caches"}) - result.values[QLatin1String(key)] = failed(EAGAIN); -} -} - -Attribute parseResizableBarSizes(const QString &text) -{ - if (text.isEmpty() || text.size() > 16) return failed(EINVAL); - for (QChar c : text) - if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) return failed(EINVAL); - bool ok = false; - const qulonglong bitmap = text.toULongLong(&ok, 16); - if (!ok || !bitmap) return failed(EINVAL); - // bit n means 2^(n+20) bytes. Do not shift beyond a 64-bit size. - if (bitmap >> 44) return failed(EOVERFLOW); - return available(QString::number(bitmap, 16)); -} -Attribute parseResizableBarAllocation(const QString &resources, int bar) -{ - if (bar < 0 || bar >= 6 || resources.size() > 65536) return failed(EINVAL); - const QStringList lines = resources.split('\n'); - if (bar >= lines.size()) return failed(EINVAL); - const QStringList fields = lines[bar].simplified().split(' '); - if (fields.size() != 3) return failed(EINVAL); - qulonglong values[3] {}; - for (int i = 0; i < 3; ++i) { - QString text = fields[i]; - if (text.startsWith("0x", Qt::CaseInsensitive)) text.remove(0, 2); - if (text.isEmpty() || text.size() > 16) return failed(EINVAL); - for (QChar c : text) - if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) return failed(EINVAL); - bool ok = false; - values[i] = text.toULongLong(&ok, 16); - if (!ok) return failed(EINVAL); - } - const qulonglong start = values[0], end = values[1], flags = values[2]; - if ((flags & 0x1f00) != 0x200) return {}; // No reported memory resource. - if (flags & 0x30000000) return {}; // Unassigned or disabled resource, not a BIOS ReBAR verdict. - if (start == 0 && end == 0) return {}; // Empty/masked addresses never mean a one-byte BAR. - if (end < start) return failed(EINVAL); - if (end - start == std::numeric_limits::max()) return failed(EOVERFLOW); - return available(QString::number(end - start + 1)); -} -Attribute parseCpuInstructionSets(const QString &cpuinfo) -{ - // Reuse the bounded /proc/cpuinfo snapshot, never claim a union of features - // is supported on every CPU. Preserve kernel tokens, including non-ISA flags. - if (cpuinfo.size() > 4 * 1024 * 1024) return failed(EOVERFLOW); - QMap processors; - QHash record; - Attribute error; - bool complete = true; - const auto commit = [&] { - if (!record.contains("processor")) return; - bool ok = false; - const int id = record.value("processor").toInt(&ok); - if (!ok || id < 0 || processors.contains(id)) { error = failed(EINVAL); complete = false; return; } - if (processors.size() >= 4096) { error = failed(EOVERFLOW); complete = false; return; } - QString features; - for (const char *key : {"flags", "Features", "isa"}) { - if (!record.value(QLatin1String(key)).isEmpty()) { - features = record.value(QLatin1String(key)).simplified(); - break; - } - } - if (features.isEmpty()) { complete = false; return; } - QStringList tokens = features.split(' ', Qt::SkipEmptyParts); - if (tokens.size() > 4096) { error = failed(EOVERFLOW); complete = false; return; } - tokens.removeDuplicates(); - tokens.sort(Qt::CaseSensitive); - processors.insert(id, tokens.join(' ')); - }; - for (const QString &line : cpuinfo.split('\n')) { - if (QThread::currentThread()->isInterruptionRequested()) return failed(ECANCELED); - if (line.trimmed().isEmpty()) { commit(); record.clear(); continue; } - const int colon = line.indexOf(':'); - if (colon < 0) continue; - const QString key = line.left(colon).trimmed(); - if (key == "processor" || key == "flags" || key == "Features" || key == "isa") - record.insert(key, line.mid(colon + 1).trimmed()); - } - commit(); - if (!complete) return error; - if (processors.isEmpty()) return {}; - QMap groups; - for (auto it = processors.cbegin(); it != processors.cend(); ++it) - groups[it.value()].append(QString::number(it.key())); - QStringList lines; - for (auto it = groups.cbegin(); it != groups.cend(); ++it) - lines.append(it.value().join(", ") + '\t' + it.key()); - return available(lines.join('\n')); -} -Attribute parseOsName(const QString &text) -{ - Attribute result = available("Linux"); // Specified default, only after a successful file read. - for (const QString &line : text.split('\n')) { - if (!line.startsWith("PRETTY_NAME=")) continue; - QString value = line.mid(12).trimmed(); - QString decoded; - const QChar quote = value.isEmpty() ? QChar() : value.front(); - const bool quoted = quote == '\'' || quote == '"'; - if (quoted) { - if (value.size() < 2 || value.back() != quote) return failed(EINVAL); - value = value.mid(1, value.size() - 2); - } - for (int i = 0; i < value.size(); ++i) { - const QChar c = value.at(i); - if (quoted && c == quote) return failed(EINVAL); - if (c == '\\' && quote != '\'') { - if (++i >= value.size()) return failed(EINVAL); - const QChar next = value.at(i); - if (quote == '"' && next != '"' && next != '\\' && next != '$' && next != '`') decoded += '\\'; - decoded += next; - } else decoded += c; - } - result = decoded.isEmpty() ? Attribute() : available(decoded); - } - return result; -} -bool parseCpuList(const QString &text, QSet &cpus) -{ - QSet parsed; - for (const QString &range : text.split(',')) { - const QStringList limits = range.trimmed().split('-'); - bool firstOk = false, lastOk = false; - const int first = limits.value(0).toInt(&firstOk); - const int last = limits.size() == 1 ? first : limits.value(1).toInt(&lastOk); - if (!firstOk || (limits.size() != 1 && !lastOk) || limits.size() > 2 - || first < 0 || last < first || last > 1048575 || last - first >= 4096) return false; - for (int cpu = first; cpu <= last; ++cpu) parsed.insert(cpu); - if (parsed.size() > 4096) return false; - } - if (parsed.isEmpty()) return false; - cpus = std::move(parsed); - return true; -} -Attribute parseInstalledMemory(const QHash &properties) -{ - const QString countKey = "MEMORY_ARRAY_NUM_DEVICES"; - if (!properties.contains(countKey)) return {}; - qulonglong count = 0, total = 0; - if (!unsignedDecimal(properties.value(countKey), count)) return failed(EINVAL); - if (count > 4096) return failed(EOVERFLOW); - for (qulonglong slot = 0; slot < count; ++slot) { - const QString prefix = QStringLiteral("MEMORY_DEVICE_%1_").arg(slot); - const QString present = properties.value(prefix + "PRESENT"); - const QString size = properties.value(prefix + "SIZE"); - if (present == "0") { - if (!size.isEmpty() && size != "0") return failed(EINVAL); - continue; // Explicitly empty slots do not require a capacity. - } - if (!present.isEmpty() && present != "1") return failed(EINVAL); - qulonglong bytes = 0; - if (size.isEmpty()) return {}; // Unknown slot invalidates the entire total. - if (!unsignedDecimal(size, bytes)) return failed(EINVAL); - if (!bytes) return {}; - const QString persistent = properties.value(prefix + "NON_VOLATILE_SIZE"); - qulonglong nonVolatile = 0; - if (!persistent.isEmpty() && persistent != "Unknown") { - if (!unsignedDecimal(persistent, nonVolatile)) return failed(EINVAL); - // SIZE may include persistent capacity; do not call that installed RAM. - if (nonVolatile) return {ReadState::Unsupported, {}, 0}; - } - if (total > std::numeric_limits::max() - bytes) return failed(EOVERFLOW); - total += bytes; - } - return total ? available(QString::number(total)) : Attribute(); -} -FirmwareMemoryInformation parseMemoryDevices(const QHash &properties) -{ - FirmwareMemoryInformation result; - const QString countKey = "MEMORY_ARRAY_NUM_DEVICES"; - if (!properties.contains(countKey)) return result; - qulonglong count = 0; - if (!unsignedDecimal(properties.value(countKey), count)) { result.status = failed(EINVAL); return result; } - if (count > maxDetailedMemoryDevices) { result.status = failed(EOVERFLOW); return result; } - int bytes = 0; - const auto numeric = [](const QString &key) { - return key == "SIZE" || key == "VOLATILE_SIZE" || key == "NON_VOLATILE_SIZE" - || key == "SPEED_MTS" || key == "CONFIGURED_SPEED_MTS"; - }; - const QStringList placeholders = {"unknown", "not specified", "not provided", "none", - "to be filled by o.e.m.", "default string"}; - for (qulonglong index = 0; index < count; ++index) { - if (QThread::currentThread()->isInterruptionRequested()) { - result.devices.clear(); result.status = failed(ECANCELED); return result; - } - FirmwareMemoryDevice record; - record.index = static_cast(index); - const QString prefix = QStringLiteral("MEMORY_DEVICE_%1_").arg(index); - for (const char *suffix : memorySuffixes) { - const QString key = QLatin1String(suffix); - const QString raw = properties.value(prefix + key); - const qsizetype byteSize = raw.toUtf8().size(); - if (byteSize > maxMemoryString || bytes > maxMemoryBytes - byteSize) { - result.devices.clear(); result.status = failed(EOVERFLOW); return result; - } - bytes += static_cast(byteSize); - const QString text = raw.trimmed(); - Attribute value; - if (!text.isEmpty() && !placeholders.contains(text.toLower())) { - value = available(text); - qulonglong amount = 0; - if (key == "PRESENT") { - if (text != "0" && text != "1") value = failed(EINVAL); - } else if (numeric(key)) { - if (!unsignedDecimal(text, amount)) value = failed(EINVAL); - else if (!amount && (key == "SPEED_MTS" || key == "CONFIGURED_SPEED_MTS")) value = {}; - } - } - record.values.insert(key, value); - } - const Attribute present = record.values.value("PRESENT"); - if (present.state == ReadState::Available && present.value == "0") { - for (const char *suffix : {"SIZE", "VOLATILE_SIZE", "NON_VOLATILE_SIZE"}) { - Attribute &capacity = record.values[QLatin1String(suffix)]; - if (capacity.state == ReadState::Error - || (capacity.state == ReadState::Available && capacity.value.toULongLong() != 0)) { - capacity = failed(EINVAL); - record.values["PRESENT"] = failed(EINVAL); - } else capacity = {ReadState::NotApplicable, {}, 0}; - } - } else { - // A zero total capacity does not establish an empty device. - Attribute &capacity = record.values["SIZE"]; - if (capacity.state == ReadState::Available && capacity.value.toULongLong() == 0) capacity = {}; - } - result.devices.append(std::move(record)); - } - result.status = available(QString::number(count)); - return result; -} -SystemProperties collectSystemProperties() -{ - SystemProperties result; - const auto put = [&](const QString &key, const Attribute &value, const QString &source) { - result.values.insert(key, value); result.sources.insert(key, source); - }; - const QString hostname = QSysInfo::machineHostName(); - put("hostname", hostname.isEmpty() ? Attribute() : available(hostname), "gethostname"); - QString osPath = "/etc/os-release"; - Attribute os = readText(osPath, 65536); - if (os.error == ENOENT) { osPath = "/usr/lib/os-release"; os = readText(osPath, 65536); } - put("os", os.state == ReadState::Available ? parseOsName(os.value) : os, osPath + ": PRETTY_NAME"); - struct utsname kernel {}; - if (uname(&kernel) == 0) { - put("architecture", available(QString::fromLocal8Bit(kernel.machine)), "uname.machine (kernel architecture)"); - put("kernel", available(QString::fromLocal8Bit(kernel.sysname) + ' ' + QString::fromLocal8Bit(kernel.release)), "uname.sysname, release"); - put("build", available(QString::fromLocal8Bit(kernel.version)), "uname.version"); - } else { - const Attribute error = failed(errno); - for (const char *key : {"architecture", "kernel", "build"}) put(QLatin1String(key), error, "uname"); - } - const Attribute cpu = readCpuInfo(); - QStringList models = parseCpuModels(cpu.value).values(); - models.removeDuplicates(); models.sort(Qt::CaseInsensitive); - put("cpu", cpu.state == ReadState::Available ? models.isEmpty() ? Attribute() : available(models.join('\n')) : cpu, "/proc/cpuinfo: per-processor model fields"); - put("instruction_sets", cpu.state == ReadState::Available ? parseCpuInstructionSets(cpu.value) : cpu, - "/proc/cpuinfo: per-processor flags, Features or isa; kernel-exposed instruction sets and CPU features"); - gpuResizableBar(result); - result.sources["gpu_rebar"] = "/sys/bus/pci/devices: display-controller class, resourceN_resize supported-size bitmaps, resource allocation metadata; BIOS setting is not exposed"; - cpuTopology(result); - installedMemory(result); - result.sources["physical_memory"] = "libudev: dmi/id MEMORY_ARRAY_NUM_DEVICES and MEMORY_DEVICE_n_SIZE/PRESENT; firmware-reported installed RAM"; - result.sources["memory_devices"] = "libudev: dmi/id cached MEMORY_ARRAY_NUM_DEVICES and MEMORY_DEVICE_n_* properties"; - const Attribute memory = readText("/proc/meminfo", 65536); - Attribute total = memory.state == ReadState::Available ? Attribute() : memory; - for (const QString &line : memory.value.split('\n')) { - if (!line.startsWith("MemTotal:")) continue; - const QStringList parts = line.mid(9).simplified().split(' '); - bool ok = false; - const qulonglong kib = parts.value(0).toULongLong(&ok); - total = ok && kib && parts.size() == 2 && parts.at(1) == "kB" - && kib <= std::numeric_limits::max() / 1024 - ? available(QString::number(kib * 1024)) : failed(EINVAL); - break; - } - put("memory", total, "/proc/meminfo: MemTotal; usable physical RAM, not installed DIMM capacity"); - const Attribute uptime = readText("/proc/uptime"); - bool valid = false; - const double seconds = uptime.value.section(' ', 0, 0).toDouble(&valid); - put("uptime", uptime.state != ReadState::Available ? uptime - : valid && std::isfinite(seconds) && seconds >= 0 && seconds < 1e12 - ? available(QString::number(static_cast(seconds))) : failed(EINVAL), "/proc/uptime"); - for (const auto &field : {qMakePair("manufacturer", "sys_vendor"), qMakePair("model", "product_name"), - qMakePair("board_vendor", "board_vendor"), qMakePair("board_model", "board_name"), - qMakePair("firmware", "bios_version"), qMakePair("firmware_date", "bios_date")}) - put(QLatin1String(field.first), readText("/sys/class/dmi/id/" + QString::fromLatin1(field.second)), - "/sys/class/dmi/id/" + QString::fromLatin1(field.second)); - struct stat efi {}; - const int status = stat("/sys/firmware/efi", &efi); - put("boot", status == 0 ? S_ISDIR(efi.st_mode) ? available("uefi") : failed(ENOTDIR) : errno == ENOENT ? available("not-exposed") : failed(errno), "/sys/firmware/efi directory presence"); - const SecureBootInformation secureBoot = collectSecureBootInformation(); - put("secure_boot", secureBoot.state, secureBoot.stateSource); - result.mok = secureBoot.mok; - result.sources["mok"] = secureBoot.mokSource; - Attribute hypervisor = readText("/sys/hypervisor/type"); - QString hypervisorSource = "/sys/hypervisor/type"; -#if defined(__i386__) || defined(__x86_64__) - unsigned int a, b, c, d; - if (hypervisor.state != ReadState::Available && __get_cpuid(1, &a, &b, &c, &d) && (c & (1u << 31))) { - __cpuid(0x40000000, a, b, c, d); - if (a >= 0x40000000 && a < 0x40010000) { - char vendor[13] {}; - memcpy(vendor, &b, 4); memcpy(vendor + 4, &c, 4); memcpy(vendor + 8, &d, 4); - const QString name = QString::fromLatin1(vendor, 12).remove(QChar('\0')).trimmed(); - hypervisor = name.isEmpty() ? Attribute() : available(name); - hypervisorSource = "CPUID hypervisor-present bit and vendor leaf 0x40000000"; - } - } -#endif - put("virtualization", hypervisor, hypervisorSource); - return result; -} -SystemProperties SystemPropertiesReader::takeResult() -{ - Q_ASSERT(!isRunning()); - return std::move(result_); -} diff --git a/src/devices/systemproperties.h b/src/devices/systemproperties.h deleted file mode 100644 index 6e3544c..0000000 --- a/src/devices/systemproperties.h +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once -#include "device.h" -#include "secureboot.h" -#include -#include - -struct FirmwareMemoryDevice { - int index = 0; // Firmware record index, not a socket/channel identifier. - QHash values; // Stable udev property suffixes. -}; -struct FirmwareMemoryInformation { - Attribute status; - QVector devices; -}; - -struct GpuResizableBar { - QString pciAddress; - Attribute status; // Available: sysfs exposes capability; Unavailable: not exposed. - QHash supportedSizes; // Raw validated hexadecimal bitmaps. - QHash currentSizes; // Allocated aperture size in bytes. -}; - -struct SystemProperties { - Attribute gpuRebarStatus; - QVector gpuRebar; - FirmwareMemoryInformation memoryDevices; - MokCertificates mok; - QHash values; - QHash sources; -}; - -// Pure parsers shared with fixtures; no commands or shell evaluation. -Attribute parseCpuInstructionSets(const QString &cpuinfo); -Attribute parseResizableBarSizes(const QString &text); -Attribute parseResizableBarAllocation(const QString &resources, int bar); -Attribute parseOsName(const QString &text); -FirmwareMemoryInformation parseMemoryDevices(const QHash &properties); -Attribute parseInstalledMemory(const QHash &properties); -bool parseCpuList(const QString &text, QSet &cpus); -SystemProperties collectSystemProperties(); - -class SystemPropertiesReader final : public QThread { -public: - using QThread::QThread; - SystemProperties takeResult(); // After finished and wait. -protected: - void run() override - { - Q_ASSERT(isCurrentThread()); - result_ = collectSystemProperties(); - } -private: - SystemProperties result_; -}; diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index 34f09b1..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,59 +0,0 @@ -#include "mainwindow.h" -#include "cameraproperties.h" -#include -#include -#include -#include -#include "projectmetadata.h" -#include -#include -#include -#include -#include -#include -#include -#include - -int main(int argc, char **argv) -{ - // Private hardware-query child: no GUI, settings, session access or capture. - if (argc == 5 && std::strcmp(argv[1], "--camera-query") == 0) { - char *end = nullptr; - errno = 0; - const long directory = std::strtol(argv[4], &end, 10); - if (errno || end == argv[4] || !end || *end || directory < 0 || directory > INT_MAX) return 1; - return runCameraQueryHelper(static_cast(directory), QString::fromLocal8Bit(argv[2]), QString::fromLocal8Bit(argv[3])); - } - // Never write root settings into the sudo caller's XDG directories. - // This does not modify display/session authorization. - if (geteuid() == 0) { - qputenv("XDG_CONFIG_HOME", "/root/.config"); - qputenv("XDG_DATA_HOME", "/root/.local/share"); - qputenv("XDG_CACHE_HOME", "/root/.cache"); - } - QApplication app(argc, argv); - QCoreApplication::setOrganizationName(QLDM_NAME); - QCoreApplication::setApplicationName(QLDM_NAME); - QCoreApplication::setApplicationVersion(QLDM_VERSION); - app.setDesktopFileName(QStringLiteral("io.github.birdie-github.QLinuxDeviceManager")); - QIcon applicationIcon; - for (const int size : {16, 32, 48, 64, 128, 256, 512}) - applicationIcon.addFile(QStringLiteral(":/icons/%1x%1/QLinuxDeviceManager.png").arg(size)); - app.setWindowIcon(applicationIcon); - QTranslator qtTranslator; - if (qtTranslator.load(QLocale(), "qtbase", "_", QLibraryInfo::path(QLibraryInfo::TranslationsPath))) - app.installTranslator(&qtTranslator); - QTranslator translator; - QStringList locations = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation); - locations.prepend(QDir(QCoreApplication::applicationDirPath()).absoluteFilePath("../share")); - for (const auto &location : locations) { - if (translator.load(QLocale(), "qlinuxdevicemanager", "_", - location + "/QLinuxDeviceManager/translations")) { - app.installTranslator(&translator); - break; - } - } - MainWindow window; - window.show(); - return app.exec(); -} diff --git a/src/ui/devicefilter.cpp b/src/ui/devicefilter.cpp deleted file mode 100644 index b3c1ad0..0000000 --- a/src/ui/devicefilter.cpp +++ /dev/null @@ -1,80 +0,0 @@ -#include "devicefilter.h" -#include "devicemodel.h" -#include - -DeviceFilter::DeviceFilter(QObject *parent) : QSortFilterProxyModel(parent) -{ - setRecursiveFilteringEnabled(true); -} -void DeviceFilter::setQuery(const QString &query, bool deep) -{ -#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) - beginFilterChange(); -#endif - query_ = query; - deep_ = deep; - matches_.clear(); -#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) - endFilterChange(Direction::Rows); -#else - invalidateFilter(); -#endif -} -void DeviceFilter::addMatches(const QVector &matches) -{ -#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) - beginFilterChange(); -#endif - for (const SearchMatch &match : matches) matches_.insert(match.path, match); -#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) - endFilterChange(Direction::Rows); -#else - invalidateFilter(); -#endif -} -bool DeviceFilter::filterAcceptsRow(int row, const QModelIndex &parent) const -{ - if (!active()) return true; - const QModelIndex index = sourceModel()->index(row, 0, parent); - if (index.data(DeviceModel::NodeKeyRole).toString() == "computer") return true; - if (index.data(DeviceModel::ResourceRole).toBool() - && index.data(Qt::DisplayRole).toString().contains(query_, Qt::CaseInsensitive)) return true; - const QString path = index.data(DeviceModel::PathRole).toString(); - if (path.isEmpty()) { - // Keep driver/module/resource children when their reporting device matches. - for (QModelIndex owner = parent; owner.isValid(); owner = owner.parent()) { - const QString ownerPath = owner.data(DeviceModel::PathRole).toString(); - if (ownerPath.isEmpty()) continue; - if (owner.data(Qt::DisplayRole).toString().contains(query_, Qt::CaseInsensitive)) return true; - const auto match = matches_.constFind(ownerPath); - return deep_ && match != matches_.cend() - && match->generation == owner.data(DeviceModel::GenerationRole).toULongLong(); - } - return false; // Recursive filtering retains matching ancestor groups. - } - if (index.data(Qt::DisplayRole).toString().contains(query_, Qt::CaseInsensitive)) return true; - const auto match = matches_.constFind(path); - return deep_ && match != matches_.cend() - && match->generation == index.data(DeviceModel::GenerationRole).toULongLong(); -} -int DeviceFilter::visibleCount() const -{ - QSet paths; - std::function visit = [&](const QModelIndex &parent) { - for (int row = 0; row < rowCount(parent); ++row) { - const QModelIndex child = index(row, 0, parent); - const QString path = child.data(DeviceModel::PathRole).toString(); - if (!path.isEmpty()) paths.insert(path); - visit(child); - } - }; - visit({}); - return static_cast(paths.size()); -} -QString DeviceFilter::matchReason(const QModelIndex &index) const -{ - if (!active() || !deep_ || index.data(Qt::DisplayRole).toString().contains(query_, Qt::CaseInsensitive)) return {}; - const auto match = matches_.constFind(index.data(DeviceModel::PathRole).toString()); - return match != matches_.cend() && match->generation == index.data(DeviceModel::GenerationRole).toULongLong() - ? match->field : QString(); -} diff --git a/src/ui/devicefilter.h b/src/ui/devicefilter.h deleted file mode 100644 index 17f98e5..0000000 --- a/src/ui/devicefilter.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once -#include "devicesearch.h" -#include - -class DeviceFilter final : public QSortFilterProxyModel { -public: - explicit DeviceFilter(QObject *parent = nullptr); - void setQuery(const QString &query, bool deep); - void addMatches(const QVector &matches); - bool active() const { return !query_.isEmpty(); } - int visibleCount() const; - QString matchReason(const QModelIndex &index) const; -protected: - bool filterAcceptsRow(int row, const QModelIndex &parent) const override; -private: - QString query_; - bool deep_ = false; - QHash matches_; -}; diff --git a/src/ui/devicemodel.cpp b/src/ui/devicemodel.cpp deleted file mode 100644 index 15fc08e..0000000 --- a/src/ui/devicemodel.cpp +++ /dev/null @@ -1,500 +0,0 @@ -#include "devicemodel.h" -#include "devicelabel.h" -#include "resourceformat.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -bool sameMetadata(const Device &a, const Device &b) -{ - if (a.path != b.path || a.parentPath != b.parentPath || a.subsystem != b.subsystem - || a.sysname != b.sysname || a.devtype != b.devtype || a.driver != b.driver - || a.driverModule != b.driverModule || a.properties != b.properties - || a.name != b.name || a.nameSource != b.nameSource || a.nameTranslated != b.nameTranslated - || a.nameCandidate != b.nameCandidate || a.representedByPath != b.representedByPath - || a.propertySources != b.propertySources || a.category != b.category - || a.eventInstance != b.eventInstance || a.incarnation != b.incarnation || a.hidden != b.hidden || a.attributes.size() != b.attributes.size() - || !sameDeviceResources(a.resources, b.resources)) - return false; - for (auto it = a.attributes.cbegin(); it != a.attributes.cend(); ++it) { - const auto other = b.attributes.constFind(it.key()); - if (other == b.attributes.cend() || it->state != other->state - || it->value != other->value || it->error != other->error) return false; - } - return true; -} -} -DeviceModel::DeviceModel(QObject *parent) : QAbstractItemModel(parent) {} -DeviceModel::Node *DeviceModel::node(const QModelIndex &i) const -{ - return i.isValid() ? static_cast(i.internalPointer()) : const_cast(&root_); -} -QModelIndex DeviceModel::index(int row, int column, const QModelIndex &p) const -{ - if (column != 0 || row < 0 || (p.isValid() && p.column() != 0)) return {}; - Node *n = node(p); - if (row >= static_cast(n->children.size())) return {}; - return createIndex(row, column, n->children[static_cast(row)].get()); -} -QModelIndex DeviceModel::parent(const QModelIndex &child) const -{ - if (!child.isValid()) return {}; - Node *p = node(child)->parent; - return !p || p == &root_ ? QModelIndex() : createIndex(p->row, 0, p); -} -int DeviceModel::rowCount(const QModelIndex &p) const -{ - return p.isValid() && p.column() != 0 ? 0 : static_cast(node(p)->children.size()); -} -QVariant DeviceModel::headerData(int section, Qt::Orientation orientation, int role) const -{ - if (section == 0 && orientation == Qt::Horizontal && role == Qt::DisplayRole) - return viewLabel(view_); - return {}; -} -QVariant DeviceModel::data(const QModelIndex &i, int role) const -{ - if (!i.isValid()) return {}; - const Node *n = node(i); - const auto found = lookup_.constFind(n->path.isEmpty() ? n->ownerPath : n->path); - const Device *d = found == lookup_.cend() ? nullptr : &devices_.at(found.value()); - if (d && d->generation != n->generation) d = nullptr; - if (role == NodeKeyRole) return n->key; - if (role == CategoryRole) return n->category; - if (role == ResourceRole) return !n->ownerPath.isEmpty(); - if (role == OwnerPathRole) return d && !n->ownerPath.isEmpty() ? QVariant(d->path) : QVariant(); - if (role == OwnerGenerationRole) return d && !n->ownerPath.isEmpty() ? QVariant::fromValue(d->generation) : QVariant(); - if (role == PathRole) return d && !n->path.isEmpty() ? QVariant(d->path) : QVariant(); - if (role == GenerationRole) return d && !n->path.isEmpty() ? QVariant::fromValue(d->generation) : QVariant(); - if (role == Qt::DisplayRole) return n->label; - if (role == Qt::DecorationRole && n->key == "computer") - return QIcon::fromTheme("computer", QApplication::style()->standardIcon(QStyle::SP_ComputerIcon)); - if (role == Qt::DecorationRole) - return QIcon::fromTheme(categoryIcon(n->category), QApplication::style()->standardIcon( - d ? QStyle::SP_ComputerIcon : QStyle::SP_DirIcon)); - if (role == Qt::ToolTipRole) { - if (!n->description.isEmpty()) - return QStringLiteral("%1").arg(n->description.toHtmlEscaped().replace('\n', "
")); - if (!d) return n->label.toHtmlEscaped(); - // Escaping prevents device-controlled strings being interpreted as markup. - QString tooltip = QStringLiteral("%1\n%2\n%3: %4\n%5: %6") - .arg(n->label, d->subsystem == "efivarfs" && !showInternal_ ? d->parentPath : d->path, - tr("Subsystem"), d->subsystem, tr("Raw name source"), d->nameSource); - tooltip += QStringLiteral("\n%1: %2\n%3: %4") - .arg(tr("Raw name"), d->name, tr("Kernel name"), - d->subsystem == "efivarfs" && !showInternal_ ? d->name : d->sysname); - if (d->subsystem == "power_supply") { - for (const char *key : {"type", "manufacturer", "model_name"}) { - const auto attribute = d->attributes.value(QLatin1String(key)); - if (attribute.state == ReadState::Available && !attribute.value.isEmpty()) - tooltip += QStringLiteral("\n%1: %2").arg(QLatin1String(key), attribute.value); - } - } - if (n->context) tooltip += QStringLiteral("\n") + tr("Ancestor retained for connection context"); - if (!d->representedByPath.isEmpty()) - tooltip += QStringLiteral("\n%1: %2").arg(tr("Represented by"), d->representedByPath); - QStringList grouped; - for (const auto &record : devices_) - if (record.representedByPath == d->path) grouped.append(record.path); - if (!grouped.isEmpty()) - tooltip += QStringLiteral("\n%1:\n%2").arg(tr("Grouped records"), grouped.join('\n')); - return QStringLiteral("%1").arg(tooltip.toHtmlEscaped().replace('\n', "
")); - } - return {}; -} -bool DeviceModel::setInventory(QVector devices, const QSet &removedPaths, - bool identityLost) -{ - QVector previous; - if (!identityLost) { - for (const Device &d : devices_) { - bool removed = false; - QString path = d.path; - while (!path.isEmpty()) { - if (removedPaths.contains(path)) { removed = true; break; } - const int slash = path.lastIndexOf('/'); - if (slash < 0) break; - path.truncate(slash); - } - if (!removed) previous.append(d); - } - } - reconcile(devices, previous, generation_); - QSet changedPaths; - for (const Device &d : devices) { - const auto old = lookup_.constFind(d.path); - if (old == lookup_.cend() || d.generation != devices_.at(old.value()).generation - || !sameMetadata(d, devices_.at(old.value()))) changedPaths.insert(d.path); - } - // Grouped-record tooltips also change when a member disappears or moves. - QHash representatives; - for (const Device &d : devices) representatives.insert(d.path, d.representedByPath); - for (const Device &d : devices_) { - if (!representatives.contains(d.path) || representatives.value(d.path) != d.representedByPath) { - changedPaths.insert(d.path); - if (!d.representedByPath.isEmpty()) changedPaths.insert(d.representedByPath); - } - } - for (const Device &d : devices) - if (changedPaths.contains(d.path) && !d.representedByPath.isEmpty()) changedPaths.insert(d.representedByPath); - const QString hostname = QSysInfo::machineHostName(); - const QString computerLabel = hostname.isEmpty() ? tr("This computer") : hostname; - if (changedPaths.isEmpty() && !root_.children.empty() && root_.children.front()->label == computerLabel) - return false; - // Build a temporary projection, then update only changed branches. Existing - // Node addresses (and persistent indexes) survive unchanged inventory entries. - auto existing = std::move(root_.children); - devices_ = std::move(devices); - rebuild(); - Node desired; - desired.children = std::move(root_.children); - root_.children = std::move(existing); - synchronize(&root_, &desired, changedPaths); - return true; -} -void DeviceModel::synchronize(Node *parentNode, Node *desired, const QSet &changedPaths) -{ - const QModelIndex parentIndex = parentNode == &root_ ? QModelIndex() - : createIndex(parentNode->row, 0, parentNode); - const auto same = [](const Node &a, const Node &b) { - return a.key == b.key && a.generation == b.generation; - }; - const auto rows = [](Node *parent) { - for (size_t i = 0; i < parent->children.size(); ++i) { - parent->children[i]->parent = parent; - parent->children[i]->row = static_cast(i); - } - }; - for (int i = static_cast(parentNode->children.size()) - 1; i >= 0; --i) { - const Node &old = *parentNode->children[static_cast(i)]; - const bool retained = std::any_of(desired->children.begin(), desired->children.end(), - [&](const auto &next) { return same(old, *next); }); - if (retained) continue; - beginRemoveRows(parentIndex, i, i); - parentNode->children.erase(parentNode->children.begin() + i); - rows(parentNode); - endRemoveRows(); - } - for (size_t i = 0; i < desired->children.size(); ++i) { - auto &next = desired->children[i]; - size_t found = i; - while (found < parentNode->children.size() && !same(*parentNode->children[found], *next)) ++found; - if (found == parentNode->children.size()) { - beginInsertRows(parentIndex, static_cast(i), static_cast(i)); - parentNode->children.insert(parentNode->children.begin() + static_cast(i), std::move(next)); - // Children of the inserted subtree already point to their owned parents. - rows(parentNode); - endInsertRows(); - continue; - } - if (found != i) { - beginMoveRows(parentIndex, static_cast(found), static_cast(found), parentIndex, static_cast(i)); - auto moving = std::move(parentNode->children[found]); - parentNode->children.erase(parentNode->children.begin() + static_cast(found)); - parentNode->children.insert(parentNode->children.begin() + static_cast(i), std::move(moving)); - rows(parentNode); - endMoveRows(); - } - Node *current = parentNode->children[i].get(); - const bool changed = current->label != next->label || current->category != next->category - || current->context != next->context || current->description != next->description - || changedPaths.contains(current->path); - current->label = next->label; - current->category = next->category; - current->context = next->context; - current->description = next->description; - current->ownerPath = next->ownerPath; - // Metadata, grouped tooltips and filter matches may change without a label change. - if (changed) { - const QModelIndex index = createIndex(current->row, 0, current); - emit dataChanged(index, index); - } - synchronize(current, next.get(), changedPaths); - } -} -void DeviceModel::setShowInternal(bool show) -{ - if (show == showInternal_) return; - beginResetModel(); - showInternal_ = show; - rebuild(); - endResetModel(); -} -void DeviceModel::rebuild() -{ - root_.children.clear(); - lookup_.clear(); - visible_ = 0; - for (int i = 0; i < devices_.size(); ++i) lookup_.insert(devices_.at(i).path, i); - QVector members; - for (const Device &d : devices_) members.append(&d); - std::sort(members.begin(), members.end(), [](const Device *a, const Device *b) { - return a->path < b->path; - }); - QHash labels; - QHash counts; - for (const Device *d : members) { - const QString label = deviceDisplayName(*d, showInternal_); - labels.insert(d->path, label); - if (showInternal_ || !d->hidden) ++counts[d->category + ":" + label]; - } - QHash namespaceNumbers; - for (const Device *d : members) { - const QString label = labels.value(d->path); - if (counts.value(d->category + ":" + label) <= 1) continue; - if (d->subsystem == "efivarfs" && !showInternal_) - labels[d->path] = tr("%1 (namespace %2)").arg(label).arg(++namespaceNumbers[label]); - else labels[d->path] = deviceDisplayName(*d, true); - } - std::sort(members.begin(), members.end(), [&labels](const Device *a, const Device *b) { - const int cmp = QString::compare(labels.value(a->path), labels.value(b->path), Qt::CaseInsensitive); - return cmp == 0 ? a->path < b->path : cmp < 0; - }); - const auto add = [](Node *parent, const QString &key, const QString &label, - const QString &category, const QString &path = QString()) { - auto item = std::make_unique(); - item->parent = parent; - item->row = static_cast(parent->children.size()); - item->key = key; - item->label = label; - item->category = category; - item->path = path; - Node *result = item.get(); - parent->children.push_back(std::move(item)); - return result; - }; - const QString hostname = QSysInfo::machineHostName(); - Node *computer = add(&root_, "computer", hostname.isEmpty() ? tr("This computer") : hostname, {}); - const auto deviceNode = [&](Node *parent, const Device &d) { - const QString suffix = view_ == View::ResourcesByType ? ':' + parent->key : QString(); - Node *item = add(parent, "device:" + d.path + suffix, labels.value(d.path), d.category, d.path); - item->generation = d.generation; - return item; - }; - const auto driverKey = [](const Device &d) { - return d.driver.isEmpty() ? QStringLiteral("unbound") - : d.subsystem + ':' + d.driver; - }; - const auto driverLabel = [](const Device &d) { - return d.driver.isEmpty() ? tr("No directly bound driver") - : tr("%1: %2 (driver)").arg(d.subsystem, d.driver); - }; - QHash groups; - const auto group = [&](Node *parent, const QString &key, const QString &label, - const QString &category) { - if (groups.contains(key)) return groups.value(key); - Node *result = add(parent, key, label, category); - groups.insert(key, result); - return result; - }; - const bool resourcesByType = view_ == View::ResourcesByType; - const bool resourcesByConnection = view_ == View::ResourcesByConnection; - const auto hasResources = [](const Device &d) { return d.resources && d.resources->hasInformation(); }; - const auto resourceNode = [&](Node *parent, const Device &d, const ResourceRow &row) { - QString label = tr("%1: %2").arg(row.type, row.setting); - if (!row.details.isEmpty()) label += QStringLiteral(" — ") + row.details; - Node *item = add(parent, parent->key + "/resource:" + row.key, label, d.category); - item->ownerPath = d.path; - item->generation = d.generation; - item->description = label + '\n' + tr("Device: %1").arg(labels.value(d.path)) - + '\n' + tr("Source: %1").arg(row.source); - }; - if (resourcesByType) { - for (const Device *d : members) { - if ((!showInternal_ && d->hidden) || !hasResources(*d)) continue; - QHash deviceGroups; - for (const ResourceRow &row : resourceRows(*d->resources)) { - const QString &type = row.group; - Node *category = group(computer, "resource-type:" + type, row.type, "other"); - Node *item = deviceGroups.value(type, nullptr); - if (!item) { item = deviceNode(category, *d); deviceGroups.insert(type, item); } - resourceNode(item, *d, row); - } - } - } else if (view_ == View::Connection || resourcesByConnection) { - // Only real inventory parent links; hidden ancestors provide context. - QSet included; - for (const Device *d : members) { - if ((!showInternal_ && d->hidden) || (resourcesByConnection && !hasResources(*d))) continue; - QString path = d->path; - QSet chain; - while (lookup_.contains(path) && !chain.contains(path)) { - chain.insert(path); - included.insert(path); - path = devices_.at(lookup_.value(path)).parentPath; - } - } - QHash nodes; - QSet building; - std::function ensure = [&](const Device &d) -> Node * { - if (nodes.contains(d.path)) return nodes.value(d.path); - building.insert(d.path); - Node *parent = computer; - if (included.contains(d.parentPath) && !building.contains(d.parentPath)) - parent = ensure(devices_.at(lookup_.value(d.parentPath))); - Node *item = deviceNode(parent, d); - item->context = !showInternal_ && d.hidden; - nodes.insert(d.path, item); - building.remove(d.path); - return item; - }; - for (const Device *d : members) if (included.contains(d->path)) { - Node *item = ensure(*d); - if (resourcesByConnection && (showInternal_ || !d->hidden) && hasResources(*d)) - for (const ResourceRow &row : resourceRows(*d->resources)) resourceNode(item, *d, row); - } - } else { - for (const Device *d : members) { - if (!showInternal_ && d->hidden) continue; - Node *parent = computer; - if (view_ == View::Type || view_ == View::DriversByType) - parent = group(parent, "category:" + d->category, categoryLabel(d->category), d->category); - if (view_ == View::DevicesByDriver || view_ == View::DriversByType) { - const QString prefix = view_ == View::DriversByType ? d->category + ':' : QString(); - parent = group(parent, "driver:" + prefix + driverKey(*d), driverLabel(*d), d->category); - } - Node *item = deviceNode(parent, *d); - if (view_ != View::DriversByDevice) continue; - Node *driver = add(item, item->key + "/driver", driverLabel(*d), d->category); - if (!d->driver.isEmpty()) - add(driver, item->key + "/module", d->driverModule.isEmpty() - ? tr("Owning module / built-in status undetermined") - : tr("%1 (kernel module)").arg(d->driverModule), d->category); - // Explicitly separate ancestor binding from the selected device's binding. - QString path = d->parentPath; - QSet visited{d->path}; - while (lookup_.contains(path) && !visited.contains(path)) { - visited.insert(path); - const Device &ancestor = devices_.at(lookup_.value(path)); - if (!ancestor.driver.isEmpty()) { - Node *relation = add(item, item->key + "/parent-driver", - tr("Parent-device driver: %1 — %2").arg(driverLabel(ancestor), labels.value(path)), - ancestor.category); - if (!ancestor.driverModule.isEmpty()) - add(relation, item->key + "/parent-module", - tr("%1 (kernel module)").arg(ancestor.driverModule), ancestor.category); - break; - } - path = ancestor.parentPath; - } - } - } - // Stable sibling order, including connection ancestors inserted before children. - QSet visiblePaths; - std::function sort = [&](Node *parent) { - std::sort(parent->children.begin(), parent->children.end(), [](const auto &a, const auto &b) { - const int cmp = QString::compare(a->label, b->label, Qt::CaseInsensitive); - return cmp == 0 ? a->key < b->key : cmp < 0; - }); - for (size_t row = 0; row < parent->children.size(); ++row) { - Node *child = parent->children[row].get(); - child->row = static_cast(row); - if (!child->path.isEmpty()) visiblePaths.insert(child->path); - sort(child); - } - }; - sort(&root_); - visible_ = static_cast(visiblePaths.size()); -} -QModelIndex DeviceModel::findDevice(const QString &path, quint64 generation) const -{ - const auto it = lookup_.constFind(path); - if (it == lookup_.cend() || devices_.at(it.value()).generation != generation) return {}; - std::function find = [&](const Node *parent) -> QModelIndex { - for (const auto &child : parent->children) { - if (child->path == path) return createIndex(child->row, 0, child.get()); - const QModelIndex found = find(child.get()); - if (found.isValid()) return found; - } - return {}; - }; - return find(&root_); -} - -QModelIndex DeviceModel::findNode(const QString &key, const QString &path, quint64 generation) const -{ - const auto record = lookup_.constFind(path); - if (record == lookup_.cend() || devices_.at(record.value()).generation != generation) return {}; - std::function find = [&](const Node *parent) -> QModelIndex { - for (const auto &child : parent->children) { - if (child->key == key && child->generation == generation - && (child->path == path || child->ownerPath == path)) - return createIndex(child->row, 0, child.get()); - const QModelIndex result = find(child.get()); - if (result.isValid()) return result; - } - return {}; - }; - return find(&root_); -} - -std::optional DeviceModel::device(const QString &path, quint64 generation) const -{ - const auto it = lookup_.constFind(path); - if (it == lookup_.cend() || devices_.at(it.value()).generation != generation) return std::nullopt; - return devices_.at(it.value()); // Owned copy; callers never retain inventory pointers. -} - -QVector DeviceModel::searchRecords() const -{ - QVector records; - records.reserve(visible_); - QSet seen; - QHash grouped; - for (const Device &d : devices_) - if (!d.representedByPath.isEmpty()) grouped[d.representedByPath].append(d.path); - std::function collect = [&](const Node *parent) { - for (const auto &child : parent->children) { - if (!child->path.isEmpty() && !seen.contains(child->path)) { - seen.insert(child->path); - const Device &d = devices_.at(lookup_.value(child->path)); - records.append({d, child->label, grouped.value(d.path).join('\n')}); - } - collect(child.get()); - } - }; - collect(&root_); - return records; -} - -QString DeviceModel::viewId(View view) -{ - switch (view) { - case View::Type: return "type"; - case View::Connection: return "connection"; - case View::DevicesByDriver: return "devicesByDriver"; - case View::DriversByDevice: return "driversByDevice"; - case View::DriversByType: return "driversByType"; - case View::ResourcesByType: return "resourcesByType"; - case View::ResourcesByConnection: return "resourcesByConnection"; - } - return "type"; -} -QString DeviceModel::viewLabel(View view) -{ - switch (view) { - case View::Type: return tr("Devices by type"); - case View::Connection: return tr("Devices by connection"); - case View::DevicesByDriver: return tr("Devices by driver"); - case View::DriversByDevice: return tr("Drivers by device"); - case View::DriversByType: return tr("Drivers by type"); - case View::ResourcesByType: return tr("Resources by type"); - case View::ResourcesByConnection: return tr("Resources by connection"); - } - return {}; -} -void DeviceModel::setView(View view) -{ - if (view_ == view) return; - beginResetModel(); - view_ = view; - rebuild(); - endResetModel(); -} diff --git a/src/ui/devicemodel.h b/src/ui/devicemodel.h deleted file mode 100644 index 554ca8f..0000000 --- a/src/ui/devicemodel.h +++ /dev/null @@ -1,60 +0,0 @@ -#pragma once -#include "device.h" -#include "devicesearch.h" -#include -#include -#include -#include - -class DeviceModel final : public QAbstractItemModel { - Q_OBJECT -public: - enum { PathRole = Qt::UserRole + 1, GenerationRole, CategoryRole, NodeKeyRole, - ResourceRole, OwnerPathRole, OwnerGenerationRole }; - enum class View { Type, Connection, DevicesByDriver, DriversByDevice, DriversByType, - ResourcesByType, ResourcesByConnection }; - View view() const { return view_; } - void setView(View view); - static QString viewId(View view); - static QString viewLabel(View view); - explicit DeviceModel(QObject *parent = nullptr); - QModelIndex index(int row, int column, const QModelIndex &parent = {}) const override; - QModelIndex parent(const QModelIndex &child) const override; - int rowCount(const QModelIndex &parent = {}) const override; - int columnCount(const QModelIndex & = {}) const override { return 1; } - QVariant data(const QModelIndex &index, int role) const override; - QVariant headerData(int section, Qt::Orientation orientation, int role) const override; - bool setInventory(QVector devices, const QSet &removedPaths = {}, - bool identityLost = false); - std::optional device(const QString &path, quint64 generation) const; - void setShowInternal(bool show); - QModelIndex findDevice(const QString &path, quint64 generation) const; - QModelIndex findNode(const QString &key, const QString &path, quint64 generation) const; - QVector searchRecords() const; - int visibleCount() const { return visible_; } - int inventoryCount() const { return static_cast(devices_.size()); } -private: - struct Node { - Node *parent = nullptr; - int row = 0; - QString category; - QString key; - bool context = false; - quint64 generation = 0; // Projection identity; replacements are removed/inserted. - QString path; // Record lookup ID, never a pointer into inventory storage. - QString label; // GUI-translated display text, rebuilt with the inventory. - QString ownerPath; // Resource rows target this device, but are not device records. - QString description; // Plain resource metadata/provenance for tooltips. - std::vector> children; - }; - Node *node(const QModelIndex &index) const; - void rebuild(); - void synchronize(Node *parent, Node *desired, const QSet &changedPaths); - QVector devices_; - QHash lookup_; - Node root_; - bool showInternal_ = false; - View view_ = View::Type; - quint64 generation_ = 0; - int visible_ = 0; -}; diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp deleted file mode 100644 index 9eee0ae..0000000 --- a/src/ui/mainwindow.cpp +++ /dev/null @@ -1,560 +0,0 @@ -#include "mainwindow.h" -#include "devicemodel.h" -#include "devicefilter.h" -#include "propertiesdialog.h" -#include "systemdialog.h" -#include "projectmetadata.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -QModelIndex propertyTarget(QModelIndex index) -{ - if (index.data(DeviceModel::ResourceRole).toBool()) index = index.parent(); - return index; -} -} -MainWindow::MainWindow() -{ - setWindowTitle(tr("QLinuxDeviceManager")); - if (geteuid() == 0) setWindowTitle(windowTitle() + tr(" — Administrative mode")); - resize(850, 650); - model_ = new DeviceModel(this); - tree_ = new QTreeView(this); - filter_ = new DeviceFilter(this); - filter_->setSourceModel(model_); - tree_->setModel(filter_); - tree_->setUniformRowHeights(true); - tree_->setSelectionMode(QAbstractItemView::SingleSelection); - tree_->setEditTriggers(QAbstractItemView::NoEditTriggers); - tree_->setTextElideMode(Qt::ElideMiddle); - auto *central = new QWidget(this); - auto *layout = new QVBoxLayout(central); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - filterBar_ = new QWidget(central); - auto *filterLayout = new QHBoxLayout(filterBar_); - filterLayout->setContentsMargins(6, 6, 6, 6); - auto *filterLabel = new QLabel(tr("&Filter:"), filterBar_); - filterEdit_ = new QLineEdit(filterBar_); - filterLabel->setBuddy(filterEdit_); - filterEdit_->setClearButtonEnabled(true); - filterEdit_->setPlaceholderText(tr("Device name")); - deepSearch_ = new QCheckBox(tr("Deep search"), filterBar_); - deepSearch_->setToolTip(tr("Search device identifiers, generic properties, resources and native storage metadata. " - "Specialized hardware-tab information is not fully indexed. Refresh updates cached metadata.")); - filterLayout->addWidget(filterLabel); - filterLayout->addWidget(filterEdit_, 1); - filterLayout->addWidget(deepSearch_); - layout->addWidget(filterBar_); - layout->addWidget(tree_, 1); - filterBar_->hide(); - setCentralWidget(central); - for (QObject *target : {static_cast(filterEdit_), static_cast(deepSearch_), - static_cast(tree_), static_cast(tree_->viewport())}) - target->installEventFilter(this); - filterTimer_.setSingleShot(true); - filterTimer_.setInterval(150); - connect(&filterTimer_, &QTimer::timeout, this, &MainWindow::applyFilter); - connect(filterEdit_, &QLineEdit::textChanged, this, [this] { - cancelSearch(); - filterTimer_.start(); - }); - connect(deepSearch_, &QCheckBox::toggled, this, [this](bool deep) { - filterEdit_->setPlaceholderText(deep ? tr("All device metadata") : tr("Device name")); - applyFilter(); - }); - qRegisterMetaType(); - connect(&searchWorker_, &DeepSearchWorker::batchReady, this, &MainWindow::acceptSearchBatch, Qt::QueuedConnection); - connect(&searchWorker_, &QThread::finished, this, &MainWindow::searchFinished, Qt::QueuedConnection); - - auto *file = menuBar()->addMenu(tr("&File")); - auto *systemInfo = file->addAction(QIcon::fromTheme("dialog-information", - style()->standardIcon(QStyle::SP_MessageBoxInformation)), tr("System Information")); - systemInfo->setToolTip(tr("Show System Information")); - systemInfo->setShortcut(QKeySequence(Qt::ALT | Qt::Key_I)); - connect(systemInfo, &QAction::triggered, this, &MainWindow::openSystemInformation); - file->addSeparator(); - auto *quit = file->addAction(tr("&Quit")); - quit->setShortcut(QKeySequence::Quit); - connect(quit, &QAction::triggered, this, &QWidget::close); - auto *action = menuBar()->addMenu(tr("&Action")); - refreshAction_ = action->addAction(QIcon::fromTheme("view-refresh", - style()->standardIcon(QStyle::SP_BrowserReload)), tr("&Refresh")); - refreshAction_->setShortcut(QKeySequence(Qt::Key_F5)); - refreshAction_->setToolTip(tr("Re-enumerate existing kernel devices; does not rescan buses.")); - connect(refreshAction_, &QAction::triggered, this, &MainWindow::refresh); - propertiesAction_ = action->addAction(QIcon::fromTheme("document-properties", - style()->standardIcon(QStyle::SP_FileDialogInfoView)), tr("&Properties")); - propertiesAction_->setEnabled(false); - connect(propertiesAction_, &QAction::triggered, this, &MainWindow::openProperties); - connect(tree_, &QTreeView::doubleClicked, this, [this](const QModelIndex &index) { - // QTreeView handles branch expansion/collapse; only leaves open properties. - if (filter_->hasChildren(index)) return; - if (index.data(DeviceModel::NodeKeyRole).toString() == "computer" - || !propertyTarget(index).data(DeviceModel::PathRole).toString().isEmpty()) openProperties(); - }); - tree_->setContextMenuPolicy(Qt::CustomContextMenu); - connect(tree_, &QWidget::customContextMenuRequested, this, [this](const QPoint &position) { - const QModelIndex index = tree_->indexAt(position); - if (index.data(DeviceModel::NodeKeyRole).toString() != "computer" - && propertyTarget(index).data(DeviceModel::PathRole).toString().isEmpty()) return; - tree_->setCurrentIndex(index); - QMenu menu(this); - menu.addAction(propertiesAction_); - menu.exec(tree_->viewport()->mapToGlobal(position)); - }); - auto *view = menuBar()->addMenu(tr("&View")); - auto *views = new QActionGroup(this); - for (const auto mode : {DeviceModel::View::Type, DeviceModel::View::Connection, - DeviceModel::View::DevicesByDriver, DeviceModel::View::DriversByDevice, - DeviceModel::View::DriversByType, DeviceModel::View::ResourcesByType, - DeviceModel::View::ResourcesByConnection}) { - expanded_.insert(DeviceModel::viewId(mode) + ":computer"); - auto *choice = view->addAction(DeviceModel::viewLabel(mode)); - choice->setCheckable(true); - views->addAction(choice); - connect(choice, &QAction::triggered, this, [this, mode] { - rememberTree(); - cancelSearch(); - model_->setView(mode); - ++searchRevision_; - restoreTree(); - applyFilter(); - }); - } - for (QAction *choice : views->actions()) - choice->setChecked(choice->text() == DeviceModel::viewLabel(model_->view())); - view->addSeparator(); - filterAction_ = view->addAction(tr("&Filter")); - filterAction_->setCheckable(true); - filterAction_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_F)); - connect(filterAction_, &QAction::toggled, this, [this](bool show) { - filterBar_->setVisible(show); - if (show) { - filterEdit_->setFocus(); - filterEdit_->selectAll(); - } else { - const QSignalBlocker blocker(filterEdit_); - filterEdit_->clear(); - applyFilter(); - tree_->setFocus(); - } - }); - internalAction_ = view->addAction(tr("Show &all devices")); - internalAction_->setCheckable(true); - internalAction_->setToolTip(tr("Include virtual devices, UEFI variables, partitions, disconnected display outputs, interface endpoints and internal kernel objects.")); - connect(internalAction_, &QAction::toggled, this, [this](bool show) { - rememberTree(); - cancelSearch(); - model_->setShowInternal(show); - ++searchRevision_; - restoreTree(); - applyFilter(); - }); - view->addSeparator(); - auto *expand = view->addAction(tr("Expand all")); - auto *collapse = view->addAction(tr("Collapse all")); - connect(expand, &QAction::triggered, tree_, &QTreeView::expandAll); - connect(collapse, &QAction::triggered, tree_, &QTreeView::collapseAll); - auto *help = menuBar()->addMenu(tr("&Help")); - auto *about = help->addAction(tr("&About")); - connect(about, &QAction::triggered, this, [this] { - QMessageBox box(this); - box.setWindowTitle(tr("About QLinuxDeviceManager")); - box.setTextFormat(Qt::PlainText); - box.setIconPixmap(QApplication::windowIcon().pixmap(QSize(96, 96), box.devicePixelRatioF())); - box.setText(QStringLiteral("%1 %2\n").arg(QCoreApplication::applicationName(), QCoreApplication::applicationVersion()) - + tr("A read-only Linux hardware viewer.\n" - "Categories are application groupings, not native Linux device classes.\n" - "Device presence does not establish hardware health.\n" - "Licensed under GNU GPL version 3; see LICENSE.")); - box.exec(); - }); - auto *toolbar = addToolBar(tr("Main toolbar")); - toolbar->setObjectName("mainToolbar"); - toolbar->setIconSize(QSize(20, 20)); - toolbar->addAction(systemInfo); - toolbar->addAction(refreshAction_); - toolbar->addAction(propertiesAction_); - view->addAction(toolbar->toggleViewAction()); - initializationProgress_ = new QProgressBar(this); - initializationProgress_->setRange(0, 0); // The initial scan has no reliable total. - initializationProgress_->setTextVisible(false); - initializationProgress_->setMinimumWidth(100); - initializationProgress_->setMaximumWidth(180); - initializationProgress_->setAccessibleName(tr("Initializing devices")); - statusBar()->addPermanentWidget(initializationProgress_); - statusBar()->showMessage(tr("Initializing…")); - - QSettings settings; - restoreGeometry(settings.value("window/geometry").toByteArray()); - // Start with Devices by type, the default visibility, and only the computer - // expanded. Discard legacy view settings; session updates still restore the tree. - settings.remove("window/state"); - settings.remove("view/tree"); - settings.remove("view/showInternal"); - settings.remove("tree/expanded"); - connect(tree_->selectionModel(), &QItemSelectionModel::currentChanged, this, - [this](const QModelIndex &, const QModelIndex &) { updateStatus(); }); - qRegisterMetaType(); - connect(&worker_, &Enumerator::inventoryReady, this, &MainWindow::acceptInventory, Qt::QueuedConnection); - connect(&worker_, &QThread::finished, this, [this] { if (closing_) close(); }, Qt::QueuedConnection); - connect(&propertiesWorker_, &QThread::finished, this, &MainWindow::acceptProperties, Qt::QueuedConnection); - connect(&systemWorker_, &QThread::finished, this, &MainWindow::acceptSystemInformation, Qt::QueuedConnection); - QTimer::singleShot(0, this, &MainWindow::refresh); -} -MainWindow::~MainWindow() -{ - worker_.requestInterruption(); - propertiesWorker_.requestInterruption(); - searchWorker_.requestInterruption(); - systemWorker_.requestInterruption(); - systemWorker_.wait(); - searchWorker_.wait(); - worker_.wait(); // Fallback for destruction without a normal window close. - propertiesWorker_.wait(); -} -void MainWindow::refresh() -{ - if (closing_) return; - busy_ = true; - requested_ = worker_.requestRefresh(); - updateStatus(); - if (!worker_.isRunning()) worker_.start(); -} -void MainWindow::acceptInventory(const Inventory &inventory) -{ - Q_ASSERT(QThread::isMainThread()); - worker_.acknowledge(); - if (closing_) return; - initializing_ = false; - initializationProgress_->hide(); // A failed initial scan is a completed attempt too. - events_ = inventory.events; - // Update before marking a removed dialog so its final removal observation - // stays in the read-only snapshot. Future instances cannot enter that tab. - if (propertiesDialog_) propertiesDialog_->updateEvents(events_, inventory.monitorNote); - const bool manualRefresh = busy_ && inventory.request >= requested_; - busy_ = inventory.request < requested_; - if (!inventory.error.isEmpty()) { - if (inventory.identityLost || !inventory.removedPaths.isEmpty()) { - model_->setInventory({}, {}, true); - if (propertiesDialog_) propertiesDialog_->reconcileInstance(nullptr); - pendingProperties_.reset(); - ++propertiesRequest_; - propertiesWorker_.requestInterruption(); - } - applyFilter(); - statusBar()->showMessage(inventory.error); - return; - } - rememberTree(); - const bool changed = model_->setInventory(inventory.devices, inventory.removedPaths, inventory.identityLost); - if (changed || manualRefresh) { - cancelSearch(); - ++searchRevision_; - } - if (propertiesDialog_) { - const Device &target = propertiesDialog_->device(); - const auto current = model_->device(target.path, target.generation); - propertiesDialog_->reconcileInstance(current ? &*current : nullptr); - if (propertiesDialog_->isRemoved()) { - pendingProperties_.reset(); - ++propertiesRequest_; - propertiesWorker_.requestInterruption(); - } - } - scanNote_ = inventory.monitorNote; - if (inventory.skipped) { - if (!scanNote_.isEmpty()) scanNote_ += QStringLiteral(" — "); - scanNote_ += tr("%1 records unavailable or removed during enumeration").arg(inventory.skipped); - } - if (changed || manualRefresh) { - restoreTree(); - applyFilter(); - } else { - if (searchPending_ && !searchBusy_ && !busy_) startSearch(); - updateStatus(); - } -} -void MainWindow::rememberTree() -{ - if (filter_->active()) return; // Preserve the unfiltered expansion and selection. - const QString prefix = DeviceModel::viewId(model_->view()) + ':'; - std::function visit = [&](const QModelIndex &parent) { - for (int row = 0; row < model_->rowCount(parent); ++row) { - const QModelIndex i = model_->index(row, 0, parent); - const QString id = prefix + i.data(DeviceModel::NodeKeyRole).toString(); - if (tree_->isExpanded(filter_->mapFromSource(i))) expanded_.insert(id); - else expanded_.remove(id); - visit(i); - } - }; - visit({}); - const QModelIndex current = tree_->currentIndex(); - selectedNodeKey_ = current.data(DeviceModel::NodeKeyRole).toString(); - const QModelIndex target = propertyTarget(current); - selectedPath_ = target.data(DeviceModel::PathRole).toString(); - selectedGeneration_ = target.data(DeviceModel::GenerationRole).toULongLong(); -} -void MainWindow::restoreTree() -{ - const QString prefix = DeviceModel::viewId(model_->view()) + ':'; - std::function visit = [&](const QModelIndex &parent) { - for (int row = 0; row < model_->rowCount(parent); ++row) { - const QModelIndex i = model_->index(row, 0, parent); - tree_->setExpanded(filter_->mapFromSource(i), filter_->active() - || expanded_.contains(prefix + i.data(DeviceModel::NodeKeyRole).toString())); - visit(i); - } - }; - visit({}); - QModelIndex source = model_->findNode(selectedNodeKey_, selectedPath_, selectedGeneration_); - if (!source.isValid()) source = model_->findDevice(selectedPath_, selectedGeneration_); - const QModelIndex selected = filter_->mapFromSource(source); - if (selected.isValid()) { - for (QModelIndex parent = selected.parent(); parent.isValid(); parent = parent.parent()) - tree_->setExpanded(parent, true); - if (tree_->currentIndex() != selected) { - tree_->setCurrentIndex(selected); - tree_->scrollTo(selected); - } - } else if (!selectedPath_.isEmpty() && !model_->device(selectedPath_, selectedGeneration_)) { - tree_->setCurrentIndex({}); // Never silently select a replacement or neighbour. - } -} -void MainWindow::updateStatus() -{ - if (closing_) return; - propertiesAction_->setEnabled(tree_->currentIndex().data(DeviceModel::NodeKeyRole).toString() == "computer" - || !propertyTarget(tree_->currentIndex()).data(DeviceModel::PathRole).toString().isEmpty()); - if (initializing_ || busy_) { - statusBar()->showMessage(initializing_ ? tr("Initializing…") : tr("Refreshing devices…")); - return; - } - QString message = tr("%1 shown / %2 discovered").arg(filter_->visibleCount()).arg(model_->inventoryCount()); - if (filter_->active() && deepSearch_->isChecked()) { - message += tr(" — Deep search: %1/%2").arg(searchDone_).arg(searchTotal_); - if (searchUnavailable_) message += tr("; %1 property reads unavailable").arg(searchUnavailable_); - const QString reason = filter_->matchReason(tree_->currentIndex()); - if (!reason.isEmpty()) message += tr(" — Matched: %1").arg(reason); - } - if (!scanNote_.isEmpty()) message += QStringLiteral(" — ") + scanNote_; - const QString path = tree_->currentIndex().data(DeviceModel::PathRole).toString(); - if (!path.isEmpty()) message += QStringLiteral(" — ") + path; - statusBar()->showMessage(message); -} -void MainWindow::saveSettings() -{ - QSettings settings; - settings.setValue("window/geometry", saveGeometry()); -} -void MainWindow::closeEvent(QCloseEvent *event) -{ - if (!closing_) saveSettings(); - closing_ = true; - initializationProgress_->hide(); - pendingProperties_.reset(); - cancelSearch(); - if (worker_.isRunning() || propertiesWorker_.isRunning() || searchWorker_.isRunning() || systemWorker_.isRunning()) { - worker_.requestInterruption(); - propertiesWorker_.requestInterruption(); - systemWorker_.requestInterruption(); - setEnabled(false); - statusBar()->showMessage(tr("Waiting for device metadata reads to finish…")); - event->ignore(); // Keep the event loop alive; finished() closes the window. - } else event->accept(); -} - -void MainWindow::openProperties() -{ - if (closing_) return; - const QModelIndex index = propertyTarget(tree_->currentIndex()); - if (index.data(DeviceModel::NodeKeyRole).toString() == "computer") { - openSystemInformation(); - return; - } - const auto record = model_->device(index.data(DeviceModel::PathRole).toString(), - index.data(DeviceModel::GenerationRole).toULongLong()); - if (!record) return; - if (propertiesDialog_ && propertiesDialog_->device().path == record->path - && propertiesDialog_->device().generation == record->generation) { - propertiesDialog_->show(); - propertiesDialog_->raise(); - propertiesDialog_->activateWindow(); - return; - } - if (propertiesDialog_) propertiesDialog_->close(); - auto *dialog = new PropertiesDialog(*record, this); - propertiesDialog_ = dialog; - dialog->updateEvents(events_, scanNote_); - connect(dialog, &PropertiesDialog::reloadRequested, this, &MainWindow::requestProperties); - connect(dialog, &QDialog::finished, this, [this, dialog] { - if (propertiesDialog_ != dialog) return; - propertiesDialog_.clear(); - pendingProperties_.reset(); - ++propertiesRequest_; - propertiesWorker_.requestInterruption(); - }); - dialog->show(); // Modeless: tree Refresh stays available while properties are open. - requestProperties(); -} -void MainWindow::requestProperties() -{ - if (closing_ || !propertiesDialog_ || propertiesDialog_->isRemoved()) return; - pendingProperties_ = propertiesDialog_->device(); - ++propertiesRequest_; - propertiesDialog_->setBusy(true); - if (propertiesBusy_) { - propertiesWorker_.requestInterruption(); - return; // Only the latest pending request survives. - } - propertiesWorker_.setRequest(*pendingProperties_, propertiesRequest_); - pendingProperties_.reset(); - propertiesBusy_ = true; - propertiesWorker_.start(); -} -void MainWindow::acceptProperties() -{ - Q_ASSERT(QThread::isMainThread()); - propertiesWorker_.wait(); // Join final thread-local cleanup before reusing it. - DeviceProperties result = propertiesWorker_.takeResult(); - propertiesBusy_ = false; - if (closing_) { close(); return; } - if (pendingProperties_ && propertiesDialog_ && !propertiesDialog_->isRemoved()) { - propertiesWorker_.setRequest(*pendingProperties_, propertiesRequest_); - pendingProperties_.reset(); - propertiesBusy_ = true; - propertiesWorker_.start(); - return; - } - pendingProperties_.reset(); - if (propertiesDialog_ && result.request == propertiesRequest_) - propertiesDialog_->acceptResult(std::move(result)); -} - -bool MainWindow::eventFilter(QObject *watched, QEvent *event) -{ - if ((watched == tree_ || watched == tree_->viewport()) && event->type() == QEvent::KeyPress) { - const auto *key = static_cast(event); - // Keypad Enter may carry KeypadModifier; other modifiers are not activation. - if ((key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter) - && (key->modifiers() == Qt::NoModifier || key->modifiers() == Qt::KeypadModifier)) { - if (!key->isAutoRepeat() && propertiesAction_->isEnabled()) propertiesAction_->trigger(); - return true; - } - } - if (event->type() == QEvent::KeyPress && filterAction_->isChecked() - && static_cast(event)->key() == Qt::Key_Escape) { - filterAction_->setChecked(false); - return true; - } - return QMainWindow::eventFilter(watched, event); -} -void MainWindow::cancelSearch() -{ - ++searchRequest_; // Reject queued batches from every obsolete request. - searchPending_ = false; - searchWorker_.requestInterruption(); -} -void MainWindow::applyFilter() -{ - filterTimer_.stop(); - cancelSearch(); - if (closing_) return; - const QString query = filterEdit_->text().trimmed(); - if (!filter_->active() && !query.isEmpty()) rememberTree(); - filter_->setQuery(query, deepSearch_->isChecked()); - searchDone_ = searchTotal_ = searchUnavailable_ = 0; - restoreTree(); - if (!query.isEmpty() && deepSearch_->isChecked()) { - searchTotal_ = model_->visibleCount(); - searchPending_ = true; - if (!searchBusy_ && !busy_) startSearch(); - } - updateStatus(); -} -void MainWindow::startSearch() -{ - if (closing_ || busy_ || !searchPending_ || filterTimer_.isActive()) return; - searchPending_ = false; - searchWorker_.setRequest(model_->searchRecords(), filterEdit_->text().trimmed(), searchRevision_, searchRequest_); - searchBusy_ = true; - searchWorker_.start(); -} -void MainWindow::acceptSearchBatch(const SearchBatch &batch) -{ - Q_ASSERT(QThread::isMainThread()); - if (closing_ || batch.request != searchRequest_ || !filter_->active() || !deepSearch_->isChecked()) return; - filter_->addMatches(batch.matches); - searchDone_ = batch.done; - searchTotal_ = batch.total; - searchUnavailable_ = batch.unavailable; - tree_->expandAll(); - updateStatus(); -} -void MainWindow::searchFinished() -{ - searchWorker_.wait(); - searchBusy_ = false; - if (closing_) { close(); return; } - if (searchPending_) startSearch(); -} - -void MainWindow::openSystemInformation() -{ - if (closing_) return; - if (!systemDialog_) { - // Retain one window-owned dialog, including while hidden, so worker results - // cannot target a deleted dialog. Opening it again reuses that same snapshot. - systemDialog_ = new SystemDialog(this); - connect(systemDialog_, &SystemDialog::refreshRequested, this, &MainWindow::requestSystemInformation); - requestSystemInformation(); - } - systemDialog_->show(); - systemDialog_->raise(); - systemDialog_->activateWindow(); -} -void MainWindow::requestSystemInformation() -{ - if (closing_ || systemBusy_ || !systemDialog_) return; - systemBusy_ = true; // Includes queued finished delivery; only one request runs. - systemDialog_->setBusy(true); - systemWorker_.start(); -} -void MainWindow::acceptSystemInformation() -{ - Q_ASSERT(QThread::isMainThread()); - systemWorker_.wait(); - systemBusy_ = false; - if (closing_) { close(); return; } - if (systemDialog_) systemDialog_->acceptResult(systemWorker_.takeResult()); -} diff --git a/src/ui/mainwindow.h b/src/ui/mainwindow.h deleted file mode 100644 index 1d0d9f9..0000000 --- a/src/ui/mainwindow.h +++ /dev/null @@ -1,88 +0,0 @@ -#pragma once -#include "enumerator.h" -#include "deviceproperties.h" -#include "systemproperties.h" -#include -#include -#include -#include -#include -#include "devicesearch.h" - -class QAction; -class QLineEdit; -class QProgressBar; -class QCheckBox; -class DeviceFilter; -class DeviceModel; -class QTreeView; -class QCloseEvent; -class PropertiesDialog; -class SystemDialog; - -class MainWindow final : public QMainWindow { - Q_OBJECT -public: - MainWindow(); - ~MainWindow() override; -protected: - void closeEvent(QCloseEvent *event) override; - bool eventFilter(QObject *watched, QEvent *event) override; -private: - void refresh(); - void applyFilter(); - void cancelSearch(); - void startSearch(); - void acceptSearchBatch(const SearchBatch &batch); - void searchFinished(); - void openProperties(); - void openSystemInformation(); - void requestSystemInformation(); - void acceptSystemInformation(); - void requestProperties(); - void acceptProperties(); - void acceptInventory(const Inventory &inventory); - void rememberTree(); - void restoreTree(); - void updateStatus(); - void saveSettings(); - DeviceModel *model_ = nullptr; - QTreeView *tree_ = nullptr; - DeviceFilter *filter_ = nullptr; - QWidget *filterBar_ = nullptr; - QLineEdit *filterEdit_ = nullptr; - QCheckBox *deepSearch_ = nullptr; - QAction *filterAction_ = nullptr; - QTimer filterTimer_; - DeepSearchWorker searchWorker_; - quint64 searchRequest_ = 0; - quint64 searchRevision_ = 1; - bool searchBusy_ = false; - bool searchPending_ = false; - int searchDone_ = 0; - int searchTotal_ = 0; - int searchUnavailable_ = 0; - QAction *refreshAction_ = nullptr; - QAction *internalAction_ = nullptr; - QAction *propertiesAction_ = nullptr; - QPointer propertiesDialog_; - PropertiesReader propertiesWorker_; - std::optional pendingProperties_; - quint64 propertiesRequest_ = 0; - bool propertiesBusy_ = false; - SystemDialog *systemDialog_ = nullptr; - SystemPropertiesReader systemWorker_; - bool systemBusy_ = false; - Enumerator worker_; - quint64 requested_ = 0; - bool busy_ = false; - bool initializing_ = true; - QProgressBar *initializationProgress_ = nullptr; - bool closing_ = false; - QSet expanded_; - QString selectedPath_; - QString selectedNodeKey_; - quint64 selectedGeneration_ = 0; - QString scanNote_; - DeviceEventsSnapshot events_; -}; diff --git a/src/ui/propertiesdialog.cpp b/src/ui/propertiesdialog.cpp deleted file mode 100644 index e3888c7..0000000 --- a/src/ui/propertiesdialog.cpp +++ /dev/null @@ -1,1843 +0,0 @@ -#include "resourceformat.h" -#include "propertiesdialog.h" -#include "devicelabel.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -// Long report tables scroll with the Properties page, not inside a short nested view. -class PropertyReportHeader final : public QHeaderView { -public: - explicit PropertyReportHeader(QWidget *parent) : QHeaderView(Qt::Horizontal, parent) {} - int wrappedHeight() const - { - int height = 0; - for (int index = 0; index < count(); ++index) { - if (isSectionHidden(index)) continue; - QStyleOptionHeaderV2 option; - initStyleOption(&option); - initStyleOptionForIndex(&option, index); - const int baseHeight = QHeaderView::sectionSizeFromContents(index).height(); - option.rect = QRect(0, 0, sectionSize(index), baseHeight); - const QRect label = style()->subElementRect(QStyle::SE_HeaderLabel, &option, this); - const QRect wrapped = fontMetrics().boundingRect(QRect(0, 0, qMax(1, label.width()), QWIDGETSIZE_MAX), - Qt::AlignCenter | Qt::TextWordWrap, option.text); - height = qMax(height, qMax(baseHeight, wrapped.height() + baseHeight - label.height())); - } - return height; - } -protected: - void paintSection(QPainter *painter, const QRect &rect, int index) const override - { - if (!rect.isValid()) return; - QStyleOptionHeaderV2 option; - initStyleOption(&option); - initStyleOptionForIndex(&option, index); - option.rect = rect; - const QString text = option.text; - const QRect label = style()->subElementRect(QStyle::SE_HeaderLabel, &option, this); - option.text.clear(); - painter->save(); - style()->drawControl(QStyle::CE_Header, &option, painter, this); - painter->setFont(font()); - style()->drawItemText(painter, label, Qt::AlignCenter | Qt::TextWordWrap, - option.palette, option.state.testFlag(QStyle::State_Enabled), text, QPalette::ButtonText); - painter->restore(); - } -}; -class PropertyReportTable final : public QTableWidget { -public: - PropertyReportTable(int rows, int columns, QWidget *parent) : QTableWidget(rows, columns, parent) - { - setHorizontalHeader(new PropertyReportHeader(this)); - setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - connect(horizontalHeader(), &QHeaderView::sectionResized, this, [this] { fitContents(); }); - } - void fitContents() - { - if (fitQueued_) return; - fitQueued_ = true; - // Let the stretched columns settle before measuring wrapped text. - QTimer::singleShot(0, this, [this] { - fitQueued_ = false; - auto *header = static_cast(horizontalHeader()); - const int headerHeight = header->wrappedHeight(); - if (header->height() != headerHeight) header->setFixedHeight(headerHeight); - resizeRowsToContents(); - int height = verticalHeader()->length() + headerHeight + 2 * frameWidth(); - if (horizontalScrollBarPolicy() != Qt::ScrollBarAlwaysOff) - height += horizontalScrollBar()->sizeHint().height(); - if (minimumHeight() != height || maximumHeight() != height) setFixedHeight(height); - }); - } -protected: - void resizeEvent(QResizeEvent *event) override - { - QTableWidget::resizeEvent(event); - fitContents(); - } - void changeEvent(QEvent *event) override - { - QTableWidget::changeEvent(event); - if (event->type() == QEvent::FontChange || event->type() == QEvent::StyleChange - || event->type() == QEvent::LayoutDirectionChange) fitContents(); - } -private: - bool fitQueued_ = false; -}; - -QString eventDescription(const DeviceEvent &event) -{ - if (event.action == "add") return QCoreApplication::translate("PropertiesDialog", "Device addition observed"); - if (event.action == "remove") return QCoreApplication::translate("PropertiesDialog", "Device removal observed"); - if (event.action == "change") return QCoreApplication::translate("PropertiesDialog", "Device change reported"); - if (event.action == "bind") return QCoreApplication::translate("PropertiesDialog", "Driver binding reported"); - if (event.action == "unbind") return QCoreApplication::translate("PropertiesDialog", "Driver unbinding reported"); - if (event.action == "move") return QCoreApplication::translate("PropertiesDialog", "Device path move reported"); - if (event.action == "online") return QCoreApplication::translate("PropertiesDialog", "Device online event reported"); - if (event.action == "offline") return QCoreApplication::translate("PropertiesDialog", "Device offline event reported"); - return QCoreApplication::translate("PropertiesDialog", "udev action: %1").arg(event.action); -} -QString eventText(const DeviceEvent &event) -{ - const auto value = [](const QString &text) { - return text.isEmpty() ? QCoreApplication::translate("PropertiesDialog", "Unavailable in event payload") : text; - }; - // Format each field once so a literal "%9" in a device string cannot - // become a later QString::arg placeholder. - return QStringList { - QCoreApplication::translate("PropertiesDialog", "Observed: %1").arg(event.observed.toString(Qt::ISODateWithMs)), - QCoreApplication::translate("PropertiesDialog", "Elapsed since monitor startup: %1 ms").arg(event.elapsedMs), - QCoreApplication::translate("PropertiesDialog", "Source: live udev observation"), - QCoreApplication::translate("PropertiesDialog", "Action: %1").arg(value(event.action)), - QCoreApplication::translate("PropertiesDialog", "Description: %1").arg(eventDescription(event)), - QCoreApplication::translate("PropertiesDialog", "Kernel path: %1").arg(event.path), - QCoreApplication::translate("PropertiesDialog", "Subsystem: %1").arg(value(event.subsystem)), - QCoreApplication::translate("PropertiesDialog", "Device type: %1").arg(value(event.devtype)), - QCoreApplication::translate("PropertiesDialog", "Driver in event payload: %1").arg(value(event.driver)), - QCoreApplication::translate("PropertiesDialog", "udev sequence: %1").arg(event.sequence ? QString::number(event.sequence) : value(QString())), - QCoreApplication::translate("PropertiesDialog", "Initialization stamp: %1").arg(value(event.initialized)), - QCoreApplication::translate("PropertiesDialog", "Local instance token: %1").arg(event.instance), - QCoreApplication::translate("PropertiesDialog", "Previous path: %1").arg(value(event.oldPath)) - }.join('\n'); -} -void clearForm(QFormLayout *form) -{ - while (form->rowCount()) form->removeRow(0); -} -QLabel *plainLabel(const QString &text, QWidget *parent) -{ - auto *label = new QLabel(text, parent); - label->setTextFormat(Qt::PlainText); - label->setWordWrap(true); - label->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); - label->setFocusPolicy(Qt::StrongFocus); - return label; -} -QFormLayout *storageDetailsForm(QVBoxLayout *layout, QWidget *parent) -{ - auto *scroll = new QScrollArea(parent); - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::StyledPanel); - auto *page = new QWidget(scroll); - auto *form = new QFormLayout(page); - form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - form->setRowWrapPolicy(QFormLayout::WrapLongRows); - form->setFormAlignment(Qt::AlignTop); - scroll->setWidget(page); - layout->addWidget(scroll, 1); - return form; -} -void showStorageFields(QFormLayout *form, const QVector &fields) -{ - clearForm(form); - for (const StorageDisplayField &field : fields) { - auto *caption = plainLabel(field.label + ':', form->parentWidget()); - auto *value = plainLabel(field.value, form->parentWidget()); - const QString hint = "" + field.source.toHtmlEscaped().replace('\n', "
") + "
"; - caption->setToolTip(hint); - value->setToolTip(hint); - form->addRow(caption, value); - } -} -QString selectedStorageText(const QVector &rows, int index) -{ - if (index < 0 || index >= rows.size()) return {}; - const StorageDisplayRow &row = rows[index]; - return storageRowsText({row}); -} -} - -PropertiesDialog::PropertiesDialog(const Device &device, QWidget *parent) : QDialog(parent) -{ - snapshot_.device = device; - setAttribute(Qt::WA_DeleteOnClose); - setWindowTitle(tr("%1 — Properties").arg(deviceDisplayName(device))); - resize(680, 560); - auto *layout = new QVBoxLayout(this); - banner_ = plainLabel(tr("Loading properties…"), this); - layout->addWidget(banner_); - tabs_ = new QTabWidget(this); - auto *tabs = tabs_; - layout->addWidget(tabs, 1); - const auto addForm = [tabs](const QString &title) { - auto *scroll = new QScrollArea(tabs); - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - auto *page = new QWidget(scroll); - auto *form = new QFormLayout(page); - form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - form->setRowWrapPolicy(QFormLayout::WrapLongRows); - scroll->setWidget(page); - tabs->addTab(scroll, title); - return form; - }; - general_ = addForm(tr("General")); - driver_ = addForm(tr("Driver")); - networkPage_ = new QWidget(tabs); - networkPage_->hide(); - auto *networkLayout = new QVBoxLayout(networkPage_); - auto *networkNotice = plainLabel(tr("ℹ️ Network interface snapshot"), networkPage_); - networkNotice->setToolTip(tr("Addresses belong to the application's network namespace. Reload properties to refresh. " - "Hardware MAC is the permanent address reported by the driver. An overridden MAC is the current " - "address when it differs from the permanent address, or Linux reports a random, inherited or explicitly set address.")); - networkLayout->addWidget(networkNotice); - auto *networkScroll = new QScrollArea(networkPage_); - networkScroll->setWidgetResizable(true); - auto *networkContent = new QWidget(networkScroll); - networkGroups_ = new QVBoxLayout(networkContent); - networkGroups_->setSizeConstraint(QLayout::SetMinAndMaxSize); - networkScroll->setWidget(networkContent); - networkLayout->addWidget(networkScroll, 1); - auto *copyNetwork = new QPushButton(tr("Copy network properties"), networkPage_); - networkLayout->addWidget(copyNetwork); - connect(copyNetwork, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(networkText_); - }); - cameraPage_ = new QWidget(tabs); - cameraPage_->hide(); - auto *cameraLayout = new QVBoxLayout(cameraPage_); - auto *cameraNotice = plainLabel(tr("ℹ️ Driver-reported camera capabilities snapshot"), cameraPage_); - cameraNotice->setToolTip(tr("Queries only: no capture, format changes or measured frame rates. Reload properties to refresh. " - "Rows preserve the format, size and frame interval relationship. Size ranges do not imply a common rate list.")); - cameraLayout->addWidget(cameraNotice); - auto *cameraScroll = new QScrollArea(cameraPage_); - cameraScroll->setWidgetResizable(true); - auto *cameraContent = new QWidget(cameraScroll); - cameraGroups_ = new QVBoxLayout(cameraContent); - cameraGroups_->setSizeConstraint(QLayout::SetMinAndMaxSize); - cameraScroll->setWidget(cameraContent); - cameraLayout->addWidget(cameraScroll, 1); - auto *copyCamera = new QPushButton(tr("Copy camera properties"), cameraPage_); - cameraLayout->addWidget(copyCamera); - connect(copyCamera, &QPushButton::clicked, this, [this] { QApplication::clipboard()->setText(cameraText_); }); - displayPage_ = new QWidget(tabs); - displayPage_->hide(); - auto *displayLayout = new QVBoxLayout(displayPage_); - auto *displayNotice = plainLabel(tr("ℹ️ Monitor hardware and display configuration snapshot"), displayPage_); - displayNotice->setToolTip(tr("Monitor information is reported by this connector’s cached EDID and kernel mode list; " - "it does not guarantee capabilities. Active settings are shown separately below. Resolution and refresh rate describe the configured physical mode. " - "Desktop color depth is not the panel or link depth. Scale excludes Qt application scaling. " - "Wayland scale derived from logical size may include rounding; integer scale is a fallback. " - "Reload properties to refresh. Unmatched or ambiguous connector names remain unavailable.")); - displayLayout->addWidget(displayNotice); - auto *displayScroll = new QScrollArea(displayPage_); - displayScroll->setWidgetResizable(true); - auto *displayContent = new QWidget(displayScroll); - displayGroups_ = new QVBoxLayout(displayContent); - displayGroups_->setSizeConstraint(QLayout::SetMinAndMaxSize); - displayScroll->setWidget(displayContent); - displayLayout->addWidget(displayScroll, 1); - auto *copyDisplay = new QPushButton(tr("Copy display properties"), displayPage_); - displayLayout->addWidget(copyDisplay); - connect(copyDisplay, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(displayText_); - }); - usbPage_ = new QWidget(tabs); - usbPage_->hide(); - auto *usbLayout = new QVBoxLayout(usbPage_); - auto *usbNotice = plainLabel(tr("ℹ️ USB configuration snapshot"), usbPage_); - usbNotice->setToolTip(tr("Power information is declared by the active USB configuration, not measured consumption. " - "USB version and speed are also available as raw values on Details. Reload properties to refresh.")); - usbLayout->addWidget(usbNotice); - auto *usbScroll = new QScrollArea(usbPage_); - usbScroll->setWidgetResizable(true); - auto *usbContent = new QWidget(usbScroll); - usbGroups_ = new QVBoxLayout(usbContent); - usbGroups_->setSizeConstraint(QLayout::SetMinAndMaxSize); - usbScroll->setWidget(usbContent); - usbLayout->addWidget(usbScroll, 1); - auto *copyUsb = new QPushButton(tr("Copy USB properties"), usbPage_); - usbLayout->addWidget(copyUsb); - connect(copyUsb, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(usbText_); - }); - batteryPage_ = new QWidget(tabs); - batteryPage_->hide(); - auto *batteryLayout = new QVBoxLayout(batteryPage_); - auto *batteryNotice = plainLabel(tr("ℹ️ Battery snapshot for this device"), batteryPage_); - batteryNotice->setToolTip(tr("Readings are reported by the kernel and battery firmware. Capacity retention is an estimate from " - "compatible full/design values, not a complete health assessment. Energy and charge units remain separate. " - "Reload properties to refresh; no background monitoring or battery settings are changed.")); - batteryLayout->addWidget(batteryNotice); - auto *batteryScroll = new QScrollArea(batteryPage_); - batteryScroll->setWidgetResizable(true); - auto *batteryContent = new QWidget(batteryScroll); - batteryGroups_ = new QVBoxLayout(batteryContent); - batteryGroups_->setSizeConstraint(QLayout::SetMinAndMaxSize); - batteryScroll->setWidget(batteryContent); - batteryLayout->addWidget(batteryScroll, 1); - auto *copyBattery = new QPushButton(tr("Copy Battery properties"), batteryPage_); - batteryLayout->addWidget(copyBattery); - connect(copyBattery, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(batteryText_); - }); - pciePage_ = new QWidget(tabs); - pciePage_->hide(); - auto *pcieLayout = new QVBoxLayout(pciePage_); - auto *pcieNotice = plainLabel(tr("ℹ️ PCIe link snapshot for this device"), pciePage_); - pcieNotice->setToolTip(tr("Current link readings can reflect power management and topology; a lower speed is not a fault verdict. " - "Device maxima do not establish the capacity of the upstream path. Reading these sysfs attributes may temporarily " - "runtime-resume the device. Reload properties to refresh.")); - pcieLayout->addWidget(pcieNotice); - auto *pcieScroll = new QScrollArea(pciePage_); - pcieScroll->setWidgetResizable(true); - auto *pcieContent = new QWidget(pcieScroll); - pcieGroups_ = new QVBoxLayout(pcieContent); - pcieGroups_->setSizeConstraint(QLayout::SetMinAndMaxSize); - pcieScroll->setWidget(pcieContent); - pcieLayout->addWidget(pcieScroll, 1); - auto *copyPcie = new QPushButton(tr("Copy PCIe properties"), pciePage_); - pcieLayout->addWidget(copyPcie); - connect(copyPcie, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(pcieText_); - }); - storagePage_ = new QWidget(tabs); - storagePage_->hide(); - auto *storageLayout = new QVBoxLayout(storagePage_); - storageNotice_ = plainLabel(tr("ℹ️ Storage snapshot for this device"), storagePage_); - storageLayout->addWidget(storageNotice_); - auto *storageScroll = new QScrollArea(storagePage_); - storageScroll->setWidgetResizable(true); - storageScroll->setFrameShape(QFrame::NoFrame); - auto *storageFormPage = new QWidget(storageScroll); - storage_ = new QFormLayout(storageFormPage); - storage_->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - storage_->setRowWrapPolicy(QFormLayout::WrapLongRows); - storageScroll->setWidget(storageFormPage); - storageLayout->addWidget(storageScroll, 1); - auto *copyStorage = new QPushButton(tr("Copy storage overview"), storagePage_); - storageLayout->addWidget(copyStorage); - connect(copyStorage, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(storageFieldsText(storagePresentation_.overview) - + "\n\n" + storagePresentation_.notes); - }); - - volumesPage_ = new QWidget(tabs); - volumesPage_->hide(); - auto *volumesLayout = new QVBoxLayout(volumesPage_); - volumesNotice_ = plainLabel(tr("ℹ️ Related volumes and mounts"), volumesPage_); - volumesLayout->addWidget(volumesNotice_); - volumesTable_ = new QTableWidget(0, 5, volumesPage_); - volumesTable_->setAlternatingRowColors(true); - volumesTable_->setHorizontalHeaderLabels({tr("Device / label"), tr("Layer"), tr("Capacity"), tr("Content"), tr("Mount points")}); - volumesTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); - volumesTable_->setSelectionBehavior(QAbstractItemView::SelectRows); - volumesTable_->setSelectionMode(QAbstractItemView::SingleSelection); - volumesTable_->verticalHeader()->hide(); - volumesTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); - volumesTable_->horizontalHeader()->setStretchLastSection(true); - volumesTable_->setColumnWidth(0, 180); - volumesTable_->setColumnWidth(1, 110); - volumesTable_->setColumnWidth(2, 180); - volumesLayout->addWidget(volumesTable_, 2); - volumeDetails_ = storageDetailsForm(volumesLayout, volumesPage_); - auto *volumeButtons = new QHBoxLayout; - auto *copyVolume = new QPushButton(tr("Copy selected volume"), volumesPage_); - auto *copyVolumes = new QPushButton(tr("Copy all volumes"), volumesPage_); - volumeButtons->addWidget(copyVolume); - volumeButtons->addWidget(copyVolumes); - volumeButtons->addStretch(); - volumesLayout->addLayout(volumeButtons); - connect(volumesTable_, &QTableWidget::currentCellChanged, this, [this] { showVolume(); }); - connect(copyVolume, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(selectedStorageText(storagePresentation_.volumes, volumesTable_->currentRow())); - }); - connect(copyVolumes, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(storageRowsText(storagePresentation_.volumes)); - }); - - healthPage_ = new QWidget(tabs); - healthPage_->hide(); - auto *healthLayout = new QVBoxLayout(healthPage_); - healthNotice_ = plainLabel(tr("ℹ️ Cached drive health"), healthPage_); - healthLayout->addWidget(healthNotice_); - auto *healthLabel = new QLabel(tr("&Drive:"), healthPage_); - healthDrive_ = new QComboBox(healthPage_); - healthDrive_->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); - healthDrive_->setMinimumContentsLength(24); - healthLabel->setBuddy(healthDrive_); - healthLayout->addWidget(healthLabel); - healthLayout->addWidget(healthDrive_); - auto *healthScroll = new QScrollArea(healthPage_); - healthScroll->setWidgetResizable(true); - healthScroll->setFrameShape(QFrame::NoFrame); - auto *healthReport = new QWidget(healthScroll); - healthGroups_ = new QVBoxLayout(healthReport); - healthScroll->setWidget(healthReport); - healthLayout->addWidget(healthScroll, 1); - auto *healthButtons = new QHBoxLayout; - auto *copyHealth = new QPushButton(tr("Copy selected drive health"), healthPage_); - auto *copyAllHealth = new QPushButton(tr("Copy all drive health"), healthPage_); - healthButtons->addWidget(copyHealth); - healthButtons->addWidget(copyAllHealth); - healthButtons->addStretch(); - healthLayout->addLayout(healthButtons); - connect(healthDrive_, &QComboBox::currentIndexChanged, this, &PropertiesDialog::showHealth); - connect(copyHealth, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(selectedStorageText(storagePresentation_.health, healthDrive_->currentIndex())); - }); - connect(copyAllHealth, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(storageRowsText(storagePresentation_.health)); - }); - auto *details = new QWidget(tabs); - auto *detailsLayout = new QVBoxLayout(details); - auto *propertyLabel = new QLabel(tr("&Property:"), details); - property_ = new QComboBox(details); - property_->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); - property_->setMinimumContentsLength(24); - propertyLabel->setBuddy(property_); - detailsLayout->addWidget(propertyLabel); - detailsLayout->addWidget(property_); - value_ = new QPlainTextEdit(details); - value_->setReadOnly(true); - detailsLayout->addWidget(value_, 1); - source_ = plainLabel(QString(), details); - detailsLayout->addWidget(source_); - advanced_ = new QCheckBox(tr("Show &advanced properties"), details); - detailsLayout->addWidget(advanced_); - auto *copyLayout = new QHBoxLayout; - auto *copySelected = new QPushButton(tr("Copy selection"), details); - auto *copyValue = new QPushButton(tr("Copy value"), details); - auto *copyEverything = new QPushButton(tr("Copy all values"), details); - copySelected->setEnabled(false); - connect(value_, &QPlainTextEdit::copyAvailable, copySelected, &QPushButton::setEnabled); - connect(copySelected, &QPushButton::clicked, value_, &QPlainTextEdit::copy); - connect(copyValue, &QPushButton::clicked, this, [this] { QApplication::clipboard()->setText(value_->toPlainText()); }); - connect(copyEverything, &QPushButton::clicked, this, &PropertiesDialog::copyAll); - for (auto *button : {copySelected, copyValue, copyEverything}) copyLayout->addWidget(button); - copyLayout->addStretch(); - detailsLayout->addLayout(copyLayout); - tabs->addTab(details, tr("Details")); - resourcesPage_ = new QWidget(tabs); - resourcesPage_->hide(); // Not a tab until metadata confirms applicability. - auto *resourcesLayout = new QVBoxLayout(resourcesPage_); - resourcesTable_ = new QTableWidget(0, 3, resourcesPage_); - resourcesTable_->setHorizontalHeaderLabels({tr("Resource type"), tr("Setting"), tr("Details")}); - resourcesTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); - resourcesTable_->setSelectionBehavior(QAbstractItemView::SelectRows); - resourcesTable_->setSelectionMode(QAbstractItemView::ExtendedSelection); - resourcesTable_->verticalHeader()->hide(); - resourcesTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - resourcesTable_->horizontalHeader()->setStretchLastSection(true); - resourcesLayout->addWidget(resourcesTable_); - auto *resourceButtons = new QHBoxLayout; - auto *copyResourceSelection = new QPushButton(tr("Copy selection"), resourcesPage_); - auto *copyResourceAll = new QPushButton(tr("Copy all resources"), resourcesPage_); - copyResourceSelection->setEnabled(false); - connect(resourcesTable_->selectionModel(), &QItemSelectionModel::selectionChanged, this, - [this, copyResourceSelection] { - copyResourceSelection->setEnabled(resourcesTable_->selectionModel()->hasSelection()); - }); - connect(copyResourceSelection, &QPushButton::clicked, this, [this] { copyResources(true); }); - connect(copyResourceAll, &QPushButton::clicked, this, [this] { copyResources(false); }); - resourceButtons->addWidget(copyResourceSelection); - resourceButtons->addWidget(copyResourceAll); - resourceButtons->addStretch(); - resourcesLayout->addLayout(resourceButtons); - // The tab appears only after collection finds resource records or read errors. - auto *eventsPage = new QWidget(tabs); - auto *eventsLayout = new QVBoxLayout(eventsPage); - eventsNotice_ = plainLabel(tr("ℹ️ Live udev events since the application started"), eventsPage); - eventsLayout->addWidget(eventsNotice_); - eventsTable_ = new QTableWidget(0, 3, eventsPage); - eventsTable_->setHorizontalHeaderLabels({tr("Observed time"), tr("Event type"), tr("Description")}); - eventsTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); - eventsTable_->setSelectionBehavior(QAbstractItemView::SelectRows); - eventsTable_->setSelectionMode(QAbstractItemView::ExtendedSelection); - eventsTable_->verticalHeader()->hide(); - eventsTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - eventsTable_->horizontalHeader()->setStretchLastSection(true); - eventsLayout->addWidget(eventsTable_, 2); - eventDetails_ = new QPlainTextEdit(eventsPage); - eventDetails_->setReadOnly(true); - eventsLayout->addWidget(eventDetails_, 1); - auto *eventButtons = new QHBoxLayout; - auto *copyEventSelection = new QPushButton(tr("Copy selection"), eventsPage); - auto *copyEventAll = new QPushButton(tr("Copy all events"), eventsPage); - copyEventSelection->setEnabled(false); - connect(eventsTable_->selectionModel(), &QItemSelectionModel::selectionChanged, this, - [this, copyEventSelection] { - copyEventSelection->setEnabled(eventsTable_->selectionModel()->hasSelection()); - }); - connect(eventsTable_, &QTableWidget::currentCellChanged, this, [this] { showEvent(); }); - connect(copyEventSelection, &QPushButton::clicked, this, [this] { copyEvents(true); }); - connect(copyEventAll, &QPushButton::clicked, this, [this] { copyEvents(false); }); - eventButtons->addWidget(copyEventSelection); - eventButtons->addWidget(copyEventAll); - eventButtons->addStretch(); - eventsLayout->addLayout(eventButtons); - tabs->addTab(eventsPage, tr("Events")); - auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); - reload_ = buttons->addButton(tr("Reload properties"), QDialogButtonBox::ActionRole); - connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); - connect(reload_, &QPushButton::clicked, this, &PropertiesDialog::reloadRequested); - connect(property_, &QComboBox::currentIndexChanged, this, &PropertiesDialog::showDetail); - connect(advanced_, &QCheckBox::toggled, this, &PropertiesDialog::rebuildDetails); - layout->addWidget(buttons); - rebuild(); -} -void PropertiesDialog::setBusy(bool busy) -{ - reload_->setEnabled(!busy && !removed_); - if (busy && !removed_) { - banner_->setText(tr("Loading properties…")); - banner_->show(); - } -} -void PropertiesDialog::acceptResult(DeviceProperties result) -{ - Q_ASSERT(QThread::isMainThread()); - if (removed_ || result.device.path != device().path || result.device.generation != device().generation) return; - setBusy(false); - if (result.state == ReadState::Removed) { markRemoved(); return; } - if (result.state != ReadState::Available) { - banner_->show(); - banner_->setText(tr("Properties could not be refreshed: %1. Showing the previous snapshot.") - .arg(propertyReadValue({result.state, {}, result.error}))); - return; - } - snapshot_ = std::move(result); - banner_->clear(); - banner_->hide(); - rebuild(); -} -void PropertiesDialog::reconcileInstance(const Device *current) -{ - if (removed_) return; - if (!current || current->generation != device().generation || current->incarnation != device().incarnation) { - markRemoved(); - return; - } - // Keep the same-instance snapshot until an explicit property reload. - // An inventory refresh must never silently retarget this dialog. -} -void PropertiesDialog::markRemoved() -{ - removed_ = true; - eventScope_ += '\n' + tr("Removed/replaced device: retained events are a read-only instance snapshot."); - eventsNotice_->setToolTip("" + eventScope_.toHtmlEscaped().replace('\n', "
") + "
"); - reload_->setEnabled(false); - banner_->show(); - banner_->setText(tr("Device removed or replaced. This dialog is a read-only snapshot; close it and reopen Properties for the current device.")); - rebuild(); -} -void PropertiesDialog::rebuild() -{ - rebuildResources(); - entries_ = propertyEntries(snapshot_, removed_); - clearForm(general_); - clearForm(driver_); - rebuildStorage(); - rebuildNetwork(); - rebuildDisplay(); - rebuildCamera(); - rebuildUsb(); - rebuildPcie(); - rebuildBattery(); - for (const PropertyEntry &entry : entries_) { - if (entry.tab > 1) continue; - QFormLayout *form = entry.tab == 0 ? general_ : driver_; - auto *label = plainLabel(entry.value, this); - label->setToolTip(entry.source.toHtmlEscaped()); - form->addRow(entry.label + ':', label); - } - rebuildDetails(); -} -void PropertiesDialog::rebuildDetails() -{ - const QString previous = property_->currentData().toString(); - const QSignalBlocker blocker(property_); - property_->clear(); - QVector available, other; - for (const PropertyEntry &entry : entries_) { - if (!isDetailsProperty(entry, device()) || (entry.advanced && !advanced_->isChecked())) continue; - if (detailsPropertyStatus(entry).isEmpty()) available.append(&entry); - else other.append(&entry); - } - for (const PropertyEntry *entry : available) property_->addItem(entry->label, entry->id); - if (!available.isEmpty() && !other.isEmpty()) property_->insertSeparator(property_->count()); - for (const PropertyEntry *entry : other) - property_->addItem(tr("%1 (%2)").arg(entry->label, detailsPropertyStatus(*entry)), entry->id); - const int index = property_->findData(previous); - property_->setCurrentIndex(index < 0 ? 0 : index); - showDetail(); -} -void PropertiesDialog::showDetail() -{ - const QString id = property_->currentData().toString(); - for (const PropertyEntry &entry : entries_) { - if (entry.id != id) continue; - value_->setFont(id == "efi/hex" ? QFontDatabase::systemFont(QFontDatabase::FixedFont) : QApplication::font()); - value_->setLineWrapMode(id == "efi/hex" ? QPlainTextEdit::NoWrap : QPlainTextEdit::WidgetWidth); - value_->setPlainText(entry.value); - source_->setText(entry.source.isEmpty() ? QString() : tr("Source: %1").arg(entry.source)); - return; - } - value_->clear(); - source_->clear(); -} -void PropertiesDialog::copyAll() -{ - QStringList values; - for (const PropertyEntry &entry : entries_) { - if (!isDetailsProperty(entry, device()) || (entry.advanced && !advanced_->isChecked())) continue; - QString text = entry.label + ":\n" + entry.value; - if (!entry.source.isEmpty()) text += '\n' + tr("Source: %1").arg(entry.source); - values.append(text); - } - QApplication::clipboard()->setText(values.join("\n\n")); -} - -void PropertiesDialog::rebuildResources() -{ - const DeviceResources &data = snapshot_.resources; - const int existing = tabs_->indexOf(resourcesPage_); - if (data.hasInformation() && existing < 0) tabs_->addTab(resourcesPage_, tr("Resources")); - else if (!data.hasInformation() && existing >= 0) { - tabs_->removeTab(existing); - resourcesPage_->hide(); - } - resourcesTable_->setRowCount(0); - QStringList copy; - const auto row = [this, ©](const QString &type, const QString &setting, - const QString &details, const QString &source) { - const int index = resourcesTable_->rowCount(); - resourcesTable_->insertRow(index); - const QStringList values {type, setting, details}; - for (int column = 0; column < values.size(); ++column) { - auto *item = new QTableWidgetItem(values[column]); - item->setToolTip(source.toHtmlEscaped()); - resourcesTable_->setItem(index, column, item); - } - resourcesTable_->item(index, 0)->setData(Qt::UserRole, source); - copy.append(values.join('\t') + '\n' + tr("Source: %1").arg(source)); - }; - if (!data.pnpState.isEmpty()) - copy.append(tr("PnP device state: %1").arg(data.pnpState == "active" ? tr("Active") : tr("Disabled"))); - for (const ResourceRow &entry : resourceRows(data)) - row(entry.type, entry.setting, entry.details, entry.source); - resourcesText_ = copy.join("\n\n"); -} -void PropertiesDialog::copyResources(bool selectedOnly) -{ - QList rows; - if (selectedOnly) { - for (const QModelIndex &index : resourcesTable_->selectionModel()->selectedRows()) rows.append(index.row()); - std::sort(rows.begin(), rows.end()); - } else { - QApplication::clipboard()->setText(resourcesText_); - return; - } - QStringList text; - for (const int row : rows) { - QStringList values; - for (int column = 0; column < resourcesTable_->columnCount(); ++column) - values.append(resourcesTable_->item(row, column)->text()); - text.append(values.join('\t') + '\n' + tr("Source: %1").arg( - resourcesTable_->item(row, 0)->data(Qt::UserRole).toString())); - } - QApplication::clipboard()->setText(text.join("\n\n")); -} - -void PropertiesDialog::rebuildNetwork() -{ - const bool applicable = device().subsystem == "net" || device().category == "network"; - const int existing = tabs_->indexOf(networkPage_); - if (applicable && existing < 0) tabs_->insertTab(2, networkPage_, tr("Network")); - else if (!applicable && existing >= 0) { - tabs_->removeTab(existing); - networkPage_->hide(); - } - while (QLayoutItem *item = networkGroups_->takeAt(0)) { - delete item->widget(); - delete item; - } - networkText_.clear(); - if (!applicable) return; - QVector forms; - QVector captions; - QStringList copy; - const auto group = [&](const QString &title) { - auto *box = new QGroupBox(title, networkGroups_->parentWidget()); - auto *form = new QGridLayout(box); - form->setColumnStretch(1, 1); - forms.append(form); - networkGroups_->addWidget(box); - if (!copy.isEmpty()) copy.append(QString()); - copy.append(title); - return form; - }; - const auto field = [&](QGridLayout *form, const QString &caption, const QString &text, const QString &source) { - auto *name = new QLabel(caption, form->parentWidget()); - name->setTextFormat(Qt::PlainText); - captions.append(name); - auto *value = plainLabel(text, form->parentWidget()); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - value->setMinimumWidth(120); - const QString hint = "" + source.toHtmlEscaped() + ""; - name->setToolTip(hint); - value->setToolTip(hint); - const int row = form->count() / 2; - form->addWidget(name, row, 0, Qt::AlignLeading | Qt::AlignTop); - form->addWidget(value, row, 1); - copy.append(caption + ": " + text); - }; - const auto ethernetValue = [&](const Attribute &value) { - if (value.state == ReadState::Unsupported) return tr("Query unsupported by the kernel/device"); - if (value.state == ReadState::Error) { - if (value.error == EPROTO) return tr("Malformed ethtool reply"); - if (value.error == ETIMEDOUT) return tr("Query timed out"); - if (value.error == EOVERFLOW) return tr("Reply exceeds collection limits"); - if (value.error == EAGAIN) return tr("Link changed during collection; reload properties"); - } - return propertyReadValue(value); - }; - if (snapshot_.network.isEmpty()) { - auto *form = group(tr("Network interfaces")); - field(form, tr("Interfaces"), snapshot_.networkState.state == ReadState::Available - ? tr("No network interfaces observed") : propertyReadValue(snapshot_.networkState), device().path); - } - for (const NetworkProperties &interface : snapshot_.network) { - const EthernetProperties ðernet = interface.ethernet; - if (ethernet.applicable) { - auto *form = group(tr("%1 — Ethernet link").arg(interface.name)); - const QString source = interface.path + '\n' + ethernet.source; - const bool disconnected = ethernet.carrier.state == ReadState::Available && ethernet.carrier.value == "0"; - field(form, tr("Link status"), ethernet.carrier.state == ReadState::Available - ? (disconnected ? tr("Disconnected") : tr("Connected")) : ethernetValue(ethernet.carrier), source); - const auto operationalValue = [&](const Attribute &value) { - return value.state == ReadState::NotApplicable && disconnected - ? tr("Not applicable — link disconnected") : ethernetValue(value); - }; - QString speed = operationalValue(ethernet.speed); - if (ethernet.speed.state == ReadState::Available) { - bool valid = false; - const qulonglong mbps = ethernet.speed.value.toULongLong(&valid); - if (!valid) speed = tr("Malformed ethtool reply"); - else if (!mbps || mbps == 0xffffffffULL) speed = tr("Unavailable (driver reports unknown speed)"); - else speed = mbps >= 1000 - ? tr("%1 Gbps").arg(QLocale().toString(static_cast(mbps) / 1000, 'g', 6)) - : tr("%1 Mbps").arg(QLocale().toString(mbps)); - } - field(form, tr("Link speed"), speed, source + '\n' + tr("Driver-reported operational speed, not measured throughput; it may be forced when autonegotiation is disabled.")); - QString duplex = operationalValue(ethernet.duplex); - if (ethernet.duplex.state == ReadState::Available) { - if (ethernet.duplex.value == "0") duplex = tr("Half duplex"); - else if (ethernet.duplex.value == "1") duplex = tr("Full duplex"); - else if (ethernet.duplex.value == "255") duplex = tr("Unavailable (driver reports unknown duplex)"); - else duplex = tr("Unrecognized duplex value: %1").arg(ethernet.duplex.value); - } - field(form, tr("Duplex"), duplex, source); - QString autoneg = ethernetValue(ethernet.autoneg); - if (ethernet.autoneg.state == ReadState::Available) { - if (ethernet.autoneg.value == "0") autoneg = tr("Disabled"); - else if (ethernet.autoneg.value == "1") autoneg = tr("Enabled"); - else autoneg = tr("Unrecognized autonegotiation value: %1").arg(ethernet.autoneg.value); - } - field(form, tr("Autonegotiation"), autoneg, source); - field(form, tr("Supported modes / capabilities"), ethernetValue(ethernet.supportedModes).replace('\n', ", "), - source + '\n' + tr("Kernel-named supported-mask entries include link modes and media, pause or autonegotiation capabilities. Unnamed bits retain their numeric index.")); - QString partner = operationalValue(ethernet.partnerModes); - if (ethernet.partnerModes.state == ReadState::NotApplicable && !disconnected) - partner = tr("Not applicable — autonegotiation disabled"); - else if (ethernet.partnerModes.state == ReadState::Unavailable) - partner = tr("Unavailable — no partner advertisement reported"); - field(form, tr("Link-partner advertised modes"), partner.replace('\n', ", "), source); - } - auto *link = group(tr("%1 — Link").arg(interface.name)); - const bool noMac = interface.linkType.state == ReadState::Available && interface.linkType.value != "1"; - const Attribute current = noMac ? Attribute {ReadState::NotApplicable, {}, 0} : interface.currentAddress; - field(link, tr("Hardware MAC address"), propertyReadValue(interface.permanentAddress), - tr("Driver permanent address: ETHTOOL_GPERMADDR")); - field(link, tr("Current MAC address"), propertyReadValue(current), interface.path + "/address"); - QString overrideValue; - if (noMac) overrideValue = tr("Not applicable"); - else if (current.state != ReadState::Available) overrideValue = propertyReadValue(current); - else if (interface.permanentAddress.state == ReadState::Available) - overrideValue = current.value.compare(interface.permanentAddress.value, Qt::CaseInsensitive) == 0 - ? tr("None") : current.value; - else if (interface.assignmentType.state == ReadState::Available - && (interface.assignmentType.value == "1" || interface.assignmentType.value == "2" - || interface.assignmentType.value == "3")) overrideValue = current.value; - else overrideValue = tr("Unavailable; permanent MAC could not be determined"); - field(link, tr("Overridden MAC address"), overrideValue, - interface.path + "/address; " + interface.path + "/addr_assign_type; ETHTOOL_GPERMADDR"); - field(link, tr("MTU"), propertyReadValue(interface.mtu), interface.path + "/mtu"); - if (!ethernet.applicable && ethernet.scope.state != ReadState::NotApplicable) - field(link, tr("Ethernet details"), ethernetValue(ethernet.scope), ethernet.source); - auto *addresses = group(tr("%1 — IP addresses").arg(interface.name)); - const QString source = tr("getifaddrs: application network namespace"); - if (interface.addresses.isEmpty()) { - field(addresses, tr("IP addresses"), interface.addressState.state == ReadState::Available - ? tr("None assigned") : propertyReadValue(interface.addressState), source); - } - for (const NetworkAddress &address : interface.addresses) - field(addresses, address.family == "IPv4" ? tr("IPv4 address") : tr("IPv6 address"), - address.address + '/' + propertyReadValue(address.netmask), source); - } - // Match System Information: one font-derived caption width across sections. - int captionWidth = 0; - for (const QLabel *caption : captions) captionWidth = qMax(captionWidth, caption->sizeHint().width()); - for (QGridLayout *form : forms) form->setColumnMinimumWidth(0, captionWidth); - networkGroups_->addStretch(); - networkText_ = copy.join('\n'); -} - -void PropertiesDialog::rebuildCamera() -{ - const CameraProperties &data = snapshot_.camera; - const int existing = tabs_->indexOf(cameraPage_); - if (data.applicable && existing < 0) tabs_->insertTab(2, cameraPage_, tr("Camera")); - else if (!data.applicable && existing >= 0) { tabs_->removeTab(existing); cameraPage_->hide(); } - while (QLayoutItem *item = cameraGroups_->takeAt(0)) { delete item->widget(); delete item; } - cameraText_.clear(); - if (!data.applicable) return; - QStringList copy; - QVector captions; - QVector forms; - const auto group = [&](const QString &title) { - auto *box = new QGroupBox(title, cameraGroups_->parentWidget()); - auto *form = new QGridLayout(box); - form->setColumnStretch(1, 1); - forms.append(form); cameraGroups_->addWidget(box); - if (!copy.isEmpty()) copy.append(QString()); - copy.append(title); - return form; - }; - const auto field = [&](QGridLayout *form, const QString &caption, const QString &value, const QString &source) { - auto *name = new QLabel(caption, form->parentWidget()); - name->setTextFormat(Qt::PlainText); captions.append(name); - auto *label = plainLabel(value, form->parentWidget()); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - label->setSizePolicy(policy); - label->setMinimumWidth(120); - label->setAlignment(Qt::AlignLeading | Qt::AlignTop); - name->setToolTip("" + source.toHtmlEscaped() + ""); label->setToolTip(name->toolTip()); - const int row = form->count() / 2; - form->addWidget(name, row, 0, Qt::AlignLeading | Qt::AlignTop); form->addWidget(label, row, 1); - copy.append(caption + ": " + value); - }; - const auto errorText = [&](int error) { - if (error == EBUSY) return tr("Device busy"); - if (error == ETIMEDOUT) return tr("Query timed out"); - if (error == EAGAIN) return tr("Previous camera query has not exited; retry later"); - if (error == EOVERFLOW) return tr("Enumeration or report limit reached"); - if (error == EPROTO) return tr("Malformed driver response"); - const ReadState state = error == EACCES || error == EPERM ? ReadState::PermissionDenied - : error == ENODEV || error == ENOENT || error == ENXIO ? ReadState::Removed - : error == EINVAL || error == ENOTTY || error == EOPNOTSUPP ? ReadState::Unsupported : ReadState::Error; - return propertyReadValue({state, {}, error}); - }; - const QJsonObject &report = data.report; - const QJsonArray modes = report.value("modes").toArray(); - auto *identity = group(tr("Capture device")); - const QString querySource = data.node + ": VIDIOC_QUERYCAP via libv4l2 native passthrough"; - field(identity, tr("Device node"), data.node.isEmpty() ? tr("Unavailable") : data.node, device().path + " / udev devnode; verified sysfs dev and /sys/dev/char binding"); - QString status = data.state.state == ReadState::Available ? tr("Driver-reported snapshot") - : data.state.error ? errorText(data.state.error) : propertyReadValue(data.state); - if (data.state.state != ReadState::Available && !modes.isEmpty()) status = tr("Incomplete enumeration: %1").arg(status); - field(identity, tr("Query status"), status, data.node + ": read-only capability enumeration; two-second worker wait budget"); - const QVector> identityFields {{"card", tr("Device name")}, {"driver", tr("Driver")}, {"bus", tr("Bus information")}}; - for (const auto &entry : identityFields) { - const QString value = report.value(entry.first).toString(); - if (report.contains(entry.first)) field(identity, entry.second, value.isEmpty() ? tr("Unavailable") : value, querySource); - } - auto *scope = group(tr("Collection scope")); - field(scope, tr("Coverage"), tr("This capture node and current media pipeline. No capture or configuration changes. " - "Reported modes do not guarantee throughput or compatibility with other simultaneous streams."), querySource); - for (QGridLayout *form : forms) { - int width = 0; for (const QLabel *caption : captions) width = qMax(width, caption->sizeHint().width()); - form->setColumnMinimumWidth(0, width); - } - if (!modes.isEmpty()) { - const QString title = tr("Format, size and frame rate combinations"); - auto *box = new QGroupBox(title, cameraGroups_->parentWidget()); - auto *layout = new QVBoxLayout(box); - auto *table = new PropertyReportTable(static_cast(modes.size()), 4, box); - const QStringList headers {tr("Capture queue"), tr("Pixel format"), tr("Resolution / range"), tr("Frame rates / intervals")}; - table->setHorizontalHeaderLabels(headers); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->setSelectionBehavior(QAbstractItemView::SelectItems); - table->setWordWrap(true); - table->verticalHeader()->hide(); - table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - layout->addWidget(table); cameraGroups_->addWidget(box); - copy.append(QString()); copy.append(title); - const auto fractionText = [](const QJsonArray &f) { return QString::number(f.at(0).toInteger()) + '/' + QString::number(f.at(1).toInteger()); }; - const auto fps = [](const QJsonArray &f) { return static_cast(f.at(1).toInteger()) / static_cast(f.at(0).toInteger()); }; - for (qsizetype index = 0; index < modes.size(); ++index) { - const QJsonObject row = modes.at(index).toObject(); - const quint32 code = static_cast(row.value("format").toInteger()); - QString fourcc; - for (int shift : {0, 8, 16, 24}) { - const unsigned c = (code >> shift) & (shift == 24 ? 127 : 255); - fourcc += c >= 32 && c <= 126 ? QString(QChar(static_cast(c))) : QString("\\x%1").arg(c, 2, 16, QChar('0')); - } - QString format = fourcc + QString(" (0x%1)").arg(code, 8, 16, QChar('0')); - if (code & 0x80000000U) format += tr(" (big endian)"); - const QString description = row.value("description").toString(); - if (!description.isEmpty()) format += " — " + description; - const quint32 flags = static_cast(row.value("flags").toInteger()); - if (flags & 1) format += tr(" (compressed)"); - if (flags & 2) format += tr(" (emulated, reported by driver)"); - // Keep the complete driver report for Copy, with concise names in the table. - QString displayFormat; - if (fourcc == "MJPG") displayFormat = "MJPEG"; - else if (fourcc == "YUYV") displayFormat = "YUYV 4:2:2"; - else if (description == fourcc || description.startsWith(fourcc + ' ')) displayFormat = description; - else displayFormat = description.isEmpty() ? fourcc : fourcc + " — " + description; - if (code & 0x80000000U) displayFormat += tr(" (big endian)"); - if ((flags & 1) && fourcc != "MJPG") displayFormat += tr(" (compressed)"); - if (flags & 2) displayFormat += tr(" (emulated, reported by driver)"); - const QString kind = row.value("size_kind").toString(); - QString size; - if (row.contains("size_error")) size = errorText(row.value("size_error").toInt()); - else if (kind == "discrete") size = tr("%1 × %2").arg(row.value("width").toInteger()).arg(row.value("height").toInteger()); - else { - size = tr("%1–%2 × %3–%4 pixels").arg(row.value("width").toInteger()).arg(row.value("max_width").toInteger()) - .arg(row.value("height").toInteger()).arg(row.value("max_height").toInteger()); - size += kind == "continuous" ? tr(" (continuous)") - : tr(" (steps: %1 × %2 pixels)").arg(row.value("step_width").toInteger()).arg(row.value("step_height").toInteger()); - } - QString rates; - const QJsonObject interval = row.value("intervals").toObject(); - if (kind != "discrete") rates = tr("Not enumerated: intervals depend on the exact size"); - else if (interval.contains("error")) rates = errorText(interval.value("error").toInt()); - else if (interval.value("kind") == "discrete") { - QStringList values; - for (const QJsonValue &value : interval.value("values").toArray()) { - const QJsonArray f = value.toArray(); - values.append(tr("%1 fps (%2 s)").arg(QLocale().toString(fps(f), 'g', 8), fractionText(f))); - } - rates = values.join('\n'); - } else { - const QJsonArray minimum = interval.value("min").toArray(), maximum = interval.value("max").toArray(); - rates = tr("%1–%2 fps; intervals %3–%4 s").arg(QLocale().toString(fps(maximum), 'g', 8), QLocale().toString(fps(minimum), 'g', 8), fractionText(minimum), fractionText(maximum)); - rates += interval.value("kind") == "continuous" ? tr(" (continuous)") - : tr(" (interval step: %1 s)").arg(fractionText(interval.value("step").toArray())); - } - const QStringList values {row.value("queue") == "multi" ? tr("Multiple planes") : tr("Single plane"), displayFormat, size, rates}; - QStringList copyValues = values; - copyValues[1] = format; - const QString source = data.node + ": VIDIOC_ENUM_FMT / VIDIOC_ENUM_FRAMESIZES / VIDIOC_ENUM_FRAMEINTERVALS\n" - + tr("Intervals queried only for this pixel format and exact discrete size. Ranges are not expanded."); - copy.append(QString()); copy.append(tr("Mode %1").arg(index + 1)); - for (int column = 0; column < values.size(); ++column) { - auto *item = new QTableWidgetItem(values.at(column)); - item->setToolTip(source); item->setTextAlignment(Qt::AlignLeading | Qt::AlignTop); - table->setItem(static_cast(index), column, item); - copy.append(headers.at(column) + ": " + copyValues.at(column)); - } - } - table->fitContents(); - } - cameraGroups_->addStretch(); - cameraText_ = copy.join('\n'); -} - -void PropertiesDialog::rebuildDisplay() -{ - const bool applicable = device().subsystem == "drm" && device().sysname.contains('-'); - const int existing = tabs_->indexOf(displayPage_); - if (applicable && existing < 0) tabs_->insertTab(2, displayPage_, tr("Display")); - else if (!applicable && existing >= 0) { - tabs_->removeTab(existing); - displayPage_->hide(); - } - while (QLayoutItem *item = displayGroups_->takeAt(0)) { - delete item->widget(); - delete item; - } - displayText_.clear(); - if (!applicable) return; - const DisplayProperties &data = snapshot_.display; - QVector forms; - QVector captions; - QStringList copy; - const auto group = [&](const QString &title) { - auto *box = new QGroupBox(title, displayGroups_->parentWidget()); - auto *form = new QGridLayout(box); - form->setColumnStretch(1, 1); - forms.append(form); - displayGroups_->addWidget(box); - if (!copy.isEmpty()) copy.append(QString()); - copy.append(title); - return form; - }; - const auto field = [&](QGridLayout *form, const QString &caption, const QString &text, const QString &source = QString()) { - auto *name = new QLabel(caption, form->parentWidget()); - name->setTextFormat(Qt::PlainText); - captions.append(name); - auto *value = plainLabel(text, form->parentWidget()); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - value->setMinimumWidth(120); - const QString hint = "" + source.toHtmlEscaped() + ""; - name->setToolTip(hint); - value->setToolTip(hint); - const int row = form->count() / 2; - form->addWidget(name, row, 0, Qt::AlignLeading | Qt::AlignTop); - form->addWidget(value, row, 1); - copy.append(caption + ": " + text); - }; - const auto alignCaptions = [&] { - int captionWidth = 0; - for (const QLabel *caption : captions) captionWidth = qMax(captionWidth, caption->sizeHint().width()); - for (QGridLayout *form : forms) form->setColumnMinimumWidth(0, captionWidth); - }; - const MonitorProperties &monitor = snapshot_.monitor; - if (monitor.applicable) { - const auto hardwareValue = [&](const QString &key) { return propertyReadValue(monitor.values.value(key)); }; - const auto hardwareField = [&](QGridLayout *form, const QString &caption, const QString &key) { - field(form, caption, hardwareValue(key), monitor.sources.value(key)); - }; - auto *identity = group(tr("Monitor hardware (reported)")); - QString descriptorStatus; - if (monitor.descriptor.state == ReadState::Available) { - descriptorStatus = monitor.values.contains("warnings") ? tr("Decoded with conformance warnings") : tr("Decoded by libdisplay-info"); - } else if (monitor.descriptor.state == ReadState::Unsupported && monitor.descriptor.value.isEmpty()) { - descriptorStatus = tr("EDID decoding unavailable: build with libdisplay-info support"); - } else if (monitor.descriptor.state == ReadState::Unsupported) { - descriptorStatus = tr("Unsupported EDID version: %1").arg(monitor.descriptor.value); - } else if (monitor.descriptor.state == ReadState::Error && monitor.descriptor.error == EPROTO) { - descriptorStatus = tr("Malformed EDID (invalid header, length, checksum or parser rejection)"); - } else if (monitor.descriptor.state == ReadState::Error && monitor.descriptor.error == EAGAIN) { - descriptorStatus = tr("Monitor report changed during collection; reload properties"); - } else descriptorStatus = propertyReadValue(monitor.descriptor); - field(identity, tr("Descriptor"), descriptorStatus, monitor.sources.value("descriptor")); - if (monitor.values.contains("version")) hardwareField(identity, tr("EDID version"), "version"); - if (monitor.descriptor.state == ReadState::Available) { - hardwareField(identity, tr("Manufacturer"), "manufacturer"); - hardwareField(identity, tr("Manufacturer code"), "manufacturer_code"); - hardwareField(identity, tr("Model"), "model"); - hardwareField(identity, tr("Product code"), "product"); - hardwareField(identity, tr("Serial number"), "serial"); - const Attribute size = monitor.values.value("size"); - const QStringList dimensions = size.value.split(' '); - field(identity, tr("Image dimensions"), size.state == ReadState::Available - ? tr("%1 × %2 cm").arg(dimensions.value(0), dimensions.value(1)) : propertyReadValue(size), monitor.sources.value("size")); - if (monitor.values.contains("warnings")) hardwareField(identity, tr("Conformance warnings"), "warnings"); - auto *capabilities = group(tr("Monitor input (reported)")); - const Attribute digital = monitor.values.value("digital"); - field(capabilities, tr("Signal type"), digital.state == ReadState::Available - ? (digital.value == "1" ? tr("Digital") : tr("Analog")) : propertyReadValue(digital), monitor.sources.value("digital")); - if (digital.state == ReadState::Available && digital.value == "1") { - hardwareField(capabilities, tr("Input interface"), "interface"); - const Attribute depth = monitor.values.value("depth"); - field(capabilities, tr("Declared color depth"), depth.state == ReadState::Available - ? tr("%1 bits per color component").arg(depth.value) : propertyReadValue(depth), monitor.sources.value("depth")); - } - } - auto *reportedModes = group(tr("Kernel-reported modes")); - hardwareField(reportedModes, tr("Mode names"), "modes"); - field(reportedModes, tr("Scope"), tr("Cached mode names; refresh rates are not exposed here. " - "The list may include driver additions or restrictions and does not guarantee monitor capabilities."), monitor.sources.value("modes")); - } - if (data.status.state == ReadState::Available && data.status.value == "disconnected") { - auto *session = group(tr("Desktop session")); - field(session, tr("Configuration"), tr("Disconnected"), data.sources.value("configuration")); - alignCaptions(); - displayGroups_->addStretch(); - displayText_ = copy.join('\n'); - return; - } - const auto raw = [&](const QString &key) { - return data.status.state == ReadState::Available ? data.values.value(key) : Attribute {data.status.state, {}, data.status.error}; - }; - const auto formatted = [&](const QString &key) { - const Attribute value = raw(key); - if (value.state != ReadState::Available) return propertyReadValue(value); - if (key == "resolution") return QString(value.value).replace('x', QChar(0x00d7)); - if (key == "depth") return tr("%1 bits per pixel").arg(value.value); - if (key == "refresh") return tr("%1 Hz").arg(QLocale().toString(value.value.toDouble(), 'f', 2)); - if (key == "rotation") return value.value == "0" ? tr("0°") - : tr("%1° counterclockwise").arg(value.value); - if (key == "reflection") return value.value == "xy" ? tr("X and Y axes") : value.value == "x" ? tr("X axis") - : value.value == "y" ? tr("Y axis") : value.value == "vertical" ? tr("Vertical axis") : tr("None"); - if (key == "scale") { - if (value.value == "custom") return tr("Custom transform"); - const QStringList axes = value.value.split(' '); - const double x = axes.value(0).toDouble(), y = axes.value(1).toDouble(); - QString text = axes.value(0) == axes.value(1) ? tr("%1×").arg(QLocale().toString(x, 'g', 6)) - : tr("X: %1×, Y: %2×").arg(QLocale().toString(x, 'g', 6), QLocale().toString(y, 'g', 6)); - const QString kind = data.values.value("scale_kind").value; - if (kind == "derived") text += tr(" (derived)"); - if (kind == "integer") text += tr(" (integer buffer scale)"); - return text; - } - return value.value; - }; - auto *session = group(tr("Desktop session")); - field(session, tr("Backend"), data.backend.isEmpty() ? tr("Unavailable") : data.backend); - field(session, tr("Output"), data.output.isEmpty() ? tr("Unavailable") : data.output, device().path); - field(session, tr("Configuration"), data.status.state == ReadState::Available - ? (data.status.value == "inactive" ? tr("No active mode") : tr("Reported by desktop server")) - : propertyReadValue(data.status), tr("Read-only session query. Unavailable can mean missing runtime libraries, " - "an output not exposed by this session, or a protocol without usable output names.")); - auto *mode = group(tr("Active display mode")); - field(mode, tr("Resolution"), formatted("resolution"), data.sources.value("resolution")); - field(mode, tr("Desktop color depth"), formatted("depth"), data.sources.value("depth")); - field(mode, tr("Refresh rate"), formatted("refresh"), data.sources.value("refresh")); - auto *transform = group(tr("Orientation and scale")); - field(transform, tr("Rotation"), formatted("rotation"), data.sources.value("rotation")); - field(transform, tr("Reflection"), formatted("reflection"), data.sources.value("reflection")); - field(transform, tr("Output scale"), formatted("scale"), data.sources.value("scale")); - alignCaptions(); - displayGroups_->addStretch(); - displayText_ = copy.join('\n'); -} - -void PropertiesDialog::rebuildUsb() -{ - const bool applicable = device().subsystem == "usb" && device().devtype == "usb_device"; - const int existing = tabs_->indexOf(usbPage_); - if (applicable && existing < 0) tabs_->insertTab(2, usbPage_, tr("USB")); - else if (!applicable && existing >= 0) { - tabs_->removeTab(existing); - usbPage_->hide(); - } - while (QLayoutItem *item = usbGroups_->takeAt(0)) { - delete item->widget(); - delete item; - } - usbText_.clear(); - if (!applicable) return; - const UsbProperties &data = snapshot_.usb; - QVector forms; - QVector captions; - QStringList copy; - const auto group = [&](const QString &title) { - auto *box = new QGroupBox(title, usbGroups_->parentWidget()); - auto *form = new QGridLayout(box); - form->setColumnStretch(1, 1); - forms.append(form); - usbGroups_->addWidget(box); - if (!copy.isEmpty()) copy.append(QString()); - copy.append(title); - return form; - }; - const auto field = [&](QGridLayout *form, const QString &caption, const QString &text, const QString &source = QString()) { - auto *name = new QLabel(caption, form->parentWidget()); - name->setTextFormat(Qt::PlainText); - captions.append(name); - auto *value = plainLabel(text, form->parentWidget()); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - value->setMinimumWidth(120); - const QString hint = "" + source.toHtmlEscaped() + ""; - name->setToolTip(hint); - value->setToolTip(hint); - const int row = form->count() / 2; - form->addWidget(name, row, 0, Qt::AlignLeading | Qt::AlignTop); - form->addWidget(value, row, 1); - copy.append(caption + ": " + text); - }; - const auto value = [&](const QString &key) { return propertyReadValue(data.values.value(key)); }; - const auto source = [&](const QString &key) { return device().path + '/' + key; }; - auto *connection = group(tr("Connection")); - const Attribute version = snapshot_.values.value("sysfs/version"); - field(connection, tr("USB version"), propertyReadValue(version), source("version") - + '\n' + tr("USB specification version reported by the device descriptor; not the port's maximum capability.")); - const Attribute speed = snapshot_.values.value("sysfs/speed"); - QString speedText = propertyReadValue(speed); - if (speed.state == ReadState::Available) { - bool valid = false; - const double mbps = speed.value.toDouble(&valid); - if (speed.value.compare("unknown", Qt::CaseInsensitive) == 0) - speedText = propertyReadValue({ReadState::Unavailable, {}, 0}); - else if (!valid || !std::isfinite(mbps) || mbps <= 0) - speedText = propertyReadValue({ReadState::Error, {}, EINVAL}); - else speedText = mbps >= 1000 - ? tr("%1 Gbps").arg(QLocale().toString(mbps / 1000, 'g', 6)) - : tr("%1 Mbps").arg(QLocale().toString(mbps, 'g', 6)); - } - field(connection, tr("Connection speed"), speedText, source("speed") - + '\n' + tr("Kernel-reported link rate, not measured throughput. For root hubs this describes the USB bus.")); - auto *power = group(tr("Power declarations")); - const Attribute attributes = data.values.value("bmAttributes"); - bool valid = false; - const uint flags = attributes.value.toUInt(&valid, 16); - QString supply = propertyReadValue(attributes), wake = supply; - if (attributes.state == ReadState::Available) { - if (!valid || flags > 255 || !(flags & 0x80)) { - supply = wake = propertyReadValue({ReadState::Error, {}, EINVAL}); - } else { - supply = flags & 0x40 ? tr("Self-powered") : tr("Bus-powered"); - wake = flags & 0x20 ? tr("Supported") : tr("Not supported"); - } - } - field(power, tr("Power source"), supply, source("bmAttributes")); - field(power, tr("Declared maximum current"), value("bMaxPower"), source("bMaxPower")); - field(power, tr("Remote wakeup capability"), wake, source("bmAttributes")); - auto *configuration = group(tr("Configuration")); - const Attribute active = data.values.value("bConfigurationValue"); - field(configuration, tr("Active configuration"), active.state == ReadState::Available && (active.value.isEmpty() || active.value == "0") - ? tr("Unconfigured") : propertyReadValue(active), source("bConfigurationValue")); - field(configuration, tr("Available configurations"), value("bNumConfigurations"), source("bNumConfigurations")); - const Attribute description = data.values.value("configuration"); - field(configuration, tr("Description"), description.state == ReadState::Available && description.value.isEmpty() - ? tr("Not provided") : propertyReadValue(description), source("configuration")); - field(configuration, tr("Interface count"), value("bNumInterfaces"), source("bNumInterfaces")); - // Class descriptions are deliberately limited; retain numeric codes for - // vendor-specific and unfamiliar functions rather than guessing a name. - const auto classText = [&](const Attribute &attribute) { - if (attribute.state != ReadState::Available) return propertyReadValue(attribute); - bool ok = false; - const uint code = attribute.value.toUInt(&ok, 16); - if (!ok || code > 255) return propertyReadValue({ReadState::Error, {}, EINVAL}); - QString name; - switch (code) { - case 0x01: name = tr("Audio"); break; - case 0x02: name = tr("Communications"); break; - case 0x03: name = tr("Human interface device"); break; - case 0x07: name = tr("Printer"); break; - case 0x08: name = tr("Mass storage"); break; - case 0x09: name = tr("Hub"); break; - case 0x0a: name = tr("CDC data"); break; - case 0x0b: name = tr("Smart card"); break; - case 0x0e: name = tr("Video"); break; - case 0xe0: name = tr("Wireless controller"); break; - case 0xef: name = tr("Miscellaneous"); break; - case 0xfe: name = tr("Application-specific"); break; - case 0xff: name = tr("Vendor-specific"); break; - } - const QString hex = QStringLiteral("0x%1").arg(code, 2, 16, QLatin1Char('0')); - return name.isEmpty() ? hex : tr("%1 (%2)").arg(name, hex); - }; - if (data.interfaceState.state != ReadState::Available || data.interfaces.isEmpty()) { - auto *interfaces = group(tr("Interfaces")); - field(interfaces, tr("Interfaces"), data.interfaceState.state == ReadState::Available - ? tr("None observed") : propertyReadValue(data.interfaceState), device().path); - } - for (const UsbInterfaceProperties &interface : data.interfaces) { - auto *form = group(tr("Interface %1").arg(interface.name)); - if (interface.values.contains("state")) { - field(form, tr("Status"), propertyReadValue(interface.values.value("state")), interface.path); - continue; - } - const auto entry = [&](const QString &key, const QString &caption) { - field(form, caption, propertyReadValue(interface.values.value(key)), interface.path + '/' + key); - }; - entry("bInterfaceNumber", tr("Number (hex)")); - entry("bAlternateSetting", tr("Alternate setting")); - const Attribute label = interface.values.value("interface"); - field(form, tr("Description"), label.state == ReadState::Available && label.value.isEmpty() - ? tr("Not provided") : propertyReadValue(label), interface.path + "/interface"); - field(form, tr("Function class"), classText(interface.values.value("bInterfaceClass")), interface.path + "/bInterfaceClass"); - entry("bInterfaceSubClass", tr("Subclass (hex)")); - entry("bInterfaceProtocol", tr("Protocol (hex)")); - const Attribute driver = interface.values.value("driver"); - field(form, tr("Bound driver"), driver.state == ReadState::Available && driver.value.isEmpty() - ? tr("Unbound") : propertyReadValue(driver), interface.path + "/driver"); - } - int captionWidth = 0; - for (const QLabel *caption : captions) captionWidth = qMax(captionWidth, caption->sizeHint().width()); - for (QGridLayout *form : forms) form->setColumnMinimumWidth(0, captionWidth); - usbGroups_->addStretch(); - usbText_ = copy.join('\n'); -} - -void PropertiesDialog::rebuildPcie() -{ - const bool applicable = device().subsystem == "pci" && snapshot_.pcieApplicable; - const int existing = tabs_->indexOf(pciePage_); - if (applicable && existing < 0) tabs_->insertTab(2, pciePage_, tr("PCIe")); - else if (!applicable && existing >= 0) { - tabs_->removeTab(existing); - pciePage_->hide(); - } - while (QLayoutItem *item = pcieGroups_->takeAt(0)) { - delete item->widget(); - delete item; - } - pcieText_.clear(); - if (!applicable) return; - QVector forms; - QVector captions; - QStringList copy; - const auto group = [&](const QString &title) { - auto *box = new QGroupBox(title, pcieGroups_->parentWidget()); - auto *form = new QGridLayout(box); - form->setColumnStretch(1, 1); - forms.append(form); - pcieGroups_->addWidget(box); - if (!copy.isEmpty()) copy.append(QString()); - copy.append(title); - return form; - }; - const auto field = [&](QGridLayout *form, const QString &caption, const QString &text, const QString &source = QString()) { - auto *name = new QLabel(caption, form->parentWidget()); - name->setTextFormat(Qt::PlainText); - captions.append(name); - auto *value = plainLabel(text, form->parentWidget()); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - value->setMinimumWidth(120); - const QString hint = "" + source.toHtmlEscaped() + ""; - name->setToolTip(hint); - value->setToolTip(hint); - const int row = form->count() / 2; - form->addWidget(name, row, 0, Qt::AlignLeading | Qt::AlignTop); - form->addWidget(value, row, 1); - copy.append(caption + ": " + text); - }; - const auto linkValue = [&](const QString &key, bool speed) { - const Attribute value = snapshot_.values.value("sysfs/" + key); - if (value.state == ReadState::Unsupported) return tr("Query unsupported by the kernel/device"); - if (value.state != ReadState::Available) return propertyReadValue(value); - const QString raw = value.value; - const auto malformed = [&] { return tr("Malformed reported value: %1").arg(raw.isEmpty() ? tr("(empty)") : raw); }; - if (raw.isEmpty()) return malformed(); - if (speed) { - if (raw.compare("Unknown speed", Qt::CaseInsensitive) == 0 - || raw.compare("unknown", Qt::CaseInsensitive) == 0) - return tr("Unavailable (%1)").arg(raw); - // Parse the kernel's C-locale rate, including its usual .0 suffix. - // Unfamiliar formats and future rates remain visible verbatim. - QString rateText = raw; - if (rateText.endsWith(" PCIe")) rateText.chop(5); - if (!rateText.endsWith(" GT/s")) return raw; - rateText.chop(5); - bool valid = false; - const double rate = rateText.toDouble(&valid); - if (!valid || !std::isfinite(rate) || rate <= 0) return malformed(); - const QVector rates {2.5, 5, 8, 16, 32, 64, 128}; - for (int i = 0; i < rates.size(); ++i) { - if (rate == rates[i]) return tr("%1 (PCIe Gen %2)").arg(raw).arg(i + 1); - } - return raw; - } - bool valid = false; - const qulonglong lanes = raw.toULongLong(&valid); - if (!valid || raw.startsWith('-')) return malformed(); - // max_link_width exposes PCIE_LNK_WIDTH_UNKNOWN (0xff) verbatim. - // It is a kernel sentinel, not a 255-lane capability. Actual PCIe - // link width fields are six bits wide. - if (key == "max_link_width" && lanes == 255) - return tr("Unavailable (kernel reports unknown width: %1)").arg(raw); - if (lanes > 63) return malformed(); - if (!lanes) return tr("Unavailable (reported width: %1)").arg(raw); - return tr("x%1 (%2 lanes)").arg(QString::number(lanes), QLocale().toString(lanes)); - }; - const auto entry = [&](QGridLayout *form, const QString &key, const QString &caption, bool speed, const QString &scope) { - field(form, caption, linkValue(key, speed), snapshot_.sources.value("sysfs/" + key) + '\n' + scope); - }; - const Attribute type = snapshot_.pcieType; - bool validType = false; - const uint number = type.value.toUInt(&validType); - validType = validType && type.state == ReadState::Available; - QString typeText = propertyReadValue(type); - if (type.state == ReadState::Error && type.error == EPROTO) - typeText = tr("Malformed PCI capability data"); - else if (type.state == ReadState::Error && type.error == EMSGSIZE) - typeText = tr("Incomplete PCI configuration data"); - if (validType) { - switch (number) { - case 0: typeText = tr("PCIe endpoint"); break; - case 1: typeText = tr("Legacy PCIe endpoint"); break; - case 4: typeText = tr("Root port"); break; - case 5: typeText = tr("Upstream switch port"); break; - case 6: typeText = tr("Downstream switch port"); break; - case 7: typeText = tr("PCIe-to-PCI/PCI-X bridge"); break; - case 8: typeText = tr("PCI/PCI-X-to-PCIe bridge"); break; - case 9: typeText = tr("Root Complex Integrated Endpoint"); break; - case 10: typeText = tr("Root Complex Event Collector"); break; - default: typeText = tr("Unrecognized PCIe device type: %1").arg(type.value); break; - } - } - auto *connection = group(tr("Connection")); - field(connection, tr("Connection type"), typeText, snapshot_.pcieTypeSource); - if (snapshot_.pcieTypeRequiresRoot) { - field(connection, tr("Access"), tr("Run the application as root or with sudo to read this device's PCIe connection type."), snapshot_.pcieTypeSource); - } - // Both root-complex types lack conventional PCIe link registers. This - // identifies connection topology, not GPU integration or performance. - const bool internal = validType && (number == 9 || number == 10); - if (internal) { - field(connection, tr("PCIe link"), tr("Not applicable — integrated into the root complex; no external PCIe link."), snapshot_.pcieTypeSource); - } else { - const QString currentScope = tr("Current link reported for this device; an on-demand reading, not measured throughput or a fault verdict."); - auto *current = group(tr("Current link")); - entry(current, "current_link_speed", tr("Link speed"), true, currentScope); - entry(current, "current_link_width", tr("Link width"), false, currentScope); - const QString capabilityScope = tr("Maximum capability reported for this device, not the slot or entire upstream path. Speed and width maxima are separate declarations."); - auto *capability = group(tr("Device capability")); - entry(capability, "max_link_speed", tr("Maximum link speed"), true, capabilityScope); - entry(capability, "max_link_width", tr("Maximum link width"), false, capabilityScope); - auto *scope = group(tr("Interpretation")); - field(scope, tr("Upstream path"), tr("Not assessed; upstream links may impose additional limits.")); - field(scope, tr("Current vs. maximum"), tr("A lower current link speed or width is not automatically a fault. Power management and topology can explain differences.")); - } - int captionWidth = 0; - for (const QLabel *caption : captions) captionWidth = qMax(captionWidth, caption->sizeHint().width()); - for (QGridLayout *form : forms) form->setColumnMinimumWidth(0, captionWidth); - pcieGroups_->addStretch(); - pcieText_ = copy.join('\n'); -} - -void PropertiesDialog::rebuildBattery() -{ - const BatteryProperties &data = snapshot_.battery; - const bool applicable = device().subsystem == "power_supply" && data.applicable; - const int existing = tabs_->indexOf(batteryPage_); - if (applicable && existing < 0) tabs_->insertTab(2, batteryPage_, tr("Battery")); - else if (!applicable && existing >= 0) { - tabs_->removeTab(existing); - batteryPage_->hide(); - } - while (QLayoutItem *item = batteryGroups_->takeAt(0)) { - delete item->widget(); - delete item; - } - batteryText_.clear(); - if (!applicable) return; - QVector forms; - QVector captions; - QStringList copy; - const auto group = [&](const QString &title) { - auto *box = new QGroupBox(title, batteryGroups_->parentWidget()); - auto *form = new QGridLayout(box); - form->setColumnStretch(1, 1); - forms.append(form); - batteryGroups_->addWidget(box); - if (!copy.isEmpty()) copy.append(QString()); - copy.append(title); - return form; - }; - const auto field = [&](QGridLayout *form, const QString &caption, const QString &text, const QString &source = QString()) { - auto *name = new QLabel(caption, form->parentWidget()); - name->setTextFormat(Qt::PlainText); - captions.append(name); - auto *value = plainLabel(text, form->parentWidget()); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - value->setMinimumWidth(120); - const QString hint = "" + source.toHtmlEscaped() + ""; - name->setToolTip(hint); - value->setToolTip(hint); - const int row = form->count() / 2; - form->addWidget(name, row, 0, Qt::AlignLeading | Qt::AlignTop); - form->addWidget(value, row, 1); - copy.append(caption + ": " + text); - }; - const auto source = [&](const QString &key) { return device().path + '/' + key; }; - const auto readValue = [&](const Attribute &value) { - return value.state == ReadState::Unsupported || value.error == EOPNOTSUPP || value.error == ENOSYS - ? tr("Query unsupported by the kernel/device") : propertyReadValue(value); - }; - const auto textValue = [&](const QString &key) { - const Attribute value = data.values.value(key); - if (value.state != ReadState::Available) return readValue(value); - if (value.value.isEmpty()) return tr("Not reported"); - if (value.value == "Unknown") return tr("Unknown (reported)"); - if (key == "status") { - if (value.value == "Charging") return tr("Charging"); - if (value.value == "Discharging") return tr("Discharging"); - if (value.value == "Not charging") return tr("Not charging"); - if (value.value == "Full") return tr("Full"); - } - return value.value; - }; - // Sysfs numeric battery properties are decimal integers, in micro-units. - // Preserve signed current/power readings, and never derive one from the other. - const auto numberValue = [&](const QString &key, double divisor, const QString &unit, bool signedReading = false) { - const Attribute value = data.values.value(key); - if (value.state != ReadState::Available) return readValue(value); - bool valid = false; - const qlonglong number = value.value.toLongLong(&valid); - if (!valid || (!signedReading && number < 0) || (key == "capacity" && number > 100)) - return tr("Malformed reported value: %1").arg(value.value.isEmpty() ? tr("(empty)") : value.value); - if (!number && (key == "cycle_count" || key.endsWith("_full") || key.endsWith("_full_design"))) - return tr("Unavailable (reported value: 0)"); - const QString formatted = divisor == 1 ? QLocale().toString(number) - : QLocale().toString(static_cast(number) / divisor, 'f', 3); - return unit.isEmpty() ? formatted : formatted + ' ' + unit; - }; - auto *current = group(tr("Current state")); - if (data.state.state != ReadState::Available) { - QString state = readValue(data.state); - if (data.state.error == EAGAIN) state = tr("Battery type or presence changed during collection; reload properties."); - else if (data.state.error == EINVAL && data.values.contains("present")) - state = tr("Malformed battery presence value: %1").arg(data.values.value("present").value); - field(current, tr("Collection status"), state, source("type") + '\n' + source("present")); - if (data.values.contains("type")) field(current, tr("Supply type"), textValue("type"), source("type")); - } else { - const Attribute present = data.values.value("present"); - const QString presence = present.state == ReadState::Available - ? (present.value == "1" ? tr("Present") : tr("Absent")) : readValue(present); - field(current, tr("Battery presence"), presence, source("present")); - field(current, tr("Status"), textValue("status"), source("status")); - field(current, tr("Charge percentage"), numberValue("capacity", 1, "%"), source("capacity")); - field(current, tr("Voltage"), numberValue("voltage_now", 1000000, "V"), source("voltage_now")); - field(current, tr("Current"), numberValue("current_now", 1000000, "A", true), source("current_now") - + '\n' + tr("Driver-reported instantaneous current; the reported sign is preserved. Charging state is reported separately.")); - field(current, tr("Power"), numberValue("power_now", 1000000, "W", true), source("power_now") - + '\n' + tr("Driver-reported instantaneous power; not calculated from voltage and current.")); - const auto retention = [&](const QString &prefix) { - const Attribute full = data.values.value(prefix + "_full"); - const Attribute design = data.values.value(prefix + "_full_design"); - if (full.state == ReadState::NotApplicable || design.state == ReadState::NotApplicable) - return readValue({ReadState::NotApplicable, {}, 0}); - if (full.state != ReadState::Available || design.state != ReadState::Available) - return tr("Unavailable — requires compatible full and design readings"); - bool fullValid = false, designValid = false; - const qlonglong fullValue = full.value.toLongLong(&fullValid); - const qlonglong designValue = design.value.toLongLong(&designValid); - if (!fullValid || !designValid || fullValue <= 0 || designValue <= 0) - return tr("Unavailable — requires positive full and design readings"); - const double percent = 100.0 * static_cast(fullValue) / static_cast(designValue); - return tr("%1 % (estimate)").arg(QLocale().toString(percent, 'f', 1)); - }; - const auto hasCapacity = [&](const QString &prefix) { - for (const QString &suffix : {QStringLiteral("_full_design"), QStringLiteral("_full"), QStringLiteral("_now")}) { - const Attribute value = data.values.value(prefix + suffix); - if (value.state != ReadState::Unavailable || value.error != ENOENT) return true; - } - return false; - }; - const bool energy = hasCapacity("energy"), charge = hasCapacity("charge"); - const auto capacityGroup = [&](const QString &prefix, const QString &title, double divisor, const QString &unit) { - auto *form = group(title); - field(form, tr("Design capacity"), numberValue(prefix + "_full_design", divisor, unit), source(prefix + "_full_design")); - field(form, tr("Last full-charge capacity"), numberValue(prefix + "_full", divisor, unit), source(prefix + "_full") - + '\n' + tr("Firmware-reported learned full capacity; it can depend on temperature, age and calibration.")); - field(form, tr("Remaining capacity"), numberValue(prefix + "_now", divisor, unit), source(prefix + "_now")); - field(form, tr("Estimated capacity retention"), retention(prefix), source(prefix + "_full") + '\n' - + source(prefix + "_full_design") + '\n' + tr("100 × full / design using matching units; not a complete battery health verdict. Values above 100% are not clamped.")); - }; - if (energy) capacityGroup("energy", tr("Energy capacity"), 1000000, "Wh"); - if (charge) capacityGroup("charge", tr("Charge capacity"), 1000, "mAh"); - if (!energy && !charge) { - auto *form = group(tr("Capacity")); - field(form, tr("Reported capacities"), tr("Unavailable — no energy or charge capacity attributes exposed"), device().path); - field(form, tr("Estimated capacity retention"), tr("Unavailable — requires compatible full and design readings"), device().path); - } - auto *identity = group(tr("Battery information")); - field(identity, tr("Cycle count"), numberValue("cycle_count", 1, QString()), source("cycle_count") - + '\n' + tr("Reported full charge/discharge cycles. The kernel ABI defines zero as unavailable.")); - field(identity, tr("Chemistry"), textValue("technology"), source("technology")); - field(identity, tr("Manufacturer"), textValue("manufacturer"), source("manufacturer")); - field(identity, tr("Model"), textValue("model_name"), source("model_name")); - field(identity, tr("Serial number"), textValue("serial_number"), source("serial_number")); - } - int captionWidth = 0; - for (const QLabel *caption : captions) captionWidth = qMax(captionWidth, caption->sizeHint().width()); - for (QGridLayout *form : forms) form->setColumnMinimumWidth(0, captionWidth); - batteryGroups_->addStretch(); - batteryText_ = copy.join('\n'); -} - -void PropertiesDialog::rebuildStorage() -{ - for (const auto &page : {qMakePair(storagePage_, tr("Storage")), - qMakePair(volumesPage_, tr("Volumes")), qMakePair(healthPage_, tr("Health"))}) { - const int existing = tabs_->indexOf(page.first); - if (snapshot_.storage.applicable && existing < 0) tabs_->addTab(page.first, page.second); - else if (!snapshot_.storage.applicable && existing >= 0) { - tabs_->removeTab(existing); - page.first->hide(); - } - } - const QString previousVolume = volumesTable_->currentRow() >= 0 - ? volumesTable_->item(volumesTable_->currentRow(), 0)->data(Qt::UserRole).toString() : QString(); - const QString previousDrive = healthDrive_->currentData().toString(); - storagePresentation_ = storagePresentation(snapshot_.storage, device().path); - const QString scope = tr("Read-only metadata snapshot. Each entity is a separate layer; capacities are not added. " - "Kernel and UDisks2 block records are merged only by their exact device-number mapping. " - "Source paths remain in tooltips and copied text. Deep search includes native metadata only."); - const auto hint = [](QLabel *label, const QString &text) { - label->setToolTip("" + text.toHtmlEscaped().replace('\n', "
") + "
"); - }; - hint(storageNotice_, scope + "\n\n" + storagePresentation_.notes); - hint(volumesNotice_, scope + '\n' + tr("Related volumes may span several drives. Mount points cover only the application " - "mount namespace. No filesystem is probed, mounted or unlocked.") + "\n\n" + storagePresentation_.notes); - hint(healthNotice_, tr("Cached UDisks2 health only, separate from device operational status. " - "Reload does not request SMART updates or self-tests. Missing evidence is unavailable; " - "no reported warning does not guarantee health. Related drives are shown separately.") - + "\n\n" + storagePresentation_.notes); - showStorageFields(storage_, storagePresentation_.overview); - { - const QSignalBlocker blocker(volumesTable_); - volumesTable_->setRowCount(0); - int selected = -1; - for (const StorageDisplayRow &entry : storagePresentation_.volumes) { - const int row = volumesTable_->rowCount(); - volumesTable_->insertRow(row); - const QStringList columns {entry.name, entry.kind, entry.capacity, entry.filesystem, entry.mounts}; - for (int column = 0; column < columns.size(); ++column) { - auto *item = new QTableWidgetItem(columns[column]); - item->setToolTip(entry.source.toHtmlEscaped()); - volumesTable_->setItem(row, column, item); - } - volumesTable_->item(row, 0)->setData(Qt::UserRole, entry.id); - if (entry.id == previousVolume || (previousVolume.isEmpty() && entry.id == device().path)) selected = row; - } - if (selected < 0 && volumesTable_->rowCount()) selected = 0; - volumesTable_->setCurrentCell(selected, 0); - } - showVolume(); - { - const QSignalBlocker blocker(healthDrive_); - healthDrive_->clear(); - for (const StorageDisplayRow &entry : storagePresentation_.health) { - healthDrive_->addItem(entry.name, entry.id); - healthDrive_->setItemData(healthDrive_->count() - 1, entry.source.toHtmlEscaped(), Qt::ToolTipRole); - } - int selected = healthDrive_->findData(previousDrive); - if (selected < 0 && !storagePresentation_.preferredHealthDrive.isEmpty()) - selected = healthDrive_->findData(storagePresentation_.preferredHealthDrive); - healthDrive_->setCurrentIndex(selected < 0 ? 0 : selected); - } - showHealth(); -} -void PropertiesDialog::showVolume() -{ - const int row = volumesTable_->currentRow(); - if (row < 0 || row >= storagePresentation_.volumes.size()) { - clearForm(volumeDetails_); - volumeDetails_->addRow(plainLabel(tr("No related volume metadata available."), volumeDetails_->parentWidget())); - return; - } - const StorageDisplayRow &entry = storagePresentation_.volumes[row]; - showStorageFields(volumeDetails_, entry.fields); -} -void PropertiesDialog::showHealth() -{ - while (QLayoutItem *item = healthGroups_->takeAt(0)) { - delete item->widget(); - delete item; - } - const int row = healthDrive_->currentIndex(); - if (row < 0 || row >= storagePresentation_.health.size()) { - healthGroups_->addWidget(plainLabel(tr("Cached health unavailable."), healthGroups_->parentWidget())); - healthGroups_->addStretch(); - return; - } - const StorageDisplayRow &entry = storagePresentation_.health[row]; - QVector forms; - QVector captions; - const auto group = [&](const QString &title, const QVector &fields) { - auto *box = new QGroupBox(title, healthGroups_->parentWidget()); - auto *form = new QGridLayout(box); - form->setColumnStretch(1, 1); - forms.append(form); - healthGroups_->addWidget(box); - int fieldRow = 0; - for (const StorageDisplayField &field : fields) { - auto *caption = new QLabel(field.label, box); - caption->setTextFormat(Qt::PlainText); - captions.append(caption); - auto *value = plainLabel(field.value, box); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - value->setMinimumWidth(120); - const QString hint = "" + field.source.toHtmlEscaped().replace('\n', "
") + "
"; - caption->setToolTip(hint); - value->setToolTip(hint); - form->addWidget(caption, fieldRow, 0, Qt::AlignLeading | Qt::AlignTop); - form->addWidget(value, fieldRow++, 1); - } - }; - if (!entry.fields.isEmpty()) group(tr("Health"), entry.fields); - for (const StorageDisplayGroup §ion : entry.healthGroups) group(section.title, section.fields); - int captionWidth = 0; - for (const QLabel *caption : captions) captionWidth = qMax(captionWidth, caption->sizeHint().width()); - for (QGridLayout *form : forms) form->setColumnMinimumWidth(0, captionWidth); - if (!entry.ataAttributes.isEmpty()) { - auto *box = new QGroupBox(tr("ATA SMART attributes"), healthGroups_->parentWidget()); - auto *layout = new QVBoxLayout(box); - auto *table = new PropertyReportTable(static_cast(entry.ataAttributes.size()), 7, box); - table->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); - table->setHorizontalHeaderLabels({tr("ID"), tr("Name"), tr("Current"), tr("Worst"), tr("Threshold"), tr("Interpreted value"), tr("Flags")}); - const QStringList hints { - tr("Attribute ID; the drive-specific name is the authoritative identifier."), - tr("UDisks2's drive-specific attribute identifier."), - tr("Current normalized value; not necessarily a percentage."), - tr("Worst normalized value reported by the drive."), - tr("Drive-reported normalized threshold."), - tr("UDisks2's interpreted value, not smartctl's raw attribute bytes."), - tr("Raw attribute flags: bit 0 indicates pre-failure/old-age; bit 1 indicates online/offline collection.") - }; - for (int column = 0; column < table->columnCount(); ++column) { - table->horizontalHeaderItem(column)->setToolTip(hints[column]); - table->horizontalHeader()->setSectionResizeMode(column, - column == 1 || column == 5 ? QHeaderView::Stretch : QHeaderView::ResizeToContents); - } - table->verticalHeader()->hide(); - table->setAlternatingRowColors(true); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::ExtendedSelection); - for (int index = 0; index < entry.ataAttributes.size(); ++index) { - const StorageDisplayAtaAttribute &attribute = entry.ataAttributes[index]; - const QStringList values {attribute.id, attribute.name, attribute.current, attribute.worst, - attribute.threshold, attribute.interpreted, attribute.flags}; - for (int column = 0; column < values.size(); ++column) { - auto *item = new QTableWidgetItem(values[column]); - item->setToolTip("" + (hints[column] + '\n' + attribute.source).toHtmlEscaped().replace('\n', "
") + "
"); - if (column == 0 || (column >= 2 && column <= 4) || column == 6) - item->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); - table->setItem(index, column, item); - } - } - layout->addWidget(table); - table->fitContents(); - healthGroups_->addWidget(box); - } - healthGroups_->addStretch(); -} - -void PropertiesDialog::updateEvents(const DeviceEventsSnapshot &history, const QString &monitorNote) -{ - Q_ASSERT(QThread::isMainThread()); - // A late removal observation may arrive after a property read detected removal. - // Accept only the old token, retaining its rows even after global eviction. - eventScope_ = tr("Live udev events since %1. Receipt times; exact device instance only. " - "Latest %3 events per device; %2 retained globally. Historical logs are not queried.") - .arg(history.started.isValid() ? history.started.toString(Qt::ISODate) : tr("monitor startup")) - .arg(DeviceEventsSnapshot::Limit).arg(DeviceEventsSnapshot::PerDeviceLimit); - if (history.evicted) eventScope_ += '\n' + tr("%1 older events evicted from the global history.").arg(history.evicted); - if (history.unassociated) eventScope_ += '\n' + tr("%1 events could not be associated safely with an inventory instance.").arg(history.unassociated); - if (history.gaps) eventScope_ += '\n' + tr("%1 event-identity resets after monitor failures/losses; coverage is incomplete.").arg(history.gaps); - if (!history.monitoring) eventScope_ += '\n' + tr("Live event monitoring is unavailable. Viewing and retained events remain available."); - if (!monitorNote.isEmpty()) eventScope_ += '\n' + monitorNote; - if (!device().eventInstance) eventScope_ += '\n' + tr("No safe live-event association is available for this instance."); - if (device().subsystem == "efivarfs") eventScope_ += '\n' + tr("EFI variables are reconciled by inventory scans; libudev does not supply their value-change history."); - QVector next; - for (auto it = history.records.crbegin(); it != history.records.crend(); ++it) { - if (!device().eventInstance || it->instance != device().eventInstance || it->path != device().path) continue; - next.append(*it); - if (next.size() == DeviceEventsSnapshot::PerDeviceLimit) break; - } - if (removed_) { - for (const DeviceEvent &old : displayedEvents_) { - bool retained = false; - for (const DeviceEvent &event : next) - if (event.sequence == old.sequence && event.elapsedMs == old.elapsedMs - && event.observed == old.observed && event.action == old.action) { retained = true; break; } - if (!retained && next.size() < DeviceEventsSnapshot::PerDeviceLimit) next.append(old); - } - eventScope_ += '\n' + tr("Removed/replaced device: retained events are a read-only instance snapshot."); - } - if (next.isEmpty()) eventScope_ += '\n' + tr("No retained live events for this instance. This does not establish that the device has had no errors."); - eventsNotice_->setToolTip("" + eventScope_.toHtmlEscaped().replace('\n', "
") + "
"); - bool unchanged = next.size() == displayedEvents_.size(); - for (int i = 0; unchanged && i < next.size(); ++i) { - const DeviceEvent &a = next.at(i); - const DeviceEvent &b = displayedEvents_.at(i); - unchanged = a.sequence == b.sequence && a.elapsedMs == b.elapsedMs && a.observed == b.observed - && a.action == b.action && a.path == b.path && a.instance == b.instance; - } - if (unchanged) return; // Unrelated events must not reset selection/scroll. - const int oldRow = eventsTable_->currentRow(); - DeviceEvent selected; - const bool hadSelection = oldRow >= 0 && oldRow < displayedEvents_.size(); - if (hadSelection) selected = displayedEvents_.at(oldRow); - displayedEvents_ = std::move(next); - const QSignalBlocker blocker(eventsTable_); - eventsTable_->setRowCount(0); - eventsTable_->setRowCount(displayedEvents_.size()); - int restore = -1; - for (int row = 0; row < displayedEvents_.size(); ++row) { - const DeviceEvent &event = displayedEvents_.at(row); - const QStringList values {event.observed.toString(Qt::ISODateWithMs), event.action, eventDescription(event)}; - for (int column = 0; column < values.size(); ++column) - eventsTable_->setItem(row, column, new QTableWidgetItem(values[column])); - if (hadSelection && event.sequence == selected.sequence && event.elapsedMs == selected.elapsedMs - && event.action == selected.action) restore = row; - } - if (restore < 0 && !displayedEvents_.isEmpty()) restore = 0; - if (restore >= 0) eventsTable_->setCurrentCell(restore, 0); - showEvent(); -} -void PropertiesDialog::showEvent() -{ - const int row = eventsTable_->currentRow(); - eventDetails_->setPlainText(row >= 0 && row < displayedEvents_.size() ? eventText(displayedEvents_.at(row)) : QString()); -} -void PropertiesDialog::copyEvents(bool selectedOnly) -{ - QStringList text {eventScope_}; - if (selectedOnly) { - QList rows; - for (const QModelIndex &index : eventsTable_->selectionModel()->selectedRows()) rows.append(index.row()); - std::sort(rows.begin(), rows.end()); - for (const int row : rows) text.append(eventText(displayedEvents_.at(row))); - } else { - for (const DeviceEvent &event : displayedEvents_) text.append(eventText(event)); - } - QApplication::clipboard()->setText(text.join("\n\n")); -} diff --git a/src/ui/propertiesdialog.h b/src/ui/propertiesdialog.h deleted file mode 100644 index 9e18901..0000000 --- a/src/ui/propertiesdialog.h +++ /dev/null @@ -1,100 +0,0 @@ -#pragma once - -#include "deviceproperties.h" -#include "propertytext.h" -#include "storagepresentation.h" -#include -#include - -class QCheckBox; -class QComboBox; -class QFormLayout; -class QLabel; -class QPlainTextEdit; -class QPushButton; -class QTabWidget; -class QTableWidget; -class QVBoxLayout; - -class PropertiesDialog final : public QDialog { - Q_OBJECT -public: - explicit PropertiesDialog(const Device &device, QWidget *parent = nullptr); - const Device &device() const { return snapshot_.device; } - bool isRemoved() const { return removed_; } - void acceptResult(DeviceProperties result); - void reconcileInstance(const Device *current); // Borrowed only for this call. - void setBusy(bool busy); - void markRemoved(); - void updateEvents(const DeviceEventsSnapshot &history, const QString &monitorNote); -signals: - void reloadRequested(); -private: - void rebuild(); - void rebuildDetails(); - void rebuildResources(); - void rebuildStorage(); - void rebuildNetwork(); - void rebuildDisplay(); - void rebuildCamera(); - void rebuildUsb(); - void rebuildPcie(); - void rebuildBattery(); - void showVolume(); - void showHealth(); - void copyResources(bool selectedOnly); - void showDetail(); - void copyAll(); - void showEvent(); - void copyEvents(bool selectedOnly); - DeviceProperties snapshot_; - QVector entries_; - QLabel *banner_ = nullptr; - QTabWidget *tabs_ = nullptr; - QWidget *resourcesPage_ = nullptr; - QTableWidget *resourcesTable_ = nullptr; - QString resourcesText_; - QFormLayout *general_ = nullptr; - QFormLayout *driver_ = nullptr; - QFormLayout *storage_ = nullptr; - QWidget *storagePage_ = nullptr; - QWidget *volumesPage_ = nullptr; - QWidget *healthPage_ = nullptr; - QTableWidget *volumesTable_ = nullptr; - QFormLayout *volumeDetails_ = nullptr; - QComboBox *healthDrive_ = nullptr; - QVBoxLayout *healthGroups_ = nullptr; - QLabel *storageNotice_ = nullptr; - QLabel *volumesNotice_ = nullptr; - QLabel *healthNotice_ = nullptr; - StoragePresentation storagePresentation_; - QWidget *networkPage_ = nullptr; - QVBoxLayout *networkGroups_ = nullptr; - QString networkText_; - QWidget *cameraPage_ = nullptr; - QVBoxLayout *cameraGroups_ = nullptr; - QString cameraText_; - QWidget *displayPage_ = nullptr; - QVBoxLayout *displayGroups_ = nullptr; - QString displayText_; - QWidget *usbPage_ = nullptr; - QVBoxLayout *usbGroups_ = nullptr; - QString usbText_; - QWidget *pciePage_ = nullptr; - QVBoxLayout *pcieGroups_ = nullptr; - QString pcieText_; - QWidget *batteryPage_ = nullptr; - QVBoxLayout *batteryGroups_ = nullptr; - QString batteryText_; - QComboBox *property_ = nullptr; - QPlainTextEdit *value_ = nullptr; - QLabel *source_ = nullptr; - QCheckBox *advanced_ = nullptr; - QPushButton *reload_ = nullptr; - QLabel *eventsNotice_ = nullptr; - QTableWidget *eventsTable_ = nullptr; - QPlainTextEdit *eventDetails_ = nullptr; - QVector displayedEvents_; - QString eventScope_; - bool removed_ = false; -}; diff --git a/src/ui/systemdialog.cpp b/src/ui/systemdialog.cpp deleted file mode 100644 index 6f9a1c9..0000000 --- a/src/ui/systemdialog.cpp +++ /dev/null @@ -1,464 +0,0 @@ -#include "systemdialog.h" -#include "propertytext.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -QVector> memoryFields() -{ - using D = SystemDialog; - return {{"PRESENT", D::tr("Presence")}, {"SIZE", D::tr("Capacity (total)")}, - {"VOLATILE_SIZE", D::tr("Volatile capacity")}, {"NON_VOLATILE_SIZE", D::tr("Nonvolatile capacity")}, - {"LOCATOR", D::tr("Device locator")}, {"BANK_LOCATOR", D::tr("Bank locator")}, - {"TYPE", D::tr("Memory type")}, {"FORM_FACTOR", D::tr("Form factor")}, - {"SPEED_MTS", D::tr("Rated transfer rate")}, {"CONFIGURED_SPEED_MTS", D::tr("Configured transfer rate")}, - {"MANUFACTURER", D::tr("Manufacturer")}, {"PART_NUMBER", D::tr("Part number")}}; -} -QString memoryScope() -{ - return SystemDialog::tr("Firmware-reported records; these do not establish removable modules, physical slots, " - "memory channels, upgradeability or active ECC. Transfer rates are firmware declarations, not live measurements."); -} -} - -SystemDialog::SystemDialog(QWidget *parent) : QDialog(parent) -{ - setWindowTitle(tr("System Information")); - resize(680, 650); - auto *layout = new QVBoxLayout(this); - auto *tabs = new QTabWidget(this); - tabs->setUsesScrollButtons(true); - layout->addWidget(tabs, 1); - QVector forms; - QVector names; - const auto group = [&](const QString &title) { - auto *scroll = new QScrollArea(tabs); - scroll->setWidgetResizable(true); - auto *page = new QWidget(scroll); - auto *contents = new QVBoxLayout(page); - auto *fields = new QWidget(page); - auto *form = new QGridLayout(fields); - form->setContentsMargins(0, 0, 0, 0); - contents->addWidget(fields); - contents->addStretch(); - scroll->setWidget(page); - tabs->addTab(scroll, title); - form->setColumnStretch(1, 1); - forms.append(form); - return form; - }; - const auto field = [&](QGridLayout *form, const QString &key, const QString &caption) { - auto *name = new QLabel(caption, form->parentWidget()); - name->setTextFormat(Qt::PlainText); - names.append(name); - auto *value = new QLabel(tr("Not collected"), form->parentWidget()); - value->setTextFormat(Qt::PlainText); - value->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setWordWrap(true); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - value->setMinimumWidth(120); - const int row = form->count() ? form->rowCount() : 0; - form->addWidget(name, row, 0, Qt::AlignLeading | Qt::AlignTop); - form->addWidget(value, row, 1); - if (!fields_.contains(key)) { - captions_.insert(key, caption); - order_.append(key); - } - fields_[key].append(value); - }; - - auto *os = group(tr("Operating System")); - field(os, "hostname", tr("Hostname")); - field(os, "os", tr("Linux distribution")); - auto *separator = new QFrame(os->parentWidget()); - separator->setFrameShape(QFrame::HLine); - separator->setFrameShadow(QFrame::Sunken); - os->addWidget(separator, os->rowCount(), 0, 1, 2); - field(os, "architecture", tr("Kernel architecture")); - field(os, "kernel", tr("Kernel version")); - field(os, "build", tr("Kernel build")); - field(os, "boot", tr("Boot mode")); - field(os, "secure_boot", tr("Secure Boot")); - field(os, "virtualization", tr("Virtualization environment")); - field(os, "uptime", tr("Uptime")); - field(os, "gpu_rebar", tr("Resizable BAR (GPU)")); - - auto *hardware = group(tr("Hardware")); - field(hardware, "manufacturer", tr("System manufacturer")); - field(hardware, "model", tr("System model")); - field(hardware, "board_vendor", tr("Motherboard manufacturer")); - field(hardware, "board_model", tr("Motherboard model")); - field(hardware, "firmware", tr("Firmware version")); - field(hardware, "firmware_date", tr("Firmware date (reported)")); - - auto *mokPage = new QWidget(hardware->parentWidget()); - mokForm_ = new QGridLayout(mokPage); - mokForm_->setContentsMargins(0, 0, 0, 0); - mokForm_->setColumnStretch(1, 1); - hardware->addWidget(mokPage, hardware->rowCount(), 0, 1, 2); - order_.append("mok"); - captions_.insert("mok", tr("MOK certificates")); - - auto *processor = group(tr("Processor")); - field(processor, "cpu", tr("CPU model(s)")); - field(processor, "sockets", tr("Sockets (reported topology)")); - field(processor, "cores", tr("Physical cores (reported topology)")); - field(processor, "logical", tr("Logical CPUs present")); - field(processor, "online", tr("Logical CPUs online")); - field(processor, "caches", tr("CPU caches (online CPUs)")); - field(processor, "instruction_sets", tr("Instruction sets / CPU features")); - - auto *memory = group(tr("RAM/Memory")); - field(memory, "physical_memory", tr("Physical RAM")); - field(memory, "memory", tr("Usable RAM")); - // Keep the summary labels outside the variable-length records rebuilt on refresh. - auto *memoryPage = new QWidget(memory->parentWidget()); - memoryForm_ = new QGridLayout(memoryPage); - memoryForm_->setContentsMargins(0, 0, 0, 0); - memoryForm_->setColumnStretch(1, 1); - memory->addWidget(memoryPage, memory->rowCount(), 0, 1, 2); - forms.append(memoryForm_); - order_.append("memory_devices"); - captions_.insert("memory_devices", tr("RAM/Memory")); - // Use one font-derived caption width for every section, so values align. - int captionWidth = 0; - for (const QLabel *name : names) captionWidth = qMax(captionWidth, name->sizeHint().width()); - for (const QString &caption : {tr("Owner:"), tr("Issuer:"), tr("Expires:")}) { - const QLabel name(caption); - captionWidth = qMax(captionWidth, name.sizeHint().width()); - } - for (const auto &entry : memoryFields()) { - const QLabel name(entry.second); - captionWidth = qMax(captionWidth, name.sizeHint().width()); - } - for (QGridLayout *form : forms) form->setColumnMinimumWidth(0, captionWidth); - mokForm_->setColumnMinimumWidth(0, captionWidth); - rebuildMok(); - rebuildMemory(); - auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); - copy_ = buttons->addButton(tr("Copy all"), QDialogButtonBox::ActionRole); - refresh_ = buttons->addButton(tr("Refresh"), QDialogButtonBox::ActionRole); - copy_->setEnabled(false); - connect(copy_, &QPushButton::clicked, this, &SystemDialog::copyAll); - connect(refresh_, &QPushButton::clicked, this, &SystemDialog::refreshRequested); - connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::close); - layout->addWidget(buttons); -} -void SystemDialog::setBusy(bool busy) -{ - refresh_->setEnabled(!busy); - refresh_->setText(busy ? tr("Refreshing…") : tr("Refresh")); -} -QString SystemDialog::displayValue(const QString &key, const Attribute &value) const -{ - if (key == "virtualization" && value.state == ReadState::Unavailable) - return tr("Unknown (no hypervisor identity reported)"); - if (key == "memory_devices") { - QStringList lines {memoryScope()}; - const auto &memory = snapshot_.memoryDevices; - if (memory.status.state != ReadState::Available) lines.append(propertyReadValue(memory.status)); - else if (memory.devices.isEmpty()) lines.append(tr("No memory-device records reported")); - else for (const FirmwareMemoryDevice &device : memory.devices) { - lines.append(tr("Memory device %1 (firmware record %2)").arg(device.index + 1).arg(device.index)); - for (const auto &field : memoryFields()) - lines.append(field.second + ": " + memoryValue(field.first, device.values.value(field.first))); - } - return lines.join('\n'); - } - if (key == "gpu_rebar") { - if (snapshot_.gpuRebarStatus.state != ReadState::Available) - return propertyReadValue(snapshot_.gpuRebarStatus); - if (snapshot_.gpuRebar.isEmpty()) return tr("No PCI display controller reported"); - const auto size = [this](const Attribute &value) { - if (value.state != ReadState::Available) return propertyReadValue(value); - const qulonglong bytes = value.value.toULongLong(); - if (bytes <= static_cast(std::numeric_limits::max())) - return QLocale().formattedDataSize(static_cast(bytes), 0, QLocale::DataSizeIecFormat); - return tr("%1 bytes").arg(QLocale().toString(bytes)); - }; - QStringList devices; - for (const GpuResizableBar &gpu : snapshot_.gpuRebar) { - QStringList lines {tr("GPU [%1]").arg(gpu.pciAddress)}; - if (gpu.status.state == ReadState::Unavailable) lines.append(tr("Capability: Not exposed by kernel")); - else if (gpu.status.state != ReadState::Available) lines.append(propertyReadValue(gpu.status)); - else { - lines.append(tr("Capability: Supported")); - QList bars = gpu.supportedSizes.keys(); - std::sort(bars.begin(), bars.end()); - for (int bar : bars) { - const Attribute bitmap = gpu.supportedSizes.value(bar); - QString supported = propertyReadValue(bitmap); - if (bitmap.state == ReadState::Available) { - const qulonglong bits = bitmap.value.toULongLong(nullptr, 16); - QStringList sizes; - for (int bit = 0; bit < 44; ++bit) - if (bits & (1ULL << bit)) sizes.append(size({ReadState::Available, QString::number(1ULL << (bit + 20)), 0})); - supported = sizes.join(", "); - } - lines.append(tr("BAR %1 — Current allocation: %2; supported sizes: %3") - .arg(bar).arg(size(gpu.currentSizes.value(bar)), supported)); - } - } - lines.append(tr("BIOS setting: Unknown")); - devices.append(lines.join('\n')); - } - return devices.join("\n\n"); - } - if (key == "mok") { - const MokCertificates &mok = snapshot_.mok; - if (mok.status.state != ReadState::Available) return propertyReadValue(mok.status); - QStringList certificates; - const auto known = [this](const QString &text) { return text.isEmpty() ? tr("Unavailable") : text; }; - for (const MokCertificate &certificate : mok.certificates) - certificates.append(tr("Owner: %1\nIssuer: %2\nExpires: %3") - .arg(known(certificate.subject), known(certificate.issuer), known(certificate.expires))); - if (certificates.isEmpty()) certificates.append(tr("No X.509 certificates in the exposed MOK list")); - if (mok.otherSignatures) certificates.append(tr("%1 non-certificate signatures omitted").arg(mok.otherSignatures)); - return certificates.join("\n\n"); - } - if (value.state != ReadState::Available) return propertyReadValue(value); - if (key == "secure_boot") return value.value == "enabled" ? tr("Enabled") : tr("Disabled"); - if (key == "boot") - return value.value == "uefi" ? tr("UEFI") - : tr("UEFI not exposed (legacy boot or restricted environment)"); - if (key == "virtualization") return tr("Reported hypervisor: %1").arg(value.value); - if (key == "memory" || key == "physical_memory") { - constexpr qulonglong gib = 1024ULL * 1024 * 1024; - constexpr qulonglong mib = 1024ULL * 1024; - const qulonglong bytes = value.value.toULongLong(); - const int precision = key == "physical_memory" && bytes % gib == 0 ? 0 : 2; - QString text = tr("%1 GiB").arg(QLocale().toString(bytes / static_cast(gib), 'f', precision)); - const Attribute physical = snapshot_.values.value("physical_memory"); - if (key == "memory" && physical.state == ReadState::Available) { - const qulonglong installed = physical.value.toULongLong(); - if (installed >= bytes) - text += tr(" (%1 MiB is system reserved)") - .arg(QLocale().toString((installed - bytes) / static_cast(mib), 'f', 0)); - } - return text; - } - if (key == "uptime") { - const qulonglong seconds = value.value.toULongLong(); - return tr("%1 days, %2 hours, %3 minutes").arg(seconds / 86400).arg(seconds / 3600 % 24).arg(seconds / 60 % 60); - } - if (key == "instruction_sets") { - const QStringList groups = value.value.split('\n'); - QStringList lines; - for (const QString &group : groups) { - const QStringList parts = group.split('\t'); - lines.append(groups.size() == 1 ? parts.value(1) - : tr("Logical CPUs %1: %2").arg(parts.value(0), parts.value(1))); - } - return lines.join("\n\n"); - } - if (key == "caches") { - QStringList lines; - for (const QString &raw : value.value.split('\n')) { - const QStringList parts = raw.split('\t'); - const QString kind = parts.value(0); - QString type = kind.section(' ', 1); - if (type == "Data") type = tr("data"); - else if (type == "Instruction") type = tr("instruction"); - else if (type == "Unified") type = tr("unified"); - lines.append(tr("%1 %2: %3 (%4 unique instances)") - .arg(kind.section(' ', 0, 0), type, - QLocale().formattedDataSize(parts.value(1).toLongLong()), parts.value(2))); - } - return lines.join('\n'); - } - return value.value; -} -QString SystemDialog::memoryValue(const QString &key, const Attribute &value) const -{ - if (key == "PRESENT" && value.state == ReadState::Unavailable) - return tr("Unknown (no explicit presence flag)"); - if (value.state != ReadState::Available) return propertyReadValue(value); - if (key == "PRESENT") return value.value == "0" ? tr("No device reported present") : tr("Device reported present"); - if (key == "SPEED_MTS" || key == "CONFIGURED_SPEED_MTS") - return tr("%1 MT/s").arg(QLocale().toString(value.value.toULongLong())); - if (key == "SIZE" || key == "VOLATILE_SIZE" || key == "NON_VOLATILE_SIZE") { - const qulonglong bytes = value.value.toULongLong(); - if (bytes <= static_cast(std::numeric_limits::max())) - return QLocale().formattedDataSize(static_cast(bytes), 2, QLocale::DataSizeIecFormat); - return tr("%1 bytes").arg(QLocale().toString(bytes)); - } - return value.value; -} -void SystemDialog::rebuildMemory() -{ - while (QLayoutItem *item = memoryForm_->takeAt(0)) { - delete item->widget(); - delete item; - } - int row = 0; - const auto label = [this](const QString &text) { - auto *value = new QLabel(text, memoryForm_->parentWidget()); - value->setTextFormat(Qt::PlainText); - value->setWordWrap(true); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - value->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setMinimumWidth(120); - return value; - }; - const auto message = [&](const QString &text, bool heading = false) { - auto *value = label(text); - if (heading) { QFont font = value->font(); font.setBold(true); value->setFont(font); } - memoryForm_->addWidget(value, row++, 0, 1, 2); - }; - const FirmwareMemoryInformation &memory = snapshot_.memoryDevices; - if (memory.status.state != ReadState::Available) { - auto *value = label(propertyReadValue(memory.status)); - value->setToolTip("" + snapshot_.sources.value("memory_devices").toHtmlEscaped() + ""); - memoryForm_->addWidget(value, row, 0, 1, 2); - return; - } - if (memory.devices.isEmpty()) message(tr("No memory-device records reported")); - for (const FirmwareMemoryDevice &device : memory.devices) { - message(tr("Memory device %1").arg(device.index + 1), true); - const QString prefix = QStringLiteral("MEMORY_DEVICE_%1_").arg(device.index); - for (const auto &field : memoryFields()) { - auto *name = new QLabel(field.second, memoryForm_->parentWidget()); - name->setTextFormat(Qt::PlainText); - auto *value = label(memoryValue(field.first, device.values.value(field.first))); - const QString hint = "" + (QStringLiteral("libudev: dmi/id ") + prefix + field.first - + '\n' + memoryScope()).toHtmlEscaped().replace('\n', "
") + "
"; - name->setToolTip(hint); value->setToolTip(hint); - memoryForm_->addWidget(name, row, 0, Qt::AlignLeading | Qt::AlignTop); - memoryForm_->addWidget(value, row++, 1); - } - } -} -void SystemDialog::rebuildMok() -{ - while (QLayoutItem *item = mokForm_->takeAt(0)) { - delete item->widget(); - delete item; - } - const MokCertificates &mok = snapshot_.mok; - const QString scope = snapshot_.sources.value("mok") + '\n' - + tr("Exposed runtime MOK certificates. Issuer and expiration are certificate metadata, " - "not verification of the signer or proof that this key signed the running kernel. Dates are UTC. " - "Firmware db certificates and pending enrollments are not included."); - int row = 0; - const auto label = [](const QString &text, QWidget *parent) { - auto *value = new QLabel(text, parent); - value->setTextFormat(Qt::PlainText); - value->setWordWrap(true); - value->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); - value->setAlignment(Qt::AlignLeading | Qt::AlignTop); - QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - value->setSizePolicy(policy); - value->setMinimumWidth(120); - return value; - }; - const auto hint = [&scope](QLabel *value, const QString &details) { - value->setToolTip("" + (scope + "\n\n" + details).toHtmlEscaped().replace('\n', "
") + "
"); - }; - const auto message = [&](const QString &text) { - auto *value = label(text, mokForm_->parentWidget()); - hint(value, {}); - mokForm_->addWidget(value, row++, 0, 1, 2); - }; - if (mok.status.state != ReadState::Available || mok.certificates.isEmpty()) { - message(displayValue("mok", {})); - return; - } - const auto known = [this](const QString &text) { return text.isEmpty() ? tr("Unavailable") : text; }; - int index = 0; - for (const MokCertificate &certificate : mok.certificates) { - const QString title = mok.certificates.size() == 1 ? tr("MOK Certificate") - : tr("MOK Certificate %1").arg(++index); - auto *box = new QGroupBox(title, mokForm_->parentWidget()); - auto *form = new QGridLayout(box); - form->setColumnStretch(1, 1); - form->setColumnMinimumWidth(0, mokForm_->columnMinimumWidth(0)); - mokForm_->addWidget(box, row++, 0, 1, 2); - int certificateRow = 0; - const auto field = [&](const QString &caption, const QString &text, const QString &details) { - auto *name = new QLabel(caption, box); - name->setTextFormat(Qt::PlainText); - auto *value = label(text, box); - hint(name, details); - hint(value, details); - form->addWidget(name, certificateRow, 0, Qt::AlignLeading | Qt::AlignTop); - form->addWidget(value, certificateRow++, 1); - }; - const QString owner = certificate.subjectDisplay.isEmpty() ? certificate.subject : certificate.subjectDisplay; - const QString issuer = !certificate.subject.isEmpty() && certificate.subject == certificate.issuer - ? tr("Same as owner") - : (certificate.issuerDisplay.isEmpty() ? certificate.issuer : certificate.issuerDisplay); - const QDateTime expires = QDateTime::fromString(certificate.expires, Qt::ISODate); - const QString date = expires.isValid() - ? tr("%1 UTC").arg(QLocale().toString(expires.toUTC(), "d MMM yyyy, HH:mm:ss")) : known(certificate.expires); - field(tr("Owner:"), known(owner), certificate.subject); - field(tr("Issuer:"), known(issuer), certificate.issuer); - field(tr("Expires:"), date, certificate.expires); - } - if (mok.otherSignatures) - message(tr("%1 non-certificate signatures omitted").arg(mok.otherSignatures)); -} -void SystemDialog::acceptResult(SystemProperties result) -{ - Q_ASSERT(QThread::isMainThread()); - snapshot_ = std::move(result); - for (const QString &key : order_) { - if (key == "mok" || key == "memory_devices") continue; // Variable-length sections are rebuilt separately. - const QString text = displayValue(key, snapshot_.values.value(key)); - QString source = snapshot_.sources.value(key); - if (key == "secure_boot") - source += '\n' + tr("Firmware SecureBoot state; does not establish shim validation or kernel lockdown policy."); - if (key == "gpu_rebar") - source += '\n' + tr("GPU capability and current OS resource allocation are separate from the firmware setting. " - "A small BAR is not proof of disabled ReBAR; a large BAR is not proof of driver use. " - "No portable BIOS Enabled/Disabled value is inferred. Only sysfs metadata is read; BAR memory is never accessed."); - if (key == "instruction_sets") - source += '\n' + tr("Kernel-exposed instruction-set and CPU-feature flags for processors listed in /proc/cpuinfo. " - "These include non-instruction features and may be limited by the kernel, operating system or hypervisor. " - "Different reported feature lists remain separate; this is not a complete raw hardware capability inventory."); - if (key == "memory") - source += '\n' + tr("System reserved is reported physical RAM minus Linux MemTotal. " - "It includes all memory unavailable to Linux, not just firmware reservations. " - "It is omitted if physical capacity is unavailable or smaller than usable RAM."); - const QString hint = "" + source.toHtmlEscaped().replace('\n', "
") + "
"; - for (QLabel *value : fields_.value(key)) { - value->setText(text); - value->setToolTip(hint); - } - } - rebuildMok(); - rebuildMemory(); - setBusy(false); - copy_->setEnabled(true); -} -void SystemDialog::copyAll() -{ - QStringList lines; - for (const QString &key : order_) - lines.append(captions_.value(key) + ": " + displayValue(key, snapshot_.values.value(key))); - QApplication::clipboard()->setText(lines.join('\n')); -} diff --git a/src/ui/systemdialog.h b/src/ui/systemdialog.h deleted file mode 100644 index 89ad86f..0000000 --- a/src/ui/systemdialog.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include "systemproperties.h" -#include -#include -#include -#include -class QGridLayout; -class QLabel; -class QPushButton; - -class SystemDialog final : public QDialog { - Q_OBJECT -public: - explicit SystemDialog(QWidget *parent = nullptr); - void setBusy(bool busy); - void acceptResult(SystemProperties result); -signals: - void refreshRequested(); -private: - QString displayValue(const QString &key, const Attribute &value) const; - void rebuildMok(); - void rebuildMemory(); - QString memoryValue(const QString &key, const Attribute &value) const; - void copyAll(); - QGridLayout *memoryForm_ = nullptr; - QGridLayout *mokForm_ = nullptr; - QHash> fields_; - QStringList order_; - QHash captions_; - SystemProperties snapshot_; - QPushButton *refresh_ = nullptr; - QPushButton *copy_ = nullptr; -}; diff --git a/tests/device-events.cpp b/tests/device-events.cpp deleted file mode 100644 index 50f2162..0000000 --- a/tests/device-events.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include "deviceevents.h" -#include "device.h" -#include -#include - -int main(int argc, char **argv) -{ - QCoreApplication app(argc, argv); - int failures = 0; - const auto check = [&](bool ok, const char *message) { - if (!ok) { std::fprintf(stderr, "%s\n", message); ++failures; } - }; - const auto fixture = [](const QString &path, const QString &stamp) { - Device device; - device.path = path; - device.incarnation = "inode:" + stamp; - device.properties.insert("USEC_INITIALIZED", stamp); - return device; - }; - const auto observation = [](const Device &device, const QString &action, quint64 sequence) { - DeviceEvent event; - event.path = device.path; - event.incarnation = action == "remove" ? QString() : device.incarnation; - event.initialized = device.properties.value("USEC_INITIALIZED"); - event.action = action; - event.sequence = sequence; - event.observed = QDateTime::fromMSecsSinceEpoch(100000 + sequence); - event.elapsedMs = sequence; - return event; - }; - DeviceEventHistory history(QDateTime::fromSecsSinceEpoch(100)); - Device parent = fixture("/sys/devices/usb/fixture", "old"); - Device child = fixture(parent.path + "/input", "old-child"); - QVector devices {parent, child}; - history.reconcile(devices); - const quint64 old = devices.at(0).eventInstance; - const quint64 oldChild = devices.at(1).eventInstance; - check(old && oldChild && old != oldChild, "Distinct devices get distinct live-event identities"); - history.observe(observation(parent, "change", 1)); - history.observe(observation(parent, "remove", 2)); - history.observe(observation(child, "remove", 3)); - auto events = history.snapshot(true); - check(events.records.at(0).instance == old && events.records.at(1).instance == old - && events.records.at(2).instance == oldChild, "Removal events retain the observed parent/child instances"); - // Even identical inode/initialization hints cannot undo observed removal. - history.observe(observation(parent, "add", 4)); - history.observe(observation(child, "add", 5)); - history.reconcile(devices); - check(devices.at(0).eventInstance != old && devices.at(1).eventInstance != oldChild, - "Remove/add at a reused path creates new event identities for descendants too"); - events = history.snapshot(true); - check(events.records.at(3).instance == devices.at(0).eventInstance, - "An observed add can be matched to the subsequent confirmed inventory"); - history.observe(observation(parent, "remove", 2)); - check(history.snapshot(true).records.back().instance == 0, "Reordered stale removal cannot retire a replacement"); - const quint64 replacement = devices.at(0).eventInstance; - history.reconcile(devices); - check(devices.at(0).eventInstance == replacement, "Stale event leaves current association intact"); - Device stale = fixture(parent.path, "stale-other-instance"); - history.observe(observation(stale, "change", 6)); - check(history.snapshot(true).records.back().instance == 0, - "Conflicting initialization/inode evidence is not attached to current instance"); - history.lose(); - history.reconcile(devices); - check(devices.at(0).eventInstance != replacement && history.snapshot(false).gaps == 1 - && !history.snapshot(false).monitoring, "Monitor loss retires old associations without deleting retained history"); - const quint64 stable = devices.at(0).eventInstance; - history.reconcile(devices); - check(devices.at(0).eventInstance == stable, "Ordinary snapshots neither rotate identity nor manufacture events"); - const int before = history.snapshot(true).records.size(); - DeviceEvent invalid = observation(parent, "change", 7); - invalid.path = "/sys/class/usb"; - history.observe(invalid); - check(history.snapshot(true).records.size() == before, "Noncanonical event paths are excluded"); - DeviceEventHistory bounded; - QVector one {parent}; - bounded.reconcile(one); - for (int i = 0; i < DeviceEventsSnapshot::Limit + 3; ++i) - bounded.observe(observation(parent, "change", i + 1)); - events = bounded.snapshot(true); - check(events.records.size() == DeviceEventsSnapshot::Limit && events.evicted == 3 - && events.records.front().sequence == 4, "Global history evicts oldest events at its explicit bound"); - DeviceEvent longValue = observation(parent, "change", 2000); - longValue.driver = QString(10000, 'x'); - bounded.observe(longValue); - check(bounded.snapshot(true).records.back().driver.size() == 4096, "Event payload copies are bounded"); - DeviceEventHistory pending; - pending.observe(observation(parent, "add", 1)); - QVector unrelated {fixture(parent.path, "different")}; - pending.reconcile(unrelated); - check(unrelated.front().eventInstance != pending.snapshot(true).records.front().instance, - "A stale add payload cannot become authoritative inventory for a replacement"); - DeviceEventHistory capped; - QVector many; - many.reserve(DeviceEventHistory::IdentityLimit + 1); - for (int i = 0; i <= DeviceEventHistory::IdentityLimit; ++i) - many.append(fixture(QStringLiteral("/sys/devices/fixture/%1").arg(i), "stamp")); - capped.reconcile(many); - check(many.at(DeviceEventHistory::IdentityLimit - 1).eventInstance != 0 && many.back().eventInstance == 0, - "Registry overflow leaves devices viewable with an explicit unassociated token"); - return failures ? 1 : 0; -} diff --git a/tests/device-resources.cpp b/tests/device-resources.cpp deleted file mode 100644 index a3b8f49..0000000 --- a/tests/device-resources.cpp +++ /dev/null @@ -1,57 +0,0 @@ -#include "deviceresources.h" -#include -#include - -namespace { -void require(bool condition, const char *message) -{ - if (!condition) { std::fprintf(stderr, "%s\n", message); std::exit(1); } -} -} -int main() -{ - using Type = DeviceResource::Type; - using Allocation = DeviceResource::Allocation; - // Fixtures only. No real sysfs access, hardware actions, or GUI creation. - const DeviceResources pci = parsePciResources( - "0x00000007c0000000 0x00000007cfffffff 0x00102200\n" - "0x000000000000ef00 0x000000000000efff 0x00000100\n" - "0 0 0\n" - "0 0xfff 0x20000200\n" - "0x1000 0x1fff 0x10000200\n" - "0 0 0x200\n", "fixture/resource"); - require(pci.issues.isEmpty() && pci.items.size() == 5, "PCI resources should preserve five typed entries"); - require(pci.items[0].start == 0x7c0000000ULL && pci.items[0].end == 0x7cfffffffULL, - "64-bit memory addresses must not be truncated"); - require(pci.items[1].type == Type::Io && pci.items[1].start == 0xef00, "I/O type must come from flags"); - require(pci.items[2].index == 3 && pci.items[2].allocation == Allocation::Unassigned, - "Empty slots must not shift BAR indices or turn unassigned resources into ranges"); - require(pci.items[3].allocation == Allocation::Disabled, "Disabled resources must not appear assigned"); - require(pci.items[4].allocation == Allocation::Unavailable, "Typed zeroed addresses must remain unavailable"); - require(pci.items[0].source == "fixture/resource", "Resource attribution must survive parsing"); - const DeviceResources bad = parsePciResources("0x1000 0x1fff 0x200\ninvalid\n", "fixture/resource"); - require(bad.items.isEmpty() && bad.issues.size() == 1, "Malformed input must not publish a shifted partial PCI table"); - require(!parsePciResources("0x2000 0x1000 0x200\n", "fixture/resource").issues.isEmpty(), - "Reversed assigned ranges must be rejected"); - require(!parsePciResources("0x10000000000000000 0x1fff 0x200\n", "fixture/resource").issues.isEmpty(), - "Overflowing addresses must be rejected"); - require(!parsePciResources(QString(65537, 'x'), "fixture/resource").issues.isEmpty(), - "Oversized metadata must be rejected"); - const DeviceResources pnp = parsePnpResources( - "state = active\nio 0x60-0x60\nirq 0\nmem 0xf0000000-0xffffffff window\nirq disabled\ndma 4\n", "fixture/resources"); - require(pnp.issues.isEmpty() && pnp.items.size() == 5, "PnP metadata should retain typed assignments"); - require(pnp.items[1].type == Type::Irq && pnp.items[1].start == 0 - && pnp.items[1].allocation == Allocation::Assigned, "PnP IRQ zero can be legitimate"); - require(pnp.items[2].flags == 0x00200000, "PnP windows must retain their explicit marker"); - require(pnp.items[3].allocation == Allocation::Disabled, "Disabled PnP IRQs must not become IRQ zero"); - const DeviceResources inactive = parsePnpResources("state = disabled\nio 0x60-0x64\n", "fixture/resources"); - require(inactive.items.size() == 1 && inactive.items[0].allocation == Allocation::Disabled, - "Disabled PnP device assignments must not be shown as active"); - require(!parsePnpResources("state = active\nmem 0x20-0x10\n", "fixture/resources").issues.isEmpty(), - "Invalid PnP ranges must be rejected"); - require(!parsePnpResources("irq 7\n", "fixture/resources").issues.isEmpty(), - "Missing PnP state must be rejected"); - require(!parsePciResources("0 0 0\n", "fixture/resource").hasInformation(), - "Empty slots alone must not enable a Resources tab"); - return 0; -} diff --git a/tests/device-rules.cpp b/tests/device-rules.cpp deleted file mode 100644 index f21f5af..0000000 --- a/tests/device-rules.cpp +++ /dev/null @@ -1,388 +0,0 @@ -// Fixture-only checks. No live hardware is touched and no GUI is launched. -#include "device.h" -#include "cpumetadata.h" -#include "devicepresentation.h" -#include "functionnames.h" -#include "devicelabel.h" -#include "efivariables.h" -#include - -static int failures = 0; -static void check(bool result, const char *message) -{ - if (!result) { std::fprintf(stderr, "%s\n", message); ++failures; } -} -int main() -{ - Device keyboard; - keyboard.path = "/sys/devices/pci0000:00/usb1/1-1/input/input7"; - keyboard.subsystem = "input"; - keyboard.sysname = "input7"; - keyboard.properties.insert("ID_INPUT_KEYBOARD", "1"); - classify(keyboard); - check(keyboard.category == "keyboard" && !keyboard.hidden, "Composite keyboard function must remain visible"); - Device audio = keyboard; - audio.subsystem = "sound"; - audio.sysname = "card1"; - classify(audio); - check(audio.category == "audio" && !audio.hidden, "Composite audio function must remain independently visible"); - Device endpoint = keyboard; - endpoint.sysname = "event3"; - classify(endpoint); - check(endpoint.hidden, "Redundant input endpoint must be filtered"); - Device virtualNet; - virtualNet.path = "/sys/devices/virtual/net/lo"; - virtualNet.subsystem = "net"; - classify(virtualNet); - check(virtualNet.category == "network" && virtualNet.hidden, "Virtual device must retain its category"); - Device partition; - partition.subsystem = "block"; - partition.devtype = "partition"; - classify(partition); - check(partition.hidden, "Partitions must not masquerade as whole disks"); - Device pci; - pci.subsystem = "pci"; - pci.properties.insert("PCI_CLASS", "0c0330"); - classify(pci); - check(pci.category == "usb", "PCI USB class decoding"); - Device unknown; - unknown.subsystem = "unknown_bus"; - unknown.driver = "example"; - unknown.sysname = "device0"; - classify(unknown); - nameDevice(unknown); - check(unknown.category == "other" && !unknown.hidden && unknown.name == QStringLiteral("example — device0"), "Unknown bound function needs a readable fallback"); - quint64 counter = 0; - keyboard.incarnation = "first"; - QVector first {keyboard}; - reconcile(first, {}, counter); - QVector next {keyboard}; - reconcile(next, first, counter); - check(next[0].generation == first[0].generation, "Same instance must preserve selection identity"); - next[0].incarnation = "replacement"; - reconcile(next, first, counter); - check(next[0].generation != first[0].generation, "Reused path must not retain old instance generation"); - next[0].properties.insert("ID_MODEL", "identical model"); - Device twin = next[0]; - twin.path += "/different"; - next.append(twin); - reconcile(next, first, counter); - check(next[0].generation != next[1].generation, "Equal model metadata must not merge different devices"); - QVector eventBefore {keyboard}; - eventBefore[0].eventInstance = 10; - reconcile(eventBefore, {}, counter); - QVector eventAfter = eventBefore; - eventAfter[0].eventInstance = 11; - reconcile(eventAfter, eventBefore, counter); - check(eventAfter[0].generation != eventBefore[0].generation, - "Retired live-event identity cannot keep an old GUI instance generation"); - const auto cpuModels = parseCpuModels(QStringLiteral( - "processor : 1\nmodel name : Different CPU\n\n" - "processor : 0\nmodel name : Intel(R) Core(TM) Ultra X7 358H\n\n" - "processor : 2\nHardware : Board name\n")); - check(cpuModels.value(0) == "Intel(R) Core(TM) Ultra X7 358H" && cpuModels.value(1) == "Different CPU", - "CPU models must match processor numbers, not parse order or a global first model"); - check(!cpuModels.contains(2), "A global board name must not become a CPU model"); - Device cpu; - cpu.subsystem = "cpu"; - cpu.sysname = "cpu0"; - Attribute model; - model.state = ReadState::Available; - model.value = cpuModels.value(0); - cpu.attributes.insert("cpu_model", model); - nameDevice(cpu); - check(cpu.name == model.value && cpu.nameSource.contains("processor 0"), "CPU name must identify its metadata source"); - model.state = ReadState::PermissionDenied; - cpu.attributes.insert("cpu_model", model); - nameDevice(cpu); - check(cpu.name == "cpu0", "Failed CPU metadata must not become an apparently resolved name"); - Device touchpad; - touchpad.subsystem = "input"; - touchpad.sysname = "input14"; - touchpad.properties.insert("NAME", "\"SYNA3517:00 06CB:CFC6 Touchpad\""); - touchpad.properties.insert("ID_MODEL", "Generic receiver"); - nameDevice(touchpad); - check(touchpad.name == "SYNA3517:00 06CB:CFC6 Touchpad", "Input function name must take precedence over generic model names"); - - // Paths/classes mirror the submitted HP laptop dump; no MAC/serial data. - Device wifi; - wifi.path = "/sys/devices/pci0000:00/0000:00:14.3"; - wifi.subsystem = "pci"; - wifi.properties.insert("PCI_CLASS", "28000"); - wifi.properties.insert("ID_MODEL_FROM_DATABASE", "Wi-Fi 7 BE211 320MHz"); - Device net; - net.path = wifi.path + "/net/wlo1"; - net.parentPath = wifi.path; - net.subsystem = "net"; - net.sysname = "wlo1"; - Device nvmePci; - nvmePci.path = "/sys/devices/pci0000:00/0000:00:06.0/0000:01:00.0"; - nvmePci.subsystem = "pci"; - nvmePci.properties.insert("PCI_CLASS", "10802"); - Device nvme; - nvme.path = nvmePci.path + "/nvme/nvme0"; - nvme.parentPath = nvmePci.path; // udev parent, not the intermediate nvme directory. - nvme.subsystem = "nvme"; - Device disk; - disk.path = nvme.path + "/nvme0n1"; - disk.parentPath = nvme.path; - disk.subsystem = "block"; - disk.devtype = "disk"; - QVector laptop {wifi, net, nvmePci, nvme, disk}; - for (auto &d : laptop) classify(d); - refinePresentation(laptop); - check(!laptop[0].hidden && laptop[1].hidden && laptop[1].representedByPath == wifi.path, - "One PCI Wi-Fi function and one interface must yield one default adapter row"); - check(!laptop[2].hidden && laptop[3].hidden && !laptop[4].hidden, - "One NVMe PCI/controller pair must yield one controller without hiding the disk"); - check(laptop.size() == 5 && laptop[1].parentPath == wifi.path && laptop[1].driver.isEmpty(), - "Grouping must retain every record, real ancestry and direct driver evidence"); - Device secondPort = net; - secondPort.path = wifi.path + "/net/other0"; - QVector multiport {wifi, net, secondPort}; - for (auto &d : multiport) classify(d); - refinePresentation(multiport); - check(multiport[0].hidden && !multiport[1].hidden && !multiport[2].hidden, - "Independent network functions must survive grouping"); - Device secondController = nvmePci; - secondController.path = "/sys/devices/pci0000:00/0000:02:00.0"; - QVector twoControllers {nvmePci, nvme, secondController}; - for (auto &d : twoControllers) classify(d); - refinePresentation(twoControllers); - check(!twoControllers[0].hidden && !twoControllers[2].hidden, - "A second real controller must never be merged by name or category"); - Device bridge = nvmePci; - bridge.properties.insert("PCI_CLASS", "60400"); - QVector behindBridge {bridge, nvme}; - for (auto &d : behindBridge) classify(d); - refinePresentation(behindBridge); - check(!behindBridge[1].hidden, "A PCI bridge is not an NVMe controller duplicate"); - Device embedded; - embedded.subsystem = "platform"; - embedded.sysname = "PNP0C09:00"; - embedded.properties.insert("MODALIAS", "acpi:PNP0C09:"); - nameDevice(embedded); - check(embedded.name == "ACPI embedded controller" && embedded.nameTranslated, - "A standard ACPI identity must have a readable translated function caption"); - embedded.properties.insert("MODALIAS", "acpi:INT340E:PNP0C02:"); - nameDevice(embedded); - check(embedded.name == "Motherboard resources" && embedded.nameSource.endsWith("PNP0C02"), - "Compatible ACPI identities must provide a caption without guessing the vendor ID"); - embedded.properties.insert("MODALIAS", "acpi:UNKNOWNPNP0C02:"); - nameDevice(embedded); - check(embedded.name == embedded.sysname && !embedded.nameTranslated, - "Substring resemblance must never resolve an unknown identifier"); - embedded.properties.insert("MODALIAS", "acpi:PNP0C09:"); - embedded.properties.insert("ID_MODEL_FROM_DATABASE", "Actual model"); - nameDevice(embedded); - check(embedded.name == "Actual model" && !embedded.nameTranslated, - "A function caption must not replace available model metadata"); - Device ps2; - ps2.subsystem = "pnp"; - ps2.properties.insert("MODALIAS", "pnp:dPNP0303"); - nameDevice(ps2); - check(ps2.name == "PS/2 keyboard interface", "PNP modalias identifier parsing"); - Device codec; - codec.subsystem = "hdaudio"; - Attribute chip; - chip.state = ReadState::Available; - chip.value = "ALC-test"; - codec.attributes.insert("chip_name", chip); - Attribute vendor; - vendor.state = ReadState::PermissionDenied; - vendor.value = "Stale vendor"; - codec.attributes.insert("vendor_name", vendor); - nameDevice(codec); - check(codec.name == "ALC-test", "Codec name must not use failed vendor metadata"); - Device usbProduct; - usbProduct.subsystem = "usb"; - usbProduct.devtype = "usb_device"; - usbProduct.properties.insert("ID_MODEL", "Generic_Model"); - chip.value = "Readable USB product"; - usbProduct.attributes.insert("product", chip); - nameDevice(usbProduct); - check(usbProduct.name == chip.value, "Direct USB product must supersede an encoded generic model"); - Device rootHub; - rootHub.subsystem = "usb"; - rootHub.devtype = "usb_device"; - rootHub.sysname = "usb2"; - rootHub.properties.insert("DEVNUM", "001"); - rootHub.properties.insert("PRODUCT", "1d6b/3/702"); - rootHub.properties.insert("ID_MODEL_FROM_DATABASE", "3.0 root hub"); - nameDevice(rootHub); - check(rootHub.name == "USB 3.x root hub" && rootHub.nameTranslated, - "Root hub label must describe the USB family without inventing a minor revision"); - rootHub.sysname = "2-1"; - nameDevice(rootHub); - check(rootHub.name == "3.0 root hub" && !rootHub.nameTranslated, - "An external device must not acquire the special root-hub caption"); - rootHub.sysname = "usb2"; - rootHub.properties.insert("PRODUCT", "1234/3/702"); - nameDevice(rootHub); - check(!rootHub.nameTranslated, "Root-hub captions require the Linux descriptor vendor identity"); - Device service; - service.subsystem = "pci_express"; - service.driver = "pciehp"; - nameDevice(service); - check(service.name == "PCI Express hot-plug service", "PCI Express service must have a readable caption"); - service.subsystem = "unknown_bus"; - nameDevice(service); - check(!service.nameTranslated, "Driver names outside their known subsystem must not imply a PCI service"); - Device faux; - faux.subsystem = "faux"; - faux.driver = "faux_driver"; - faux.sysname = "reg-dummy"; - nameDevice(faux); - check(faux.name == "Dummy voltage regulator", "Faux objects must describe their software function"); - faux.sysname = "reg-dummy-other"; - nameDevice(faux); - check(!faux.nameTranslated, "Faux captions require an exact known function name"); - Device firmware; - firmware.subsystem = "platform"; - firmware.sysname = "INTC1025:00"; - firmware.properties.insert("MODALIAS", "acpi:INTC1025:INT_PTL_SINIT:"); - nameDevice(firmware); - check(firmware.name == firmware.sysname, "Vendor ACPI objects must not receive hardcoded model labels"); - firmware.properties.insert("MODALIAS", "platform:coretemp"); - nameDevice(firmware); - check(firmware.name == "Intel CPU temperature monitor", "Exact platform alias must identify the kernel function"); - - Device audioPci; - audioPci.path = "/sys/devices/pci0000:00/0000:00:1f.3"; - audioPci.subsystem = "pci"; - audioPci.properties.insert("PCI_CLASS", "40300"); - Device dsp; - dsp.path = audioPci.path + "/skl_hda_dsp_generic"; - dsp.parentPath = audioPci.path; - dsp.subsystem = "platform"; - Device card; - card.path = dsp.path + "/sound/card0"; - card.parentPath = dsp.path; - card.subsystem = "sound"; - card.sysname = "card0"; - QVector audioPair {audioPci, dsp, card}; - for (auto &d : audioPair) classify(d); - refinePresentation(audioPair); - check(!audioPair[0].hidden && audioPair[2].hidden && audioPair[2].representedByPath == audioPci.path, - "One PCI audio controller and its ALSA card must yield one default audio row"); - Device card2 = card; - card2.path = dsp.path + "/sound/card1"; - card2.sysname = "card1"; - QVector multiCard {audioPci, dsp, card, card2}; - for (auto &d : multiCard) classify(d); - refinePresentation(multiCard); - check(multiCard[0].hidden && !multiCard[2].hidden && !multiCard[3].hidden, - "Distinct ALSA cards must remain independently visible"); - audioPci.properties.insert("PCI_CLASS", "0c0330"); - QVector usbAudio {audioPci, dsp, card}; - for (auto &d : usbAudio) classify(d); - refinePresentation(usbAudio); - check(!usbAudio[2].hidden, "A USB controller must not stand in for a downstream sound card"); - audioPci.properties.insert("PCI_CLASS", "40300"); - Device audioPci2 = audioPci; - audioPci2.path = "/sys/devices/pci0000:00/0000:01:00.1"; - QVector twoAudio {audioPci, dsp, card, audioPci2}; - for (auto &d : twoAudio) classify(d); - refinePresentation(twoAudio); - check(!twoAudio[0].hidden && !twoAudio[3].hidden, - "A separate audio controller must not be merged by model or category"); - Device battery; - battery.subsystem = "power_supply"; - battery.sysname = "BAT0"; - battery.name = "Primary"; - battery.properties.insert("POWER_SUPPLY_TYPE", "Battery"); - battery.properties.insert("POWER_SUPPLY_MANUFACTURER", "ExampleVendor"); - battery.properties.insert("POWER_SUPPLY_MODEL_NAME", "Primary"); - check(deviceDisplayName(battery) == "ExampleVendor battery" && battery.name == "Primary", - "Battery captions must use reported metadata while preserving the raw name"); - battery.properties.remove("POWER_SUPPLY_MANUFACTURER"); - check(deviceDisplayName(battery) == "Battery", "An unknown battery manufacturer must never be inferred"); - battery.properties.insert("POWER_SUPPLY_MODEL_NAME", "Real model"); - check(deviceDisplayName(battery) == "Battery (Real model)", "Meaningful battery model identity must survive"); - Attribute denied; - denied.state = ReadState::PermissionDenied; - denied.value = "Stale vendor"; - battery.attributes.insert("manufacturer", denied); - battery.properties.insert("POWER_SUPPLY_MANUFACTURER", "Stale vendor"); - check(!deviceDisplayName(battery).contains("Stale"), "Failed fresh metadata must not reuse a stale manufacturer"); - Device supply; - supply.subsystem = "power_supply"; - supply.sysname = "ADP1"; - supply.properties.insert("POWER_SUPPLY_TYPE", "Mains"); - check(deviceDisplayName(supply) == "AC power adapter", "Mains supply must have a readable role"); - supply.properties.insert("POWER_SUPPLY_TYPE", "USB"); - supply.parentPath = "/sys/devices/platform/USBC000:00"; - supply.sysname = "ucsi-source-psy-USBC000:001"; - check(deviceDisplayName(supply) == "USB-C power supply (port 1)", "UCSI port number must match its direct controller prefix"); - supply.sysname = "ucsi-source-psy-USBC000:002"; - check(deviceDisplayName(supply) == "USB-C power supply (port 2)", "Distinct UCSI supplies must keep distinct port labels"); - supply.parentPath = "/sys/devices/platform/unrelated"; - check(deviceDisplayName(supply) == "USB power supply", "An unmatched UCSI name must not imply a connector number"); - touchpad.properties.insert("PRODUCT", "18/6cb/cfc6/100"); - touchpad.properties.insert("ID_INPUT_TOUCHPAD", "1"); - check(deviceDisplayName(touchpad) == "Touchpad (SYNA3517:00)", "Generated I2C names must put the function before the firmware identifier"); - Device generatedMouse = touchpad; - generatedMouse.properties.insert("NAME", "\"SYNA3517:00 06CB:CFC6 Mouse\""); - generatedMouse.properties.remove("ID_INPUT_TOUCHPAD"); - generatedMouse.properties.insert("ID_INPUT_MOUSE", "1"); - check(deviceDisplayName(generatedMouse) == "Mouse (SYNA3517:00)", "Mouse captions must put the function first too"); - touchpad.properties.insert("PRODUCT", "3/6cb/cfc6/100"); - check(deviceDisplayName(touchpad).contains("SYNA3517"), "USB input names must not be shortened by I2C rules"); - touchpad.properties.insert("PRODUCT", "18/6cb/ffff/100"); - check(deviceDisplayName(touchpad).contains("SYNA3517"), "Mismatched generated identity must retain the original name"); - Device jack; - jack.subsystem = "input"; - jack.sysname = "input23"; - jack.path = card.path + "/input23"; - jack.parentPath = card.path; - jack.properties.insert("NAME", "\"sof-hda-dsp HDMI/DP,pcm=3\""); - jack.properties.insert("PHYS", "\"ALSA\""); - jack.properties.insert("ID_INPUT_SWITCH", "1"); - nameDevice(jack); - check(deviceDisplayName(jack) == "HDMI/DisplayPort jack detection (PCM 3)", "ALSA HDMI switch must describe jack detection, not a monitor"); - QVector audioWithJack {audioPci, dsp, card, jack}; - for (auto &d : audioWithJack) classify(d); - refinePresentation(audioWithJack); - check(audioWithJack[3].category == "audio" && audioWithJack[3].hidden - && audioWithJack[3].representedByPath == audioPci.path, - "ALSA switch must leave the default HID list and remain associated with the audio controller"); - jack.properties.insert("NAME", "\"sof-hda-dsp Headphone\""); - classify(jack); - check(jack.category == "audio" && jack.hidden && deviceDisplayName(jack) == "Headphone jack detection", - "ALSA headphone switches must receive a readable internal audio label"); - jack.properties.insert("NAME", "\"sof-hda-dsp Mic\""); - check(deviceDisplayName(jack) == "Microphone jack detection", "ALSA microphone switches need a readable label"); - jack.properties.remove("PHYS"); - classify(jack); - check(!jack.hidden && jack.category == "hid", "An audio-like name without ALSA identity must remain visible"); - jack.properties.insert("PHYS", "\"ALSA\""); - jack.properties.insert("ID_INPUT_KEYBOARD", "1"); - classify(jack); - check(jack.category == "keyboard" && !jack.hidden, "Mixed keyboard input must remain visible despite an audio-like name"); - QString efiName, efiGuid; - check(splitEfiVariableFilename("BootCurrent-8BE4DF61-93CA-11D2-AA0D-00E098032B8C", efiName, efiGuid) - && efiName == "BootCurrent" && efiGuid == "8be4df61-93ca-11d2-aa0d-00e098032b8c", - "EFI filenames must separate the complete namespace GUID from the original variable name"); - check(efiVariableLabel(efiName) == "Boot Current" && efiVariableLabel("Boot000A") == "Boot 000A" - && efiVariableLabel("SecureBoot") == "Secure Boot" && efiVariableLabel("PKDefault") == "PK Default", - "EFI labels must split concatenated words while preserving acronyms and entry identifiers"); - check(efiVariableLabel("WiMAXModuleID") == "WiMAX Module ID" - && efiVariableLabel("OfflineUniqueIDEKPubCRC") == "Offline Unique ID EK Pub CRC" - && efiVariableLabel("LoaderDevicePartUUID") == "Loader Device Part UUID" - && efiVariableLabel("AMITCGPPIVAR") == "AMITCGPPIVAR" - && efiVariableLabel("EDID1Nv") == "EDID1 Nv" - && efiVariableLabel("SmbiosV3EntryPointTable") == "Smbios V3 Entry Point Table", - "EFI word boundaries must preserve mixed-case terms and capital runs without swallowing ID or version boundaries"); - check(splitEfiVariableFilename("Vendor-Setting-8be4df61-93ca-11d2-aa0d-00e098032b8c", efiName, efiGuid) - && efiName == "Vendor-Setting", "Variable names may themselves contain hyphens"); - check(!splitEfiVariableFilename("BootCurrent-not-a-guid", efiName, efiGuid) - && efiName.isEmpty() && efiGuid.isEmpty(), "Malformed EFI filenames must not retain stale metadata"); - Device efiVariable; - efiVariable.subsystem = "efivarfs"; - efiVariable.name = "BootCurrent"; - efiVariable.properties.insert("EFI_VENDOR_GUID", "8be4df61-93ca-11d2-aa0d-00e098032b8c"); - check(deviceDisplayName(efiVariable) == "Boot Current" - && deviceDisplayName(efiVariable, true).contains(efiVariable.properties.value("EFI_VENDOR_GUID")), - "GUIDs must stay out of default labels and remain available in the internal view"); - return failures ? 1 : 0; -} diff --git a/tests/device-search.cpp b/tests/device-search.cpp deleted file mode 100644 index ed38fac..0000000 --- a/tests/device-search.cpp +++ /dev/null @@ -1,477 +0,0 @@ -#include "devicesearch.h" -#include "devicefilter.h" -#include "devicemodel.h" -#include "resourceformat.h" -#include "propertytext.h" -#include "storagepresentation.h" -#include -#include -#include -#include -#include -#include - -int main(int argc, char **argv) -{ - QCoreApplication app(argc, argv); - int failures = 0; - const auto check = [&](bool ok, const char *message) { - if (!ok) { std::fprintf(stderr, "%s\n", message); ++failures; } - }; - Device device; - device.path = "/sys/devices/fixture"; - device.incarnation = "fixture:1"; - device.name = "Visible device"; - device.category = "other"; - device.subsystem = "pci"; - device.properties.insert("ID_SERIAL", "CaseSensitiveSerial"); - DeviceProperties properties; - properties.device = device; - properties.state = ReadState::Available; - properties.values.insert("sysfs/vendor", {ReadState::Available, "0x8086", 0}); - DeviceResource io; - io.type = DeviceResource::Type::Io; - io.start = 0x300; io.end = 0x31f; - io.source = "/sys/devices/fixture/resource"; - properties.resources.items.append(io); - DeviceProperties statusFixture = properties; - statusFixture.device.properties.clear(); - statusFixture.values.remove("sysfs/vendor"); - statusFixture.values.insert("sysfs/model", {ReadState::Available, "Unavailable", 0}); - statusFixture.values.insert("sysfs/serial", {ReadState::Unavailable, {}, 0}); - statusFixture.values.insert("sysfs/revision", {ReadState::PermissionDenied, {}, 13}); - for (const PropertyEntry &entry : propertyEntries(statusFixture)) { - if (entry.id == "sysfs/model") - check(detailsPropertyStatus(entry).isEmpty(), "A literal status-like device value remains meaningful"); - if (entry.id == "sysfs/serial") - check(entry.state == ReadState::Unavailable && detailsPropertyStatus(entry) == "unavailable", - "Unavailable raw properties retain status independently of displayed text"); - if (entry.id == "sysfs/revision") - check(entry.state == ReadState::PermissionDenied && detailsPropertyStatus(entry) == "permission denied", - "Permission-denied properties remain distinct from unavailable properties"); - if (entry.id == "identifiers") - check(entry.state == ReadState::Unavailable, "Missing derived hardware identifiers carry an unavailable status"); - } - PropertyEntry failedProperty {"failed", "Failure", "localized explanation", {}, false, 2, ReadState::Error, 5}; - check(detailsPropertyStatus(failedProperty) == "read error (errno 5)", "Read error suffix preserves errno"); - DeviceProperties efiStatus = properties; - efiStatus.device.subsystem = "efivarfs"; - for (const PropertyEntry &entry : propertyEntries(efiStatus)) - if (entry.id == "identifiers") - check(entry.state == ReadState::NotApplicable && detailsPropertyStatus(entry) == "not applicable", - "UEFI hardware identifiers carry their derived not-applicable status"); - auto document = deviceSearchDocument({device, "Visible device", {}}, properties); - check(!searchDocumentMatch(document, "casesensitiveserial").isEmpty(), "Raw metadata is case insensitive"); - check(!searchDocumentMatch(document, "32902").isEmpty(), "PCI vendor has decimal alias"); - check(!searchDocumentMatch(document, "0x8086").isEmpty(), "PCI vendor keeps hex value"); - check(searchDocumentMatch(document, "768") == "I/O range", "I/O range has decimal alias and reason"); - check(!searchDocumentMatch(document, "0000000000000300").isEmpty(), "Address keeps padded hex representation"); - Device efi = device; - efi.subsystem = "efivarfs"; - DeviceProperties efiProperties; - efiProperties.device = efi; - efiProperties.state = ReadState::Available; - efiProperties.efiBytes = QByteArray::fromHex("4142ff"); - efiProperties.values.insert("efi/hex", {ReadState::Available, {}, 0}); - const auto efiDocument = deviceSearchDocument({efi, "EFI fixture", {}}, efiProperties); - check(!searchDocumentMatch(efiDocument, "41 42 ff").isEmpty(), "EFI hex bytes are searchable"); - check(!searchDocumentMatch(efiDocument, "AB.").isEmpty(), "EFI printable byte view is searchable"); - check(!searchDocumentMatch(efiDocument, "4142ff").isEmpty(), "EFI compact hex bytes are searchable"); - // Synthetic storage observations, not live hardware results. Missing health - // must remain unavailable, while explicit cached evidence stays searchable. - DeviceProperties storageFixture; - storageFixture.device = device; - storageFixture.state = ReadState::Available; - storageFixture.storage.applicable = true; - StorageEntity volume; - volume.id = "/sys/devices/storage-fixture"; - volume.fields.append({"capacity", "Block capacity", {ReadState::Available, "18446744073709551615", 0}, - "fixture/size", StorageField::Format::Bytes}); - volume.fields.append({"health", "Cached NVMe critical warnings", {}, "fixture/cache", StorageField::Format::NvmeHealth}); - volume.fields.append({"mounts", "Mount points", {ReadState::Available, "/fixture mount", 0}, "fixture/mountinfo"}); - storageFixture.storage.entities.append(volume); - const auto storageEntries = propertyEntries(storageFixture); - bool unavailableHealth = false; - bool exactCapacity = false; - for (const PropertyEntry &entry : storageEntries) { - if (entry.id.endsWith("/health")) unavailableHealth = entry.value == "Unavailable"; - if (entry.id.endsWith("/capacity")) exactCapacity = entry.value.endsWith(" bytes") && !entry.value.contains('('); - } - check(unavailableHealth, "Missing health data cannot produce a healthy verdict"); - check(exactCapacity, "Capacity above signed 64-bit range keeps exact bytes without a clamped size"); - const auto storageDocument = deviceSearchDocument({device, "Visible device", {}}, storageFixture); - check(!searchDocumentMatch(storageDocument, "/fixture mount").isEmpty(), "Storage mounts are searchable"); - storageFixture.state = ReadState::Removed; - check(searchDocumentMatch(deviceSearchDocument({device, "Visible device", {}}, storageFixture), "/fixture mount").isEmpty(), - "Removed instances cannot contribute partial storage results to search"); - properties.state = ReadState::Removed; - document = deviceSearchDocument({device, "Visible device", {}}, properties); - check(searchDocumentMatch(document, "32902").isEmpty(), "Invalidated properties do not contribute partial data"); - check(!searchDocumentMatch(document, "casesensitiveserial").isEmpty(), "Last inventory remains searchable after removal"); - - Device hidden = device; - hidden.path += "/hidden"; - hidden.hidden = true; - DeviceModel model; - model.setInventory({device, hidden}); - const auto records = model.searchRecords(); - check(records.size() == 1, "Hidden devices are excluded from search inputs"); - if (records.isEmpty()) return 1; - const auto &record = records.front(); - DeviceFilter filter; - filter.setSourceModel(&model); - filter.setQuery("casesensitiveserial", false); - check(filter.visibleCount() == 0 && filter.rowCount() == 1 - && filter.rowCount(filter.index(0, 0)) == 0, "Name search ignores metadata and empty categories but retains computer root"); - filter.setQuery("casesensitiveserial", true); - filter.addMatches({{record.device.path, record.device.generation + 1, "ID_SERIAL"}}); - check(filter.visibleCount() == 0, "A stale generation cannot match a reused path"); - filter.addMatches({{record.device.path, record.device.generation, "ID_SERIAL"}}); - check(filter.visibleCount() == 1 && filter.rowCount() == 1, "Deep match retains its category"); - filter.setQuery(record.label.toUpper(), false); - check(filter.visibleCount() == 1, "Name search uses the exact displayed label, case insensitively"); - filter.setQuery({}, false); - check(filter.visibleCount() == 1, "Clearing query restores eligible devices"); - model.setShowInternal(true); - check(model.searchRecords().size() == 2 && filter.visibleCount() == 2, "Internal visibility applies to both modes"); - // Phase 3 projections: bus-qualified identity, real ancestry and synthetic rows. - Device pci = device; - pci.driver = "shared"; - pci.driverModule = "owner"; - Device usb = device; - usb.path = "/sys/devices/usb-fixture"; - usb.name = "USB fixture"; - usb.subsystem = "usb"; - usb.driver = "shared"; - usb.driverModule = "owner"; - Device child = device; - child.path = pci.path + "/child"; - child.parentPath = pci.path; - child.name = "Child fixture"; - Device context = pci; - context.hidden = true; - model.setShowInternal(false); - model.setInventory({context, child, usb}); - model.setView(DeviceModel::View::Connection); - check(model.visibleCount() == 3, "Connection retains hidden ancestor context"); - // Find generations by record identity; presentation ordering is not ownership. - quint64 childGeneration = 0; - for (const auto &entry : model.searchRecords()) - if (entry.device.path == child.path) childGeneration = entry.device.generation; - auto childIndex = model.findDevice(child.path, childGeneration); - check(childIndex.parent().data(DeviceModel::PathRole).toString() == pci.path, - "Connection uses actual parent record"); - filter.setQuery("Child fixture", false); - check(filter.visibleCount() == 2, "Connection filter retains matching device ancestry"); - filter.setQuery({}, false); - model.setInventory({pci, child, usb}); - for (const auto &entry : model.searchRecords()) - if (entry.device.path == child.path) childGeneration = entry.device.generation; - model.setView(DeviceModel::View::DevicesByDriver); - check(model.rowCount(model.index(0, 0)) == 3, "Same driver name on two buses stays distinct from unbound group"); - check(model.visibleCount() == 3 && filter.visibleCount() == 3, - "Driver groups never inflate device count"); - model.setView(DeviceModel::View::DriversByDevice); - childIndex = model.findDevice(child.path, childGeneration); - check(model.rowCount(childIndex) == 2, "Unbound child has separate parent-driver relationship"); - for (int row = 0; row < model.rowCount(childIndex); ++row) - check(!model.index(row, 0, childIndex).data(DeviceModel::PathRole).isValid(), - "Synthetic driver rows cannot retarget device Properties"); - check(model.searchRecords().size() == 3 && filter.visibleCount() == 3, - "Device-centric search/count ignores module rows"); - model.setView(DeviceModel::View::DriversByType); - check(model.rowCount(model.index(0, 0)) == 1 - && model.rowCount(model.index(0, 0, model.index(0, 0))) == 3, - "Drivers by type nests bus-qualified groups within category"); - check(model.findDevice(child.path, childGeneration).isValid(), "Instance selection survives view changes"); - Device unknown = pci; - unknown.driverModule.clear(); - model.setInventory({unknown, child, usb}); - model.setView(DeviceModel::View::DriversByDevice); - quint64 pciGeneration = 0; - for (const auto &entry : model.searchRecords()) - if (entry.device.path == pci.path) pciGeneration = entry.device.generation; - const QModelIndex pciIndex = model.findDevice(pci.path, pciGeneration); - const QModelIndex driverIndex = model.index(0, 0, pciIndex); - check(model.index(0, 0, driverIndex).data().toString().contains("undetermined"), - "Missing module link does not establish built-in or modular status"); - filter.setQuery("Visible device", false); - const QModelIndex filteredPci = filter.mapFromSource(pciIndex); - check(filter.rowCount(filteredPci) == 1 && filter.rowCount(filter.index(0, 0, filteredPci)) == 1, - "Filtering a device retains its driver and module-status rows"); - filter.setQuery({}, false); - for (const auto mode : {DeviceModel::View::Type, DeviceModel::View::Connection, - DeviceModel::View::DevicesByDriver, DeviceModel::View::DriversByDevice, - DeviceModel::View::DriversByType}) { - model.setView(mode); - const QModelIndex computer = model.index(0, 0); - check(model.rowCount() == 1 && computer.data(DeviceModel::NodeKeyRole).toString() == "computer", - "Every projection has one computer root"); - check(!computer.data(DeviceModel::PathRole).isValid() && !computer.data().toString().isEmpty(), - "Computer root has a caption and is not a device Properties target"); - check(model.visibleCount() == 3 && model.searchRecords().size() == 3, - "Computer root is excluded from device counts and search records"); - } - model.setView(DeviceModel::View::Type); - check(model.visibleCount() == 3, "Returning to type projection retains same inventory"); - // Live updates keep persistent indexes and replace observed instances. - int resets = 0; - int changes = 0; - QObject observationContext; // Disconnect before the captured counters leave scope. - QObject::connect(&model, &QAbstractItemModel::modelReset, &observationContext, [&] { ++resets; }); - QObject::connect(&model, &QAbstractItemModel::dataChanged, &observationContext, [&] { ++changes; }); - model.setInventory({pci, child, usb}); - auto currentGeneration = [&](const QString &path) { - for (const auto &entry : model.searchRecords()) - if (entry.device.path == path) return entry.device.generation; - return quint64(0); - }; - pciGeneration = currentGeneration(pci.path); - QPersistentModelIndex retained(model.findDevice(pci.path, pciGeneration)); - changes = 0; - model.setInventory({pci, child, usb}); - check(retained.isValid() && changes == 0 && resets == 0, - "Unchanged live snapshots emit no row data changes or model resets"); - Device renamed = pci; - renamed.name = "ZZ renamed fixture"; - model.setInventory({renamed, child, usb}); - check(retained.isValid() && retained.data().toString().contains("ZZ renamed"), - "Rename/reorder preserves persistent instance index"); - QPersistentModelIndex oldChild(model.findDevice(child.path, currentGeneration(child.path))); - model.setInventory({renamed, child, usb}, {pci.path}); - check(!retained.isValid() && !oldChild.isValid() && !model.device(pci.path, pciGeneration), - "Remove/add replaces parent and descendant identities even with unchanged inode hints"); - QPersistentModelIndex lost(model.findDevice(usb.path, currentGeneration(usb.path))); - model.setInventory({renamed, child, usb}, {}, true); - check(!lost.isValid() && resets == 0, "Event loss invalidates identities through row replacement"); - for (const auto mode : {DeviceModel::View::Type, DeviceModel::View::Connection, - DeviceModel::View::DevicesByDriver, DeviceModel::View::DriversByDevice, - DeviceModel::View::DriversByType}) { - model.setView(mode); - QPersistentModelIndex stable(model.findDevice(usb.path, currentGeneration(usb.path))); - model.setInventory({renamed, usb}); - check(stable.isValid() && model.visibleCount() == 2, "Removal retains unrelated rows in every projection"); - model.setInventory({renamed, child, usb}); - check(stable.isValid() && model.visibleCount() == 3, "Addition retains unrelated rows in every projection"); - } - // Resource projections share formatting, retain real ancestry and count - // unique devices rather than vectors, ranges or repeated type memberships. - auto resources = std::make_shared(); - DeviceResource vector; - vector.type = DeviceResource::Type::Irq; - vector.start = vector.end = 121; - vector.mode = "msix"; - vector.source = pci.path + "/msi_irqs/121"; - resources->items.append(vector); - DeviceResource memory; - memory.type = DeviceResource::Type::Memory; - memory.start = 0x80000000; - memory.end = 0x80000fff; - memory.index = 0; - memory.pci = true; - memory.flags = 0x00102200; - memory.source = pci.path + "/resource"; - resources->items.append(memory); - const auto formatted = resourceRows(*resources); - check(formatted.size() == 2 && formatted.at(0).setting == "121" - && formatted.at(0).details.contains("MSI-X") && formatted.at(1).details.contains("BAR 0") - && formatted.at(1).details.contains("Prefetchable"), "Shared resource formatter preserves vectors/BAR flags"); - Device bus = pci; - bus.path = "/sys/devices/pci-root"; - bus.name = "Real parent context"; - bus.hidden = true; - bus.resources.reset(); - pci.resources = resources; - pci.parentPath = bus.path; - auto childResources = std::make_shared(*resources); - for (auto &entry : childResources->items) - entry.source.replace(pci.path, child.path); - child.resources = childResources; - // Shared IRQ/range evidence stays on both devices, without conflict inference. - model.setInventory({bus, pci, child, usb}); - model.setView(DeviceModel::View::ResourcesByType); - check(model.rowCount(model.index(0, 0)) == 2 && model.visibleCount() == 2 - && model.searchRecords().size() == 2 && filter.visibleCount() == 2, - "Type view groups memory/IRQ while deduplicating device counts and search inputs"); - pciGeneration = currentGeneration(pci.path); - auto resourceOwner = model.findDevice(pci.path, pciGeneration); - auto resourceIndex = model.index(0, 0, resourceOwner); - check(resourceIndex.data(DeviceModel::ResourceRole).toBool() - && !resourceIndex.data(DeviceModel::PathRole).isValid() - && resourceIndex.data(DeviceModel::OwnerPathRole).toString() == pci.path - && resourceIndex.data(DeviceModel::OwnerGenerationRole).toULongLong() == pciGeneration, - "Resource rows carry an instance-scoped owner without becoming device records"); - const QString resourceKey = resourceIndex.data(DeviceModel::NodeKeyRole).toString(); - QPersistentModelIndex stableResource(resourceIndex); - Device cloned = pci; - cloned.resources = std::make_shared(*resources); - check(!model.setInventory({bus, cloned, child, usb}) && stableResource.isValid(), - "Equivalent owned resource snapshots do not rebuild the model"); - check(model.findNode(resourceKey, pci.path, pciGeneration).isValid(), - "Resource selection restores its exact node by device generation"); - auto changedResources = std::make_shared(*resources); - changedResources->items[0].start = changedResources->items[0].end = 122; - changedResources->items[0].source = pci.path + "/msi_irqs/122"; - Device reassigned = pci; - reassigned.resources = changedResources; - QPersistentModelIndex sameOwner(resourceOwner); - check(model.setInventory({bus, reassigned, child, usb}) && sameOwner.isValid() - && !stableResource.isValid() && currentGeneration(pci.path) == pciGeneration, - "Resource-only changes update assignment rows without replacing the reporting device"); - model.setInventory({bus, pci, child, usb}); - filter.setQuery("121", false); - check(filter.visibleCount() == 2, "Name filtering finds resource settings and retains both shared owners"); - filter.setQuery({}, false); - model.setView(DeviceModel::View::ResourcesByConnection); - resourceOwner = model.findDevice(pci.path, pciGeneration); - check(resourceOwner.parent().data(DeviceModel::PathRole).toString() == bus.path - && model.visibleCount() == 3 && model.searchRecords().size() == 3, - "Connection view retains real hidden ancestry and excludes unrelated no-resource devices"); - childIndex = model.findDevice(child.path, currentGeneration(child.path)); - check(childIndex.parent() == resourceOwner && model.rowCount(childIndex) == 2, - "Connection view follows recorded parents and adds direct resource children"); - filter.setQuery("121", false); - check(filter.visibleCount() == 3, "Resource filtering retains connection-context ancestors"); - filter.setQuery({}, false); - childGeneration = currentGeneration(child.path); - model.setInventory({bus, pci, usb}); - check(model.visibleCount() == 2 && !model.findDevice(child.path, childGeneration).isValid(), - "Resource connection updates remove vanished records"); - model.setView(DeviceModel::View::ResourcesByType); - resourceOwner = model.findDevice(pci.path, pciGeneration); - QPersistentModelIndex replacedResource(model.index(0, 0, resourceOwner)); - model.setInventory({bus, pci, usb}, {pci.path}); - check(!replacedResource.isValid() && !model.findNode(resourceKey, pci.path, pciGeneration).isValid(), - "A replaced device cannot inherit resource selection or an old owner generation"); - // Storage presentation stays independent of widgets and live services. - StorageProperties storage; - storage.applicable = true; - const auto sf = [](const QString &id, const QString &value, StorageField::Format format = StorageField::Format::Text) { - return StorageField {id, "Fixture", {ReadState::Available, value, 0}, "fixture:" + id, format}; - }; - StorageEntity nativeBlock {"/sys/devices/fixture/block/sda", { - sf("DEVNAME", "/dev/sda"), sf("dev", "8:0"), sf("capacity", "1024", StorageField::Format::Bytes), - sf("ID_FS_TYPE", "ext4"), sf("ID_FS_LABEL", "data"), sf("mounts", "/data")}}; - StorageEntity cachedBlock {"/org/freedesktop/UDisks2/block_devices/sda", { - sf("org.freedesktop.UDisks2.Block/IdType", "xfs")}}; - StorageEntity cachedDrive {"/org/freedesktop/UDisks2/drives/fixture", { - sf("org.freedesktop.UDisks2.Drive/Model", "Fixture Drive"), - sf("org.freedesktop.UDisks2.Drive/Size", "4096", StorageField::Format::Bytes), - sf("org.freedesktop.UDisks2.Drive.Ata/SmartFailing", "0", StorageField::Format::AtaHealth)}}; - storage.entities = {nativeBlock, cachedBlock, cachedDrive}; - storage.links = {{nativeBlock.id, cachedBlock.id, "UDisks2 block metadata", "UDisks2 Block.DeviceNumber / sysfs dev"}, - {cachedBlock.id, cachedDrive.id, "Storage relationship", "org.freedesktop.UDisks2.Block.Drive"}}; - auto presented = storagePresentation(storage, nativeBlock.id); - check(presented.volumes.size() == 1 && presented.volumes.front().name == "/dev/sda — data", - "An exact native/service block mapping produces one recognizable volume"); - check(storageFieldsText(presented.volumes.front().fields).contains("Conflicting metadata:"), - "Conflicting block content metadata remains explicit with both sources"); - check(presented.health.size() == 1 && presented.health.front().name.contains("/dev/sda") - && storageRowsText(presented.health).contains("not a guarantee of health"), - "Health is per drive, names its mapped block and preserves cached scope"); - check(storageFieldsText(presented.overview).contains("Block capacity:") - && storageFieldsText(presented.overview).contains("Drive capacity:"), - "Physical drive capacity is separate from selected block capacity"); - check(presented.preferredHealthDrive == cachedDrive.id, - "A unique explicit drive association determines the initial health selection"); - { - // A related drive sorts before the selected device's drive (e.g. RAID/LVM). - StorageProperties related = storage; - StorageEntity otherDrive = cachedDrive; - otherDrive.id = "/org/freedesktop/UDisks2/drives/aaa_related"; - related.entities.append(otherDrive); - auto multiple = storagePresentation(related, nativeBlock.id); - check(multiple.health.size() == 2 && multiple.health.front().id == otherDrive.id - && multiple.preferredHealthDrive == cachedDrive.id, - "A related drive's sort order cannot determine the selected device's preferred health drive"); - const QString controller = "/sys/devices/fixture"; - check(storagePresentation(related, controller).preferredHealthDrive == cachedDrive.id, - "A controller can prefer the unique drive explicitly associated with its descendants"); - StorageEntity otherBlock {controller + "/block/sdb", {sf("DEVNAME", "/dev/sdb")}}; - related.entities.append(otherBlock); - related.links.append({otherBlock.id, otherDrive.id, "Storage relationship", "org.freedesktop.UDisks2.Block.Drive"}); - check(storagePresentation(related, controller).preferredHealthDrive.isEmpty(), - "Multiple explicitly associated drives do not establish a unique preferred health drive"); - } - storage.links.removeFirst(); - check(storagePresentation(storage, nativeBlock.id).volumes.size() == 2, - "Matching object basenames without an exact mapping never merge entities"); - check(storagePresentation(storage, nativeBlock.id).preferredHealthDrive.isEmpty(), - "Without exact block mapping, object basenames cannot establish the preferred health drive"); - storage.entities = {nativeBlock}; - storage.links.clear(); - presented = storagePresentation(storage, nativeBlock.id); - check(presented.health.size() == 1 && storageRowsText(presented.health).contains("Unavailable"), - "Native-only snapshots explicitly report unavailable cached health"); - check(presented.preferredHealthDrive.isEmpty(), - "Unavailable native-only health has no preferred UDisks2 drive"); - { - StorageProperties health; - const QLocale previousLocale; - QLocale::setDefault(QLocale::c()); - const QString nvme = "org.freedesktop.UDisks2.NVMe.Controller/"; - const QString attributes = nvme + "SmartGetAttributes/"; - StorageEntity drive {"/org/freedesktop/UDisks2/drives/nvme_fixture", { - sf("org.freedesktop.UDisks2.Drive/Model", "NVMe fixture"), - sf(nvme + "SmartUpdated", "1720000000", StorageField::Format::Epoch), - sf(nvme + "SmartCriticalWarning", "readonly\nfuture_warning", StorageField::Format::NvmeHealth), - sf(attributes + "percent_used", "150", StorageField::Format::Percent), - sf(attributes + "media_errors", "0", StorageField::Format::Count), - sf(attributes + "temp_sensors/1", "306", StorageField::Format::Kelvin), - {attributes + "temp_sensors/2", "Fixture", {}, "fixture:absent", StorageField::Format::Kelvin}, - {attributes + "temp_sensors/3", "Fixture", {ReadState::Error, {}, EPROTO}, "fixture:invalid", StorageField::Format::Kelvin}, - {attributes + "temp_sensors/4", "Fixture", {ReadState::Error, {}, EPROTO}, "fixture:invalid", StorageField::Format::Kelvin} - }}; - health.entities = {drive}; - auto report = storagePresentation(health, drive.id); - const QString copied = storageRowsText(report.health); - check(report.health.front().healthGroups.front().title == "Health and endurance" - && copied.contains("150%") && copied.contains("Media and data integrity errors: 0"), - "Grouped NVMe health preserves over-100 endurance estimates and zero error counters"); - check(copied.contains("Temperature sensor 1: 33 °C") && !copied.contains("Temperature sensor 2") - && copied.count("Temperature sensors:") == 1, - "NVMe sensor precision, omitted unreported slots and retained sensor errors reach copied output"); - check(copied.contains("Media is in read-only mode") && copied.contains("future_warning"), - "Known critical warnings are explained and unknown warning tokens remain visible"); - drive.id = "/org/freedesktop/UDisks2/drives/ata_fixture"; - drive.fields = {sf("org.freedesktop.UDisks2.Drive/Model", "ATA fixture"), - sf("org.freedesktop.UDisks2.Drive.Ata/SmartFailing", "0", StorageField::Format::AtaHealth)}; - drive.ataAttributes = {{194, "temperature-celsius", 0, 100, 95, 0, 306000, 4}, - {9, "power-on-hours", 0, 0, -1, 0, 1234567, 0}, - {5, "reallocated-sector-count", 3, 100, 100, 36, 0, 3}}; - health.entities = {drive}; - report = storagePresentation(health, drive.id); - const auto &table = report.health.front().ataAttributes; - const QString ataCopy = storageRowsText(report.health); - check(table.size() == 3 && table[0].id == "5" && table[1].current == "0" - && table[1].worst == "Unavailable" && table[1].interpreted == "Unavailable" - && table[2].interpreted == "32.9 °C", - "ATA table preserves ID order, normalized zero, unknown values and millikelvin precision"); - check(ataCopy.contains("reallocated-sector-count (ID 5)") && ataCopy.contains("0 sectors") - && ataCopy.contains("Flags: 0x0003") && ataCopy.contains("Source: " + drive.id), - "Health copying includes ATA table values, zero counters, flags and provenance"); - QLocale::setDefault(previousLocale); - } - Device storageDevice = device; - storageDevice.subsystem = "block"; - DeviceProperties storageSnapshot; - storageSnapshot.device = storageDevice; - storageSnapshot.storage = storage; - storageSnapshot.values.insert("udev/ID_FS_UUID", {ReadState::Available, "uuid", 0}); - storageSnapshot.device.properties.insert("DM_UUID", "dm-uuid"); - storageSnapshot.device.attributes.insert("size", {ReadState::Available, "2", 0}); - int excludedStorageEntries = 0; - for (const PropertyEntry &entry : propertyEntries(storageSnapshot)) { - if (entry.tab == 3 || entry.id == "udev/ID_FS_UUID" || entry.id == "inventory/DM_UUID" - || entry.id == "inventory/sysfs/size") { - ++excludedStorageEntries; - check(!isDetailsProperty(entry, storageDevice), "Storage fields never enter Details, including advanced fields"); - } - if (entry.id == "name" || entry.id == "module/type") - check(isDetailsProperty(entry, storageDevice), "Generic device and driver properties remain in Details"); - } - check(excludedStorageEntries > 3, "Details exclusion fixture exercises storage and raw metadata"); - check(isDetailsProperty({"udev/ID_SERIAL", "Serial", "value", {}, true, 2}, device), - "Raw non-storage device identity remains available"); - return failures ? 1 : 0; -} diff --git a/tests/directory-scan.cpp b/tests/directory-scan.cpp deleted file mode 100644 index f1a1c0c..0000000 --- a/tests/directory-scan.cpp +++ /dev/null @@ -1,58 +0,0 @@ -#include "directoryscan.h" -#include -#include -#include -#include -#include -#include - -int main(int argc, char **argv) -{ - QCoreApplication app(argc, argv); - int failures = 0; - const auto check = [&](bool ok, const char *message) { - if (!ok) { std::fprintf(stderr, "%s\n", message); ++failures; } - }; - QTemporaryDir fixture; - check(fixture.isValid(), "Temporary fixture directory is available"); - if (!fixture.isValid()) return 1; - const QDir root(fixture.path()); - check(root.mkdir("zeta") && root.mkdir("alpha") && root.mkdir(".hidden"), "Fixture directories created"); - QFile file(root.filePath("regular")); - check(file.open(QIODevice::WriteOnly), "Fixture regular file created"); - file.close(); - const auto link = [&](const char *target, const char *name) { - return symlink(target, root.filePath(QLatin1String(name)).toUtf8().constData()) == 0; - }; - check(link("alpha", "linked") && link("regular", "file-link") && link("missing", "broken"), - "Fixture directory, file and dangling links created"); - const DirectoryNames complete = boundedDirectoryNames(root.path(), 3); - check(!complete.error && complete.names == QStringList({"alpha", "linked", "zeta"}), - "Listing preserves directory symlinks and sorted names; omits files, broken links, hidden and dot entries"); - const DirectoryNames overflow = boundedDirectoryNames(root.path(), 2); - check(overflow.error == EOVERFLOW && overflow.names.isEmpty(), "Overflow rejects the partial listing"); - const DirectoryNames filtered = boundedDirectoryNames(root.path(), 1, {"a*"}); - check(!filtered.error && filtered.names == QStringList({"alpha"}), "Name filtering counts only matching directories"); - check(boundedDirectoryNames(root.path(), 0).error == EOVERFLOW, "Zero bound rejects a nonempty listing"); - check(boundedDirectoryNames(root.path(), -1).error == EINVAL, "Negative bound is invalid"); - check(root.mkdir("empty"), "Empty fixture directory created"); - const DirectoryNames empty = boundedDirectoryNames(root.filePath("empty"), 0); - check(!empty.error && empty.names.isEmpty(), "Empty listing fits a zero bound"); - class CancelledScan final : public QThread { - public: - QString path; - DirectoryNames result; - protected: - void run() override - { - requestInterruption(); - result = boundedDirectoryNames(path, 3); - } - } worker; - worker.path = root.path(); - worker.start(); - check(worker.wait(5000), "Cancellation fixture worker completes"); - if (worker.isRunning()) worker.wait(); - check(worker.result.error == ECANCELED && worker.result.names.isEmpty(), "Cancellation never returns partial names"); - return failures ? 1 : 0; -} diff --git a/tests/monitor-state.cpp b/tests/monitor-state.cpp deleted file mode 100644 index 4360c74..0000000 --- a/tests/monitor-state.cpp +++ /dev/null @@ -1,50 +0,0 @@ -#include "monitorstate.h" -#include -#include - -int main(int argc, char **argv) -{ - QCoreApplication app(argc, argv); - int failures = 0; - const auto check = [&](bool ok, const char *message) { - if (!ok) { std::fprintf(stderr, "%s\n", message); ++failures; } - }; - MonitorState events; - events.observe("/sys/devices/usb/device", true); - events.observe("/sys/devices/usb/device", false); - check(events.changed.size() == 1 && events.removed.size() == 1, - "Coalescing remove/add retains replacement evidence"); - events.beginScan(); - check(!events.dirty && events.changed.isEmpty() && events.removed.size() == 1, - "Fresh scans clear changes but retain removals until publication"); - events.observe("/sys/devices/usb/device", false); - Device ancestor; ancestor.path = "/sys/devices/usb"; - Device target; target.path = ancestor.path + "/device"; - Device child; child.path = target.path + "/interface"; child.parentPath = target.path; - Device peer; peer.path = ancestor.path + "/other"; peer.parentPath = ancestor.path; - Device independent; independent.path = "/sys/devices/pci/card"; - Inventory inventory; inventory.devices = {ancestor, target, child, peer, independent}; - events.excludeUnsettled(inventory); - check(inventory.devices.size() == 2 && inventory.devices.at(0).path == peer.path - && inventory.devices.at(1).path == independent.path, - "Unsettled ancestors and descendants are withheld without losing unrelated siblings"); - check(inventory.skipped == 3, "Withheld records are counted"); - events.lose(); - inventory.devices = {independent}; - events.excludeUnsettled(inventory); - check(inventory.devices.isEmpty() && events.lost, "Loss during collection withholds untrusted snapshot"); - events.beginScan(); - inventory.devices = {independent}; - events.excludeUnsettled(inventory); - check(inventory.devices.size() == 1 && events.lost, - "A fresh loss-recovery scan can publish while forcing new generations"); - events = {}; - for (int i = 0; i <= MonitorState::Limit; ++i) - events.observe(QStringLiteral("/sys/devices/fixture/%1").arg(i), true); - check(events.lost && events.changed.size() <= MonitorState::Limit - && events.removed.size() <= MonitorState::Limit, "Bounded hints degrade to full reconciliation"); - events = {}; - events.observe("/sys/class/usb", true); - check(!events.dirty, "Non-canonical hints do not enter inventory reconciliation"); - return failures ? 1 : 0; -} diff --git a/tests/system-properties.cpp b/tests/system-properties.cpp deleted file mode 100644 index 4a2beff..0000000 --- a/tests/system-properties.cpp +++ /dev/null @@ -1,177 +0,0 @@ -#include "systemproperties.h" -#include -#include -#include -#include - -int main(int argc, char **argv) -{ - QCoreApplication app(argc, argv); - int failures = 0; - const auto check = [&](bool ok, const char *message) { - if (!ok) { std::fprintf(stderr, "%s\n", message); ++failures; } - }; - check(parseOsName("PRETTY_NAME=\"Example Linux 2\"\n").value == "Example Linux 2", "Double quoted OS caption"); - check(parseOsName("PRETTY_NAME='Example $literal'\n").value == "Example $literal", "Single quotes preserve literal dollars"); - check(parseOsName("PRETTY_NAME=\"Escaped \\\"quote\\\" and \\$dollar\"\n").value == "Escaped \"quote\" and $dollar", - "Decode permitted shell escapes without evaluation"); - check(parseOsName("PRETTY_NAME=\"$(must-not-run)\"\n").value == "$(must-not-run)", "Command substitution remains literal text"); - check(parseOsName("PRETTY_NAME=\"unterminated\n").state == ReadState::Error, "Malformed OS quote is explicit error"); - check(parseOsName("NAME=Example\n").value == "Linux", "Missing PRETTY_NAME uses documented Linux default"); - check(parseOsName("PRETTY_NAME=First\nPRETTY_NAME=Second\n").value == "Second", "Last OS definition wins"); - QSet cpus; - check(parseCpuList("0-3,8,10-11", cpus) && cpus.size() == 7 && cpus.contains(11) && !cpus.contains(9), - "Sparse CPU ranges retain actual IDs"); - const QSet previous = cpus; - check(!parseCpuList("3-1", cpus) && cpus == previous, "Reversed ranges fail without partial output"); - check(!parseCpuList("0-500000", cpus), "CPU list expansion is bounded"); - check(!parseCpuList("0,", cpus) && !parseCpuList("-1", cpus) && !parseCpuList("word", cpus), - "Invalid CPU identifiers never become CPU zero"); - check(parseCpuInstructionSets("processor : 0\nflags : sse avx2 sse\n\nprocessor : 1\nflags : avx2 sse\n").value - == "0, 1\tavx2 sse", "Identical CPU feature lists group without duplicate flags"); - const Attribute heterogeneous = parseCpuInstructionSets("processor : 0\nflags : sse avx2\n\nprocessor : 1\nflags : sse\n"); - check(heterogeneous.value.contains("0\tavx2 sse") && heterogeneous.value.contains("1\tsse"), - "Different CPU flags never become a universal feature union"); - check(parseCpuInstructionSets("processor : 0\nFeatures : aes asimd\n").value == "0\taes asimd", - "ARM feature field is retained"); - check(parseCpuInstructionSets("processor : 0\nisa : rv64imafdc\n").value == "0\trv64imafdc", - "RISC-V ISA declaration is retained"); - check(parseCpuInstructionSets("processor : 0\nflags : sse\n\nprocessor : 1\nmodel name : Example\n").state - == ReadState::Unavailable, "Missing per-CPU feature list prevents an apparently complete result"); - check(parseCpuInstructionSets("processor : 0\nflags : sse\n\nprocessor : 0\nflags : avx\n").error == EINVAL, - "Duplicate CPU records are rejected"); - check(parseCpuInstructionSets("Hardware : Board\nFeatures : aes\n").state == ReadState::Unavailable, - "Global board fields are not assigned to a processor"); - check(parseCpuInstructionSets(QString(4 * 1024 * 1024 + 1, 'x')).error == EOVERFLOW, - "CPU feature parser input is bounded"); - check(parseResizableBarSizes("00000000000001c0").value == "1c0", - "Resizable BAR bitmap preserves supported 64/128/256 MiB sizes"); - check(parseResizableBarSizes("100").value == "100", "Single supported 256 MiB size is valid capability evidence"); - check(parseResizableBarSizes("0").error == EINVAL && parseResizableBarSizes("wrong").error == EINVAL, - "Empty or malformed supported-size masks are explicit errors"); - check(parseResizableBarSizes("100000000000").error == EOVERFLOW, - "Unsupported size arithmetic never shifts beyond 64 bits"); - check(parseResizableBarAllocation("0000009800000000 000000980fffffff 000000000014220c", 0).value == "268435456", - "Allocated GPU memory aperture uses inclusive resource range, not an enabled heuristic"); - check(parseResizableBarAllocation("0 0 200", 0).state == ReadState::Unavailable, - "Masked or empty addresses never become a one-byte BAR"); - check(parseResizableBarAllocation("0 ffffffff 20000200", 0).state == ReadState::Unavailable, - "Unassigned resources do not establish a current aperture"); - check(parseResizableBarAllocation("100 1ff 100", 0).state == ReadState::Unavailable, - "I/O resources are not memory apertures"); - check(parseResizableBarAllocation("200 100 200", 0).error == EINVAL - && parseResizableBarAllocation("garbage", 0).error == EINVAL, - "Malformed and reversed allocations remain explicit errors"); - check(parseResizableBarAllocation("0 ffffffffffffffff 200", 0).error == EOVERFLOW, - "Inclusive full-address-space size cannot overflow"); - check(parseResizableBarAllocation("0 0 0", 6).error == EINVAL, "Only the six PCI BAR resource slots are parsed"); - QHash memory { - {"MEMORY_ARRAY_NUM_DEVICES", "2"}, - {"MEMORY_DEVICE_0_SIZE", "17179869184"}, - {"MEMORY_DEVICE_1_SIZE", "17179869184"} - }; - check(parseInstalledMemory(memory).value == "34359738368", "Two 16 GiB devices report 32 GiB installed"); - memory["MEMORY_ARRAY_NUM_DEVICES"] = "3"; - memory["MEMORY_DEVICE_2_PRESENT"] = "0"; - check(parseInstalledMemory(memory).value == "34359738368", "Explicitly empty slot does not invalidate total"); - memory.remove("MEMORY_DEVICE_2_PRESENT"); - check(parseInstalledMemory(memory).state == ReadState::Unavailable, "Unknown slot never produces a partial total"); - memory["MEMORY_DEVICE_2_SIZE"] = "garbage"; - check(parseInstalledMemory(memory).state == ReadState::Error, "Malformed capacity is a read error"); - memory["MEMORY_DEVICE_2_SIZE"] = "18446744073709551615"; - check(parseInstalledMemory(memory).error == EOVERFLOW, "Installed capacity sum checks overflow"); - memory["MEMORY_DEVICE_2_PRESENT"] = "0"; - check(parseInstalledMemory(memory).state == ReadState::Error, "Nonzero size conflicts with empty slot evidence"); - memory.remove("MEMORY_DEVICE_2_SIZE"); - memory["MEMORY_DEVICE_0_NON_VOLATILE_SIZE"] = "0"; - memory["MEMORY_DEVICE_1_NON_VOLATILE_SIZE"] = "Unknown"; - check(parseInstalledMemory(memory).value == "34359738368", "Unknown optional nonvolatile field preserves known capacities"); - memory["MEMORY_DEVICE_0_NON_VOLATILE_SIZE"] = "17179869184"; - check(parseInstalledMemory(memory).state == ReadState::Unsupported, "Persistent capacity is not silently counted as RAM"); - memory["MEMORY_ARRAY_NUM_DEVICES"] = "4097"; - check(parseInstalledMemory(memory).error == EOVERFLOW, "Memory slot count is bounded"); - check(parseInstalledMemory({}).state == ReadState::Unavailable, "Missing firmware data is not zero RAM"); - QHash records { - {"MEMORY_ARRAY_NUM_DEVICES", "3"}, {"MEMORY_DEVICE_0_SIZE", "17179869184"}, - {"MEMORY_DEVICE_0_LOCATOR", "Same locator"}, {"MEMORY_DEVICE_1_LOCATOR", "Same locator"}, - {"MEMORY_DEVICE_0_TYPE", "LPDDR5"}, {"MEMORY_DEVICE_0_SPEED_MTS", "8533"}, - {"MEMORY_DEVICE_0_CONFIGURED_SPEED_MTS", "0"}, {"MEMORY_DEVICE_0_PART_NUMBER", "Unknown"}, - {"MEMORY_DEVICE_1_SIZE", "Unknown"}, {"MEMORY_DEVICE_2_PRESENT", "0"} - }; - auto details = parseMemoryDevices(records); - check(details.status.state == ReadState::Available && details.devices.size() == 3, - "Keep populated, unknown and explicitly empty firmware records separate"); - check(details.devices[0].values.value("PRESENT").state == ReadState::Unavailable - && details.devices[0].values.value("SIZE").value == "17179869184", - "Missing presence flag does not discard known capacity"); - check(details.devices[0].values.value("LOCATOR").value == details.devices[1].values.value("LOCATOR").value, - "Duplicate locators do not merge records"); - check(details.devices[1].values.value("SIZE").state == ReadState::Unavailable - && details.devices[2].values.value("SIZE").state == ReadState::NotApplicable, - "Unknown capacity differs from explicitly empty device"); - check(details.devices[0].values.value("SPEED_MTS").value == "8533" - && details.devices[0].values.value("CONFIGURED_SPEED_MTS").state == ReadState::Unavailable - && details.devices[0].values.value("PART_NUMBER").state == ReadState::Unavailable, - "MT/s remains numeric; zero rate and firmware placeholders remain unavailable"); - records["MEMORY_DEVICE_0_NON_VOLATILE_SIZE"] = "1073741824"; - records["MEMORY_DEVICE_0_VOLATILE_SIZE"] = "16106127360"; - records["MEMORY_DEVICE_1_SPEED_MTS"] = "bad"; - records["MEMORY_DEVICE_2_SIZE"] = "1024"; - details = parseMemoryDevices(records); - check(details.devices[0].values.value("NON_VOLATILE_SIZE").value == "1073741824" - && details.devices[0].values.value("VOLATILE_SIZE").value == "16106127360", - "Volatile and persistent capacities remain separate"); - check(details.devices[1].values.value("SPEED_MTS").error == EINVAL - && details.devices[2].values.value("SIZE").error == EINVAL - && details.devices[2].values.value("PRESENT").error == EINVAL, - "Malformed numeric values and contradictory empty records are explicit errors"); - records["MEMORY_DEVICE_0_MANUFACTURER"] = QString(257, 'x'); - check(parseMemoryDevices(records).status.error == EOVERFLOW - && parseMemoryDevices(records).devices.isEmpty(), "Oversized metadata publishes no partial record list"); - records.remove("MEMORY_DEVICE_0_MANUFACTURER"); - records["MEMORY_ARRAY_NUM_DEVICES"] = "257"; - check(parseMemoryDevices(records).status.error == EOVERFLOW, "Detailed record expansion is bounded"); - check(parseMemoryDevices({}).status.state == ReadState::Unavailable, "Missing cache is not a permission failure"); - records = {{"MEMORY_ARRAY_NUM_DEVICES", "0"}}; - check(parseMemoryDevices(records).status.state == ReadState::Available - && parseMemoryDevices(records).devices.isEmpty(), "Explicit zero record count is distinct from missing cache"); - check(parseSecureBoot(QByteArray(1, char(1))).value == "enabled", "SecureBoot byte enables firmware state"); - check(parseSecureBoot(QByteArray(1, char(0))).value == "disabled", "SecureBoot zero disables firmware state"); - check(parseSecureBoot({}).state == ReadState::Error - && parseSecureBoot(QByteArray(1, char(2))).state == ReadState::Error - && parseSecureBoot(QByteArray(5, char(0))).state == ReadState::Error, - "Malformed SecureBoot payload is not treated as disabled"); - const auto signatureList = [](const QByteArray &guid, const QByteArray &signature) { - QByteArray list = guid; - const auto append = [&list](quint32 value) { - char bytes[4]; - qToLittleEndian(value, reinterpret_cast(bytes)); - list.append(bytes, 4); - }; - append(28 + 16 + signature.size()); append(0); append(16 + signature.size()); - list.append(QByteArray(16, char(0))); // Signature-owner GUID. - list.append(signature); - return list; - }; - const QByteArray x509Guid = QByteArray::fromHex("a159c0a5e494a74a87b5ab155c2bf072"); - const QByteArray der = QByteArray::fromBase64( - "MIIBGzCBwaADAgECAgEBMAoGCCqGSM49BAMCMBcxFTATBgNVBAMMDFFMRE0gZml4dHVyZTAeFw0yMDAxMDEwMDAwMDBaFw0zMDAxMDEwMDAwMDBaMBcxFTATBgNVBAMMDFFMRE0gZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABK9KZsQWrAixDhWcxerBMOstANWdiM9RdfPf+25bsZX6Hgz+pMLR2hpgxDWrLCpmGDVZNYAAya1/3hrgz6/Tm9gwCgYIKoZIzj0EAwIDSQAwRgIhAPjy8A6F++gXv0CNHw3xd5VwAHI0/Tpt5Bifyhf1KK5AAiEA839IXhcKhZWx329x4fNC1e8wt25T7EBf5Hjzq6wDviw="); - const QByteArray list = signatureList(x509Guid, der); - const MokCertificates mok = parseMokCertificates(list); - check(mok.status.state == ReadState::Available && mok.certificates.size() == 1 - && mok.certificates.first().subject.contains("QLDM fixture") - && mok.certificates.first().issuer.contains("QLDM fixture") - && mok.certificates.first().expires == "2030-01-01T00:00:00Z", - "MOK X.509 owner, issuer and expiration remain paired"); - check(parseMokCertificates(list + list).certificates.size() == 2, "Multiple MOK certificate lists are retained"); - check(parseMokCertificates(list.left(list.size() - 1)).status.state == ReadState::Error, - "Truncated MOK lists cannot expose apparently complete certificates"); - check(parseMokCertificates(signatureList(x509Guid, "invalid DER")).status.state == ReadState::Error, - "Invalid X.509 data does not become an empty successful list"); - const MokCertificates hashes = parseMokCertificates(signatureList(QByteArray(16, char(0)), QByteArray(32, char(1)))); - check(hashes.status.state == ReadState::Available && hashes.certificates.isEmpty() && hashes.otherSignatures == 1, - "Non-certificate signatures are counted without inventing certificate dates"); - check(parseMokCertificates(QByteArray(1024 * 1024 + 1, char(0))).status.error == EOVERFLOW, - "MOK input is bounded"); - return failures ? 1 : 0; -} From 177318ae7afd554cd6dc8ef28ffc10aa5333d92f Mon Sep 17 00:00:00 2001 From: YuraLitvinov Date: Thu, 15 Oct 2026 03:31:27 +0200 Subject: [PATCH 2/2] Add an Ubuntu 24.04 AppImage build and workflow packaging/appimage/build.sh builds the application and packages it with linuxdeploy and its GTK plugin, bundling the dlopen()ed libkmod and libdisplay-info. The workflow runs it on push, pull request and manual dispatch and uploads the AppImage. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/appimage.yml | 23 ++++++++++++++++ .gitignore | 2 ++ Makefile | 5 ++-- packaging/appimage/build.sh | 48 ++++++++++++++++++++++++++++++++++ packaging/build-rust.md | 19 +++++++++----- 5 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/appimage.yml create mode 100755 packaging/appimage/build.sh diff --git a/.github/workflows/appimage.yml b/.github/workflows/appimage.yml new file mode 100644 index 0000000..9cd56ca --- /dev/null +++ b/.github/workflows/appimage.yml @@ -0,0 +1,23 @@ +name: AppImage (Ubuntu 24.04) + +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: read + +jobs: + appimage: + name: AppImage (Ubuntu 24.04, glibc 2.39, x86_64) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Build and package + run: SRC="$GITHUB_WORKSPACE" OUT="$GITHUB_WORKSPACE/dist" bash packaging/appimage/build.sh + + - uses: actions/upload-artifact@v4 + with: + name: QLinuxDeviceManager-AppImage-x86_64-${{ github.sha }} + path: dist/*.AppImage + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index fde01ce..ba1a2e0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ BUILD/ .directory executable_graph.k.json /target +dist/ +/target-appimage diff --git a/Makefile b/Makefile index 6bc022f..ad18ed5 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ DATADIR ?= $(PREFIX)/share LOCALEDIR ?= $(DATADIR)/locale APP_ID := io.github.birdie-github.QLinuxDeviceManager EXE := qlinuxdevicemanager +TARGET_DIR ?= target/release ICON_SIZES := 16 32 48 64 128 256 512 LINGUAS := $(basename $(notdir $(wildcard po/*.po))) @@ -18,8 +19,8 @@ check: # The camera helper must sit next to the GUI binary: the GUI looks for it there. install: all - install -Dm755 target/release/qldm-gtk $(DESTDIR)$(BINDIR)/$(EXE) - install -Dm755 target/release/qldm-camera-helper $(DESTDIR)$(BINDIR)/qldm-camera-helper + install -Dm755 $(TARGET_DIR)/qldm-gtk $(DESTDIR)$(BINDIR)/$(EXE) + install -Dm755 $(TARGET_DIR)/qldm-camera-helper $(DESTDIR)$(BINDIR)/qldm-camera-helper install -Dm644 packaging/$(APP_ID).desktop $(DESTDIR)$(DATADIR)/applications/$(APP_ID).desktop for s in $(ICON_SIZES); do \ install -Dm644 resources/external/icons/$${s}x$${s}/QLinuxDeviceManager.png \ diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh new file mode 100755 index 0000000..95145f3 --- /dev/null +++ b/packaging/appimage/build.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Runs on Ubuntu 24.04 (GTK 4.14, glibc 2.39), as root in a container or with sudo on a runner, +# and packages the application as an AppImage in $OUT. +# env: SRC (checkout, default /src) OUT (default $SRC/dist) +set -euo pipefail +SRC=${SRC:-/src}; OUT=${OUT:-$SRC/dist} +export PATH="$HOME/.cargo/bin:$PATH" +mkdir -p "$OUT" + +SUDO=""; [ "$(id -u)" = 0 ] || SUDO=sudo +echo "::group::system packages" +$SUDO apt-get update -qq +DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y --no-install-recommends \ + build-essential pkg-config python3 git ca-certificates curl wget file patchelf make \ + gettext desktop-file-utils libgtk-4-dev libudev-dev libkmod-dev libdisplay-info-dev libv4l-dev \ + libwayland-dev libxcb-randr0-dev clang libclang-dev +echo "::endgroup::" + +echo "::group::Rust" +command -v cargo >/dev/null || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal +echo "::endgroup::" + +echo "::group::application and AppDir" +cd "$SRC" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$SRC/target-appimage}" +APPDIR=$OUT/AppDir; rm -rf "$APPDIR" +make install PREFIX=/usr DESTDIR="$APPDIR" TARGET_DIR="$CARGO_TARGET_DIR/release" +echo "::endgroup::" + +echo "::group::AppImage" +tools=$(mktemp -d) +wget -q -O "$tools/linuxdeploy" https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage +wget -q -O "$tools/linuxdeploy-plugin-gtk.sh" https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh +chmod +x "$tools"/* +export PATH="$tools:$PATH" APPIMAGE_EXTRACT_AND_RUN=1 DEPLOY_GTK_VERSION=4 NO_STRIP=1 +VERSION=$(python3 -c 'import json;print(json.load(open("project.json"))["release"]["version"])') +export LDAI_OUTPUT="$OUT/QLinuxDeviceManager-$VERSION-Linux-x86_64.AppImage" +ID=io.github.birdie-github.QLinuxDeviceManager +# libkmod and libdisplay-info are dlopen()ed, which ELF scanning cannot see. +"$tools/linuxdeploy" --appdir "$APPDIR" \ + --desktop-file "$APPDIR/usr/share/applications/$ID.desktop" \ + --icon-file "$APPDIR/usr/share/icons/hicolor/512x512/apps/$ID.png" \ + --library /usr/lib/x86_64-linux-gnu/libkmod.so.2 \ + --library "$(ls /usr/lib/x86_64-linux-gnu/libdisplay-info.so.[0-9]* | head -1)" \ + --plugin gtk --output appimage +test -f "$LDAI_OUTPUT" +echo "::endgroup::" +echo "built $LDAI_OUTPUT" diff --git a/packaging/build-rust.md b/packaging/build-rust.md index e7ce3d0..993592e 100644 --- a/packaging/build-rust.md +++ b/packaging/build-rust.md @@ -73,10 +73,17 @@ Inside the sandbox `/sys` and `/proc` are those of the host kernel but processes network namespaces are the sandbox's, and `libkmod`/`libdisplay-info` must be bundled. Treat this as a starting point: it has not been built. -## AppImage (replaces the Qt recipe) +## AppImage -Stage with `make install PREFIX=/usr DESTDIR=$PWD/AppDir`, then run `linuxdeploy` with -`--appdir AppDir --desktop-file … --icon-file … --plugin gtk --output appimage` -(the `linuxdeploy-plugin-gtk` bundles GTK 4 and its schemas). Bundle `libkmod.so.2` and -`libdisplay-info.so.*` with `--library` since they are `dlopen`ed and invisible to ELF scanning. -This has not been run; build on the oldest distribution you intend to support. +`packaging/appimage/build.sh` builds the AppImage on Ubuntu 24.04 (GTK 4.14, glibc 2.39, so it +runs on distributions with glibc 2.39 or newer). `.github/workflows/appimage.yml` runs it on every +push; locally: + +```sh +podman run --rm -v "$PWD":/src:Z -e SRC=/src ubuntu:24.04 bash /src/packaging/appimage/build.sh +``` + +The result is `dist/QLinuxDeviceManager--Linux-x86_64.AppImage`. It uses `linuxdeploy` +with the GTK plugin and bundles `libkmod` and `libdisplay-info` explicitly, since they are +`dlopen`ed. Translations are not bundled yet (the locale directory is baked in at build time; +set `QLDM_LOCALE_DIR` to override).