diff --git a/.github/workflows/package-linux.yml b/.github/workflows/package-linux.yml index 50989df..3dc49b4 100644 --- a/.github/workflows/package-linux.yml +++ b/.github/workflows/package-linux.yml @@ -49,6 +49,7 @@ jobs: cpio \ dbus-daemon \ desktop-file-utils \ + ffmpeg-free \ findutils \ gcc-c++ \ git \ @@ -59,6 +60,8 @@ jobs: protobuf-devel \ qt6-linguist \ qt6-qtbase-devel \ + qt6-qtdeclarative \ + qt6-qtdeclarative-devel \ qt6-rpm-macros \ rpm-build \ rpmlint \ @@ -155,11 +158,13 @@ jobs: rpm -V tryx-panorama-manager test "$(tryx-panorama-manager --version)" = \ "tryx-panorama-manager $(tr -d '\r\n' < VERSION)" - ldd /usr/bin/tryx-panorama-manager | - tee "$RUNNER_TEMP/tryx-panorama-manager.ldd" - if grep -q 'not found' "$RUNNER_TEMP/tryx-panorama-manager.ldd"; then - exit 1 - fi + for binary in tryx-panorama-manager; do + ldd "/usr/bin/$binary" | tee "$RUNNER_TEMP/$binary.ldd" + if grep -q 'not found' "$RUNNER_TEMP/$binary.ldd"; then + exit 1 + fi + done + QT_QPA_PLATFORM=offscreen tryx-panorama-manager --smoke-test ffmpeg -hide_banner -encoders \ > "$RUNNER_TEMP/ffmpeg-encoders.txt" 2>&1 grep -qw libx264 "$RUNNER_TEMP/ffmpeg-encoders.txt" @@ -221,7 +226,21 @@ jobs: protobuf-compiler \ qmake6 \ qt6-base-dev \ + qt6-declarative-dev \ + qt6-declarative-dev-tools \ qt6-l10n-tools \ + qml6-module-qt-labs-folderlistmodel \ + qml6-module-qtqml \ + qml6-module-qtqml-models \ + qml6-module-qtqml-workerscript \ + qml6-module-qtquick \ + qml6-module-qtquick-controls \ + qml6-module-qtquick-dialogs \ + qml6-module-qtquick-layouts \ + qml6-module-qtquick-shapes \ + qml6-module-qtquick-templates \ + qml6-module-qtquick-window \ + qml6-module-qttest \ systemd \ systemd-dev \ udev \ @@ -299,11 +318,13 @@ jobs: dpkg --verify tryx-panorama-manager test "$(tryx-panorama-manager --version)" = \ "tryx-panorama-manager $(tr -d '\r\n' < VERSION)" - ldd /usr/bin/tryx-panorama-manager | - tee "$RUNNER_TEMP/tryx-panorama-manager.ldd" - if grep -q 'not found' "$RUNNER_TEMP/tryx-panorama-manager.ldd"; then - exit 1 - fi + for binary in tryx-panorama-manager; do + ldd "/usr/bin/$binary" | tee "$RUNNER_TEMP/$binary.ldd" + if grep -q 'not found' "$RUNNER_TEMP/$binary.ldd"; then + exit 1 + fi + done + QT_QPA_PLATFORM=offscreen tryx-panorama-manager --smoke-test ffmpeg -hide_banner -encoders \ > "$RUNNER_TEMP/ffmpeg-encoders.txt" 2>&1 grep -qw libx264 "$RUNNER_TEMP/ffmpeg-encoders.txt" @@ -352,6 +373,7 @@ jobs: namcap \ protobuf \ qt6-base \ + qt6-declarative \ qt6-tools \ shadow \ systemd @@ -424,11 +446,13 @@ jobs: pacman -Qkk tryx-panorama-manager test "$(tryx-panorama-manager --version)" = \ "tryx-panorama-manager $(tr -d '\r\n' < VERSION)" - ldd /usr/bin/tryx-panorama-manager | - tee "$RUNNER_TEMP/tryx-panorama-manager.ldd" - if grep -q 'not found' "$RUNNER_TEMP/tryx-panorama-manager.ldd"; then - exit 1 - fi + for binary in tryx-panorama-manager; do + ldd "/usr/bin/$binary" | tee "$RUNNER_TEMP/$binary.ldd" + if grep -q 'not found' "$RUNNER_TEMP/$binary.ldd"; then + exit 1 + fi + done + QT_QPA_PLATFORM=offscreen tryx-panorama-manager --smoke-test ffmpeg -hide_banner -encoders \ > "$RUNNER_TEMP/ffmpeg-encoders.txt" 2>&1 grep -qw libx264 "$RUNNER_TEMP/ffmpeg-encoders.txt" @@ -452,3 +476,143 @@ jobs: if-no-files-found: error overwrite: true retention-days: 7 + + rpm-runtime-smoke: + name: Clean RPM runtime / Fedora ${{ matrix.fedora }} + needs: rpm + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - fedora: "43" + image: registry.fedoraproject.org/fedora@sha256:b1df059ba84f5ed169e245ee8dfd086925a8eba8e502994840cfe17ed75c6079 + - fedora: "44" + image: registry.fedoraproject.org/fedora@sha256:a2383763c3f25bb4fd4f806d5d2d004ca02589c331e5e94235da1289eb7de633 + container: + image: ${{ matrix.image }} + defaults: + run: + shell: bash + steps: + - name: Download RPM built in this workflow + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: tryx-rpm-f${{ matrix.fedora }}-${{ github.run_id }} + path: dist + + - name: Install only declared runtime dependencies and smoke-test + run: | + set -euo pipefail + rpm_file=$(find dist -maxdepth 1 -type f -name '*.rpm' -print -quit) + test -n "$rpm_file" + dnf install -y \ + "https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${{ matrix.fedora }}.noarch.rpm" + dnf install -y --allowerasing "$rpm_file" + version=$(rpm -q --qf '%{VERSION}' tryx-panorama-manager) + test "$(tryx-panorama-manager --version)" = \ + "tryx-panorama-manager $version" + for binary in tryx-panorama-manager; do + ldd "/usr/bin/$binary" | tee "$RUNNER_TEMP/$binary.ldd" + if grep -q 'not found' "$RUNNER_TEMP/$binary.ldd"; then + exit 1 + fi + done + DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/tryx-no-session-bus \ + QT_QPA_PLATFORM=offscreen \ + tryx-panorama-manager --smoke-test + ffmpeg -hide_banner -encoders \ + > "$RUNNER_TEMP/ffmpeg-encoders.txt" 2>&1 + grep -qw libx264 "$RUNNER_TEMP/ffmpeg-encoders.txt" + + deb-runtime-smoke: + name: Clean DEB runtime / Ubuntu 24.04 + needs: deb + runs-on: ubuntu-24.04 + timeout-minutes: 15 + container: + image: ubuntu@sha256:52df9b1ee71626e0088f7d400d5c6b5f7bb916f8f0c82b474289a4ece6cf3faf + env: + DEBIAN_FRONTEND: noninteractive + defaults: + run: + shell: bash + steps: + - name: Download DEB built in this workflow + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: tryx-deb-ubuntu24.04-${{ github.run_id }} + path: dist + + - name: Install only declared runtime dependencies and smoke-test + run: | + set -euo pipefail + deb_file=$(find dist -maxdepth 1 -type f -name '*.deb' -print -quit) + test -n "$deb_file" + apt-get update + apt-get install -y "./$deb_file" + version=$(dpkg-query -W -f='${Version}' tryx-panorama-manager) + version=${version%%-*} + test "$(tryx-panorama-manager --version)" = \ + "tryx-panorama-manager $version" + for binary in tryx-panorama-manager; do + ldd "/usr/bin/$binary" | tee "$RUNNER_TEMP/$binary.ldd" + if grep -q 'not found' "$RUNNER_TEMP/$binary.ldd"; then + exit 1 + fi + done + DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/tryx-no-session-bus \ + QT_QPA_PLATFORM=offscreen \ + tryx-panorama-manager --smoke-test + ffmpeg -hide_banner -encoders \ + > "$RUNNER_TEMP/ffmpeg-encoders.txt" 2>&1 + grep -qw libx264 "$RUNNER_TEMP/ffmpeg-encoders.txt" + + arch-runtime-smoke: + name: Clean Arch runtime + needs: arch + runs-on: ubuntu-24.04 + timeout-minutes: 15 + container: + image: archlinux@sha256:bf0af8920a0e70715207d9c4f463ebac321db72748db2b8a391b639696965d87 + defaults: + run: + shell: bash + steps: + - name: Download Arch package built in this workflow + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: tryx-arch-${{ github.run_id }} + path: dist + + - name: Install only declared runtime dependencies and smoke-test + run: | + set -euo pipefail + package_file=$(find dist -maxdepth 1 -type f \ + -name '*.pkg.tar.zst' -print -quit) + test -n "$package_file" + pacman -Syu --noconfirm + mapfile -t package_dependencies < <( + bsdtar -xOf "$package_file" .PKGINFO | + sed -n 's/^depend = //p' + ) + test "${#package_dependencies[@]}" -gt 0 + pacman -S --noconfirm --needed "${package_dependencies[@]}" + pacman -U --noconfirm "$package_file" + version=$(pacman -Q tryx-panorama-manager | awk '{print $2}') + version=${version%-*} + test "$(tryx-panorama-manager --version)" = \ + "tryx-panorama-manager $version" + for binary in tryx-panorama-manager; do + ldd "/usr/bin/$binary" | tee "$RUNNER_TEMP/$binary.ldd" + if grep -q 'not found' "$RUNNER_TEMP/$binary.ldd"; then + exit 1 + fi + done + DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/tryx-no-session-bus \ + QT_QPA_PLATFORM=offscreen \ + tryx-panorama-manager --smoke-test + ffmpeg -hide_banner -encoders \ + > "$RUNNER_TEMP/ffmpeg-encoders.txt" 2>&1 + grep -qw libx264 "$RUNNER_TEMP/ffmpeg-encoders.txt" diff --git a/.gitignore b/.gitignore index ab8615b..c6f6fd2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ build.tar.xz *.so *.d Makefile +Makefile.quick +Makefile.runtime +tests/quick/target_wrapper.sh .qmake.stash moc_*.cpp moc_predefs.h diff --git a/README.md b/README.md index 8fa0728..493be8a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # TRYX Panorama Linux GUI -Qt6 GUI application for managing TRYX Panorama AIO cooler displays on Linux. +Linux-only Qt 6 Quick application for managing TRYX Panorama AIO cooler +displays. + +The project ships one desktop GUI backed by a separate headless runtime. The +GUI owns presentation and user interaction; the runtime owns device discovery, +USB and serial/ADB communication, media operations, metrics delivery, and +firmware work. They communicate over the user D-Bus session. + +Project home: [github.com/DXVSI/Tryx-Linux-GUI](https://github.com/DXVSI/Tryx-Linux-GUI) ## Support the Project @@ -60,35 +68,93 @@ https://github.com/user-attachments/assets/f9baac04-fe28-4aeb-a8ea-eb2af37ff6cb - Analysis of KANALI resources to identify the device-side preset catalog without redistributing its extracted media - Full protocol analysis to discover device commands for system metrics display - Implemented working real-time CPU/GPU/Disk temperature monitoring on the cooler screen -- Built complete Qt6 GUI from scratch (Homepage, Panorama, Settings pages) +- Built a complete Qt 6 Quick GUI from scratch (Dashboard, Display, and Settings pages) - Auto-detection of CPU/GPU hardware names for badge display - Auto-conversion of non-MP4 media formats (WebM, MKV, AVI, GIF) before upload to device - Fixed serial communication issues (timeouts, wrong command formats, broken ADB quoting) -- Restructured into a single qmake project +- Separated hardware ownership into a headless runtime behind the desktop interface + +## Architecture + +There is one supported desktop frontend: `tryx-panorama-manager`, implemented +with Qt Quick. It talks to `tryx-panorama-runtime` through the Manager1 and +Manager2 D-Bus interfaces. Closing or restarting the GUI does not transfer +hardware ownership away from the runtime. + +Development builds are written to: + +- `build/quick/tryx-panorama-manager` - desktop GUI +- `build/runtime/tryx-panorama-runtime` - headless runtime + +A system installation uses: + +- `/usr/bin/tryx-panorama-manager` - public desktop launcher +- `/usr/lib/tryx-panorama-manager/tryx-panorama-runtime` - private runtime + started by the user service or the GUI bootstrap + +The Linux tray integration exports a StatusNotifierItem and DBusMenu over +D-Bus and sends notifications through `org.freedesktop.Notifications`. When a +StatusNotifier watcher and host are available, closing the window hides the +GUI to the native desktop tray. Without a watcher, closing the window exits +only the GUI; the separate runtime remains available to the user service. + +## What's new in 2.1.0 + +- The desktop application is now one Qt Quick GUI with no Qt Widgets runtime + dependency. Hardware access remains in the separately packaged private + runtime. +- The Linux tray uses StatusNotifierItem and DBusMenu, while desktop + notifications use `org.freedesktop.Notifications`. Its explicit Quit action + closes the GUI reliably while leaving the separate runtime active. +- Legacy serial/ADB devices retain display, media, metrics, keepalive, and + device-control support through the same Quick interface. +- PASE user media can be edited with Fit, Fill, Crop, Stretch, zoom, pan, and + rotation, exported as its exact raw H264 device copy, saved as a new item, + replaced through a crash-safe verified workflow, or deleted when eligible. +- Quick Settings provides local firmware package selection and validation. + The runtime obtains an exclusive device-transport gate before handing work + to the existing updater backend and writes an owner-only recovery interlock + before dispatch. A daemon restart cannot silently reconnect after an + interrupted or completed flash. The user must inspect the display and + explicitly acknowledge recovery before the normal device session resumes. + The gate, journal, and validation paths are covered by offline tests; this + release preparation did not physically flash a device and does not claim + that hardware operation as verified. +- The QML application and package checks remain compatible with Qt 6.4 for + Ubuntu 24.04 and Linux Mint 22. +- The protocol implementation uses project-owned clean-room schemas. Release + packages contain no extracted KANALI schemas, vendor firmware, or bundled + media. ## Features - Upload images, videos, GIFs (auto-converts non-MP4 formats) +- Modern desktop interface with a preview-first PASE media editor +- Explicit Fit, Fill, Crop, Stretch, Zoom, pan, rotation, and Fit background controls before upload +- Exact transformed preview rendered through the same canonical FFmpeg filter used for the final 2240 × 1080 media +- Immutable private upload snapshot with atomic client-to-runtime ownership transfer before D-Bus acceptance - Origin-aware PASE media catalog that labels device presets separately from user uploads +- Export of writable PASE user media as an honest raw H264 device copy +- Edit of an existing PASE user-media copy with Save as new or crash-safe Replace - Real-time system metrics on display (temperature, usage, frequency, power and date/time) - Hardware name badges (auto-detected from system) - Brightness control (0-100) - Display settings: position, alignment, color, filter -- Keepalive daemon for persistent display +- Runtime-owned keepalive for persistent display - Auto-detects legacy devices through `/dev/ttyACM*` and PASE firmware through direct libusb discovery -- System tray integration (KDE Plasma native) +- Native Linux StatusNotifierItem tray integration with DBusMenu and desktop notifications when a watcher is available - Settings persistence between sessions - Async device communication (non-blocking GUI) -- Native firmware update flow for locally selected Panorama SE OTA and Rockchip packages +- Quick Settings firmware panel for locally selected packages, with validation and hardware work owned by the headless runtime - Device information and media list over the new KANALI USB printer-class protocol - Direct asynchronous libusb transport with one request-scoped IN armed before OUT and bounded response reads after known OUT completion - Exact operation IDs, progress, cancellation, verified completion, and manual retry through D-Bus Manager2 -- Backward-compatible media catalog through D-Bus Manager1 and an enhanced origin-aware catalog through Manager2 API version 6 +- Backward-compatible media catalog through D-Bus Manager1 and an enhanced origin-aware catalog through Manager2 API version 8 - Content-aware Save that reuses a verified PASE copy instead of uploading the same local media again - Verified deletion of one eligible user media file at a time, with crash-safe reconciliation and no automatic FileRemove replay - One shared Panorama operation banner with progress, cancellation, and one fail-closed manual retry candidate -- Daemon-owned PASE metric configuration and one-second sampling that continue after the GUI closes -- Runtime API compatibility check that prevents a new GUI from silently using an outdated background daemon +- Runtime-owned PASE metric configuration and one-second sampling that continue after the GUI closes +- Runtime API compatibility check that prevents the GUI from silently using an outdated background runtime ## Media-free distribution @@ -96,15 +162,36 @@ The application does not bundle, install, or search for the extracted KANALI vid Manual user upload remains available. Thumbnails are generated from the user-selected source and shown only after the runtime has confirmed the uploaded origin in its device-scoped XDG media catalog. Legacy files left by an older installation under `/usr/share/tryx-panorama-manager/media` are ignored by the current runtime and are not deleted automatically. +Writable user media already stored on a PASE device has an action menu in the +Media Library. `Export copy…` saves the exact prepared device stream as +`.h264`; it cannot reconstruct the original MP4, GIF, image, audio, or file +name. `Edit` downloads the same private copy into the background runtime, +opens it in the Fit, Fill, Crop and Stretch editor, and offers two explicit +results: + +- `Save as new` uploads a verified new media file and always keeps the + original. +- `Replace original` uploads and verifies the new file first, updates only + supported active display references, verifies them again, and only then + removes the original once. Immediately before FileRemove, the runtime + rechecks both the original and the verified replacement in the same fresh + FileList by exact name, size, user source, and writable flag. + +Factory presets, read-only entries, and unsupported device media do not expose +Export, Edit, or Delete. An interrupted Replace is reconciled from its +owner-only journal without automatically repeating an uncertain Apply or +Delete command. Reconciliation becomes terminal only when a fresh FileList +also proves that the exact verified replacement copy still exists. + ## Native Linux packages -Version 2.0 uses one source version to build separate native packages for -Fedora, Ubuntu/Linux Mint, and Arch Linux. A Fedora binary is not reused on -other distributions. +TRYX Panorama Manager supports Linux only. Native packaging targets Fedora 43 +and 44, Ubuntu 24.04, Linux Mint 22, and current Arch Linux. A binary package +built for one distribution is not reused on another distribution. Release assets use these formats: -- RPM `x86_64` for supported Fedora releases +- RPM `x86_64` for Fedora 43 and Fedora 44 - DEB `amd64` for Ubuntu 24.04 and Linux Mint 22 - `.pkg.tar.zst` `x86_64` for current Arch Linux - `SHA256SUMS` for artifact verification @@ -118,13 +205,13 @@ Install a downloaded package with the package manager for your distribution: ```fish # Fedora. Enable RPM Fusion Free first because media conversion requires the # full ffmpeg package with the libx264 encoder. -sudo dnf install --allowerasing ./tryx-panorama-manager-2.0.1-1.fc44.x86_64.rpm +sudo dnf install --allowerasing ./tryx-panorama-manager-2.1.0-1.fc44.x86_64.rpm # Ubuntu 24.04 or Linux Mint 22 -sudo apt install ./tryx-panorama-manager_2.0.1-1_amd64.deb +sudo apt install ./tryx-panorama-manager_2.1.0-1_amd64.deb # Arch Linux -sudo pacman -U ./tryx-panorama-manager-2.0.1-1-x86_64.pkg.tar.zst +sudo pacman -U ./tryx-panorama-manager-2.1.0-1-x86_64.pkg.tar.zst ``` These commands use the distribution package manager to resolve and download @@ -145,10 +232,13 @@ executable without the `libx264` encoder required by PASE media preparation. Use `--allowerasing` when installing the RPM so DNF can replace an existing `ffmpeg-free` package with RPM Fusion's full `ffmpeg` build. -Native packages install the binary, desktop entry, icon, systemd user unit, -and two PASE udev rules. They do not enable autostart or restart an existing -daemon during an upgrade. Reconnect the PASE USB cable after installation, -launch the application once, and enable autostart in Settings only if wanted. +Native packages install the Qt Quick GUI at +`/usr/bin/tryx-panorama-manager`, the private background runtime at +`/usr/lib/tryx-panorama-manager/tryx-panorama-runtime`, the desktop entry, +icon, systemd user unit, and two PASE udev rules. Packages do not enable +autostart or restart an existing runtime during an upgrade. Reconnect the PASE +USB cable after installation, launch the application once, and enable +autostart in Settings only if wanted. The committed Arch PKGBUILD intentionally accepts only a local release source archive with an explicit checksum. From a clean release checkout, build it @@ -169,7 +259,8 @@ popd ## Requirements **Build:** -- Qt6 (Core, D-Bus, Gui, Network, Widgets) +- Linux +- Qt 6.4 or newer (Concurrent, Core, D-Bus, Gui, QML, Quick, Quick Controls 2) - C++17 compiler - qmake6 - Qt6 translation tools with `lrelease` @@ -181,19 +272,19 @@ popd Fedora build dependencies: ```fish -sudo dnf install -y gcc-c++ git make dbus-daemon pkgconf-pkg-config qt6-qtbase-devel qt6-linguist protobuf-compiler protobuf-devel systemd-devel libusb1-devel +sudo dnf install -y gcc-c++ git make dbus-daemon ffmpeg-free pkgconf-pkg-config qt6-qtbase-devel qt6-qtdeclarative-devel qt6-linguist protobuf-compiler protobuf-devel systemd-devel libusb1-devel ``` Ubuntu 24.04 and Linux Mint 22 build dependencies: ```fish -sudo apt install build-essential dbus-user-session git libprotobuf-dev libsystemd-dev libudev-dev libusb-1.0-0-dev pkg-config protobuf-compiler qmake6 qt6-base-dev qt6-l10n-tools +sudo apt install build-essential dbus-user-session ffmpeg git libprotobuf-dev libsystemd-dev libudev-dev libusb-1.0-0-dev pkg-config protobuf-compiler qmake6 qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools qt6-l10n-tools qml6-module-qt-labs-folderlistmodel qml6-module-qtqml qml6-module-qtqml-models qml6-module-qtqml-workerscript qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-dialogs qml6-module-qtquick-layouts qml6-module-qtquick-shapes qml6-module-qtquick-templates qml6-module-qtquick-window qml6-module-qttest systemd-dev ``` Arch Linux build dependencies: ```fish -sudo pacman -S --needed base-devel dbus git libusb protobuf qt6-base qt6-tools systemd +sudo pacman -S --needed base-devel dbus git libusb protobuf qt6-base qt6-declarative qt6-tools systemd ``` The qmake guard requires the protobuf compiler and C++ runtime to be from the @@ -222,20 +313,39 @@ sudo dnf install -y android-tools unzip e2fsprogs ffmpeg mesa-demos ## Firmware Updates -The firmware updater supports two local package formats for Panorama SE: +Firmware updates are initiated from the firmware panel in Quick Settings, but +package validation and hardware access belong to the headless runtime. The +panel accepts a locally selected ZIP; it does not download firmware +automatically. Availability depends on the package type, connected device +state, and required external tools. Validation is not a claim that an +arbitrary package is safe for a different model. + +The local validator recognizes two Panorama SE package formats: -- Legacy Android OTA `update.zip` for `cm01_se` devices. The app validates `META-INF/com/android/metadata`, copies the package to `/sdcard/update.zip` over ADB, verifies the copied size, and reboots the cooler into recovery. -- New KANALI Rockchip loader ZIP bundles for `PASE`. The app validates the required Rockchip files, checks `parameter.txt` for `RK3568`, and inspects `rootfs:/usr/bin/panorama` for the product marker. Flashing uses an external Rockchip `upgrade_tool` executable when it is available. If an ADB device is present, the app reboots it into Loader first; if RockUSB Loader or Maskrom is already present, the app can continue directly without ADB. +- Legacy Android OTA `update.zip` for `cm01_se` devices. The runtime validates `META-INF/com/android/metadata`, copies an approved package to `/sdcard/update.zip` over ADB, verifies the copied size, and requests recovery reboot. +- New KANALI Rockchip loader ZIP bundles for `PASE`. The runtime validates the required Rockchip files, checks `parameter.txt` for `RK3568`, and inspects `rootfs:/usr/bin/panorama` for the product marker. It can invoke an external Rockchip `upgrade_tool` only when the backend and device-state checks pass. If an ADB device is present, it may request reboot into Loader first; if RockUSB Loader or Maskrom is already present, the external backend can continue without ADB. The `upgrade_tool` executable is not bundled in this open source repository because its redistribution rights are not clear. The app looks for it in `TRYX_UPGRADE_TOOL`, `PATH`, next to the app binary, `tools/upgrade_tool`, and `~/.local/bin/upgrade_tool`. Rockchip RK3568 loader access may require a local udev rule for USB VID/PID `2207:350a` so the flashing backend can reset or inspect the device without root. +Before dispatching an approved package, the runtime atomically writes an +owner-only recovery journal and keeps the device transport under an exclusive +firmware gate. The journal survives daemon crashes, forced termination, and +successful updater completion. While it exists, startup is fail-closed: the +runtime does not automatically open a normal display session. After the +updater finishes, wait for the cooler to boot, inspect the physical display, +then use **I inspected the display; resume connection** in Quick Settings. +That explicit action removes the exact journal entry, releases the gate, and +starts a fresh connection. It is not an automated firmware-version or boot +verification. A new locally approved recovery flash remains possible while +the device is still in Rockchip Loader mode. + After updating to the new KANALI firmware, the cooler no longer exposes ADB by default. It appears as `391a:1021 RK PASE` with a bidirectional printer interface. The app generates C++ types from three minimal, project-owned schemas under `protocol/wire-v1`; recovered vendor descriptor sources are not a build or release dependency. The production path does not read or write `/dev/usb/lp*`: it claims the `07/01/02` interface through usbfs, temporarily detaches `usblp`, arms one bulk IN before each request, never re-arms that endpoint while the matching bulk OUT is still active, drains optional periodic responses to a complete frame boundary after OUT, and releases the interface on shutdown. All printer operations are serialized by one worker-owned session, while cancellable ffmpeg conversion runs outside the USB worker. Passive udev discovery recognizes the `391a:0006 rk3xxx` Rockchip gadget identity but never opens it. Discovery is based on physical USB device events and stable bus/port identity, so the app does not mistake its own `usblp` detach or attach for a physical reconnect. Printer Class `GET_PORT_STATUS` is deliberately not used because PASE does not provide a reliable readiness signal through that request. A physical remove/add creates a new connection generation, interrupts old I/O through its cancellation gate, and discards stale results. Recovery confirms protocol readiness through an exact DeviceInfo response, completes the remaining bootstrap once, sends one post-bootstrap Ping, restores the confirmed overlay at most once, and only then starts metrics. It never retries a complete bootstrap in the same physical generation or automatically replays user configuration, upload, delete, or apply mutations. -The readiness phase has a 20-second monotonic deadline. It retries only a DeviceInfo request whose USB OUT is confirmed to have transferred zero bytes, keeping the same claimed handle and using capped `500`, `1000`, then `2000` millisecond backoff. A partial or unknown OUT, cancellation, malformed response, or a complete OUT without the exact DeviceInfo response is terminal for that physical generation. System configuration and authentication queries are each sent at most once after readiness. Keepalive uses the observed untracked Ping frame and drains an optional asynchronous Pong. Metrics sampling and mutations start only after the post-bootstrap barrier. Manual upload uses the response-driven begin/data/end flow, converts media to the device's raw H264 format, gives data chunks a dedicated 15-second OUT deadline, and verifies the exact new name, prepared size, writable flag, and user source through a fresh media catalog before reporting success or applying it. Save first hashes the opened source file, looks up the source hash and versioned conversion profile in the device-scoped catalog, refreshes that catalog, and applies an exact verified match without conversion or retransmission. No completed IN transfer is re-armed while any OUT remains active, preventing queued response fragments or `EPROTO` completions from starving the writer. Periodic write-only commands perform a bounded post-OUT drain; no response is acceptable, but a partial or malformed frame closes the session fail-closed. A persistent bulk-IN failure latches the current USB endpoint generation as lost. Production does not call `libusb_reset_device`, retry the same generation, or replay its last mutation; recovery requires an observed physical remove/add cycle or a full PASE power cycle that creates a new generation. Conversion and preview subprocesses have bounded deadlines; a preview timeout falls back to an honest placeholder without discarding valid H264. The direct USB reader can recover a complete tracked protobuf when faulty PASE firmware drops only the `TRYX` frame header after an IN transport error; recovery still requires the exact transaction ID and expected response body. Manager2 API version 6 exposes stable UUIDs, structured operation states, origin-aware catalog entries, typed display mutations, confirmed display state, per-side overlay configuration, and explicit backlight power control. Manager1 retains its original catalog tuple for ABI compatibility. A verified prepared file and its staged JPEG preview are cached atomically after a failed transfer and can only be retried manually after prepared-file hash, device-generation, and media-catalog checks; the original source file is not required after conversion. If a data transfer ends partially or with an unknown outcome, its recovery requirement remains sticky across retries and daemon restarts. Upload, Retry, Apply, Delete, and metrics changes remain blocked until the runtime observes removal and reconnection of the current PASE endpoint, because closing libusb or issuing a generic USB reset does not prove that firmware discarded its hidden transfer session. A successful verification promotes the preview and content identity into the XDG media catalog. Apply is not atomic: uncertain writes are reported as partial or unknown, the session is closed, and no automatic rollback or replay is attempted. +The readiness phase has a 20-second monotonic deadline. It retries only a DeviceInfo request whose USB OUT is confirmed to have transferred zero bytes, keeping the same claimed handle and using capped `500`, `1000`, then `2000` millisecond backoff. A partial or unknown OUT, cancellation, malformed response, or a complete OUT without the exact DeviceInfo response is terminal for that physical generation. System configuration and authentication queries are each sent at most once after readiness. Keepalive uses the observed untracked Ping frame and drains an optional asynchronous Pong. Metrics sampling and mutations start only after the post-bootstrap barrier. Manual upload uses the response-driven begin/data/end flow, converts media to the device's raw H264 format, gives data chunks a dedicated 15-second OUT deadline, and verifies the exact new name, prepared size, writable flag, and user source through a fresh media catalog before reporting success or applying it. Save first hashes the opened source file, looks up the source hash and transform-aware versioned conversion profile in the device-scoped catalog, refreshes that catalog, and applies an exact verified match without conversion or retransmission. No completed IN transfer is re-armed while any OUT remains active, preventing queued response fragments or `EPROTO` completions from starving the writer. Periodic write-only commands perform a bounded post-OUT drain; no response is acceptable, but a partial or malformed frame closes the session fail-closed. A persistent bulk-IN failure latches the current USB endpoint generation as lost. Production does not call `libusb_reset_device`, retry the same generation, or replay its last mutation; recovery requires an observed physical remove/add cycle or a full PASE power cycle that creates a new generation. Conversion and preview subprocesses have bounded deadlines; a preview timeout falls back to an honest placeholder without discarding valid H264. The direct USB reader can recover a complete tracked protobuf when faulty PASE firmware drops only the `TRYX` frame header after an IN transport error; recovery still requires the exact transaction ID and expected response body. Manager2 API version 8 adds FilePull-backed trusted device-media artifacts with owner-bound leases and crash-safe Save as new or Replace operations while preserving the API 7 media-transform and upload semantics. It also exposes stable UUIDs, structured operation states, origin-aware catalog entries, typed display mutations, confirmed display state, per-side overlay configuration, and explicit backlight power control. Manager1 retains its original catalog tuple for ABI compatibility. A verified prepared file and its staged JPEG preview are cached atomically after a failed transfer and can only be retried manually after prepared-file hash, device-generation, and media-catalog checks; the original source file is not required after conversion. If a data transfer ends partially or with an unknown outcome, its recovery requirement remains sticky across retries and daemon restarts. Upload, Retry, Apply, Delete, and metrics changes remain blocked until the runtime observes removal and reconnection of the current PASE endpoint, because closing libusb or issuing a generic USB reset does not prove that firmware discarded its hidden transfer session. A successful verification promotes the preview and content identity into the XDG media catalog. Apply is not atomic: uncertain writes are reported as partial or unknown, the session is closed, and no automatic rollback or replay is attempted. PASE full-screen mode supports up to three exact protocol metrics selected from CPU temperature, frequency, usage and power; GPU temperature, frequency, usage and power; memory frequency and usage; and date/time. A separate Manager2 operation sends the overlay layout, then the background daemon sends live values through a headerless metric batch every second. The two-second background scheduler supports two measured arms through `pase_overlay_lease_mode` in the existing XDG `config.json`: `ping-and-overlay-lease` alternates Ping with a full overlay lease, while `ping-only` sends only Ping after the initial reconnect overlay restoration. The default preserves the current `ping-and-overlay-lease` behavior until the A/B monitor selects an arm. The lease never writes user configuration or media state. An explicit protocol error from either metric update or layout lease is fail-closed instead of being discarded. Metric sampling pauses during upload or Apply and coalesces to the latest sample, while a delayed tracked response can still receive one bounded liveness command without replaying the mutation. The confirmed layout is stored only for the same non-empty device serial and survives GUI or daemon restarts. Missing sensors remain unavailable instead of being reported as zero. The Memory Frequency protocol label is retained for compatibility, but the current Linux runtime reports it as unavailable because upstream Linux does not expose a portable unprivileged source for the live DRAM clock; static SMBIOS transfer rates are not mislabeled as MHz. @@ -248,12 +358,27 @@ Automatic firmware download is not enabled yet. KANALI uses SM2-encrypted reques ## Build ```fish -git clone --branch production https://github.com/DXVSI/tryx-panorama-se-360-linux-gui.git tryx-panorama-current; and cd tryx-panorama-current -qmake6 tryx-panorama.pro; and make -j(nproc) -./build/tryx-panorama-manager +git clone --branch production https://github.com/DXVSI/Tryx-Linux-GUI.git; and cd Tryx-Linux-GUI +qmake6 tryx-panorama-all.pro +make +dbus-run-session -- make package-check +``` + +For a development run, start the runtime in one terminal: + +```fish +./build/runtime/tryx-panorama-runtime +``` + +Then start the GUI from another terminal in the same user session: + +```fish +./build/quick/tryx-panorama-manager ``` -System installation includes the binary, user service, PASE usbfs rule, desktop entry, icon, and translations. It does not install a video library: +System installation includes the public GUI, private runtime, user service, +PASE usbfs rules, desktop entry, icon, and translations. It does not install a +video library: ```fish sudo make install; and sudo udevadm control --reload-rules; and sudo udevadm trigger --action=add --subsystem-match=usb --attr-match=idVendor=391a --attr-match=idProduct=1021; and sudo udevadm settle --timeout=10 @@ -262,7 +387,7 @@ systemctl --user daemon-reload; and systemctl --user start tryx-panorama.service The command above is the first-install path. When updating an existing manual source installation, first finish or cancel every active media operation, then -install the new files and restart the daemon explicitly: +install the new files and restart the runtime explicitly: ```fish sudo make install; and systemctl --user daemon-reload; and systemctl --user restart tryx-panorama.service; and systemctl --user is-active tryx-panorama.service @@ -275,10 +400,12 @@ The install target supplies a user preset that keeps autostart disabled by default. Enable it later from Settings or explicitly with `systemctl --user enable tryx-panorama.service`. -The version command is safe to use without a graphical or D-Bus session: +The GUI and runtime version commands are safe to use without a graphical or +D-Bus session: ```fish -./build/tryx-panorama-manager --version +./build/quick/tryx-panorama-manager --version +./build/runtime/tryx-panorama-runtime --version ``` Offline printer-protocol tests do not access physical USB hardware: @@ -291,19 +418,26 @@ cd tests; and qmake6 printerprotocol_tests.pro; and make -j(nproc); and ../build ``` src/ - core/ # Device protocol library - main.cpp # Entry point - mainwindow.* # Main window with navigation - panoramapage.* # Display + metrics configuration - homepage.* # System monitoring dashboard - settingspage.* # App settings - devicemanager.* # Async device communication + core/ # Legacy serial/ADB protocol and shared configuration + quick/ # The Qt Quick GUI, D-Bus client, tray, and controllers + runtime/ # Headless runtime entry point + devicemanager.* # Runtime-owned async device and operation coordination + firmwarebridge.* # Runtime-side firmware D-Bus boundary + firmwareupdater.* # Local firmware validation and external-tool execution + mediatransform.* # Canonical media transform validation and FFmpeg filter + runtimecontract.* # Shared Manager1/Manager2 D-Bus data contract + runtimebridge.* # Exported runtime D-Bus adaptors printerprotocol.* # PASE framing, direct libusb transport and udev discovery systemmonitor.* # System metrics reader - traymanager.* # System tray +qml/ + Main.qml # Single desktop shell + pages/ # Dashboard, Display, and Settings + components/ # Media editor, firmware panel, and shared controls +resources/ # GUI resource collection and application icon +translations/ # Qt Linguist translation sources include/panorama/ # Protocol headers protocol/wire-v1/ # Minimal project-owned protobuf wire schema -tests/ # Offline protocol, transport and discovery tests +tests/ # Offline runtime, protocol, transport, and Quick tests debian/ # Ubuntu 24.04 and Linux Mint 22 package metadata packaging/ arch/ # Arch Linux PKGBUILD @@ -311,6 +445,8 @@ packaging/ metainfo/ # AppStream metadata scripts/ # Release and package-content gates *.rules # PASE permissions and printer suppression +tryx-panorama-all.pro # Aggregate runtime + GUI build and package-check +tryx-panorama.pro # Headless runtime qmake project ``` ## Tested on diff --git a/VERSION b/VERSION index 38f77a6..7ec1d6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.1 +2.1.0 diff --git a/debian/changelog b/debian/changelog index 114a351..1590ffe 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,22 @@ +tryx-panorama-manager (2.1.0-1) noble; urgency=medium + + * Replace the Qt Widgets frontend with one Qt Quick desktop GUI and a + separately packaged private runtime. + * Add native Linux tray integration through StatusNotifierItem, DBusMenu, + and freedesktop Notifications. + * Make the tray Quit action terminate the GUI without stopping the separate + runtime. + * Preserve legacy serial/ADB control while adding the PASE media editor, + export, Save as new, Replace, and verified deletion workflows. + * Add local firmware validation and the Quick firmware panel with an + exclusive device-transport safety gate. Physical flashing is not claimed + as verified by this release preparation. + * Keep the QML interface compatible with Qt 6.4. + * Use project-owned clean-room protocol schemas and ship no bundled vendor + media. + + -- DXVSI Sun, 02 Aug 2026 01:48:44 +0700 + tryx-panorama-manager (2.0.1-1) noble; urgency=medium * Fix checksum generation for native GitHub Release assets. diff --git a/debian/clean b/debian/clean index c0b9f6f..09c6482 100644 --- a/debian/clean +++ b/debian/clean @@ -1,3 +1,7 @@ .qmake.stash Makefile +Makefile.quick +Makefile.runtime tests/Makefile +tests/quick/Makefile +tests/quick/target_wrapper.sh diff --git a/debian/control b/debian/control index e9ad0e4..ddb1965 100644 --- a/debian/control +++ b/debian/control @@ -5,6 +5,7 @@ Maintainer: DXVSI Build-Depends: dbus-daemon, debhelper-compat (= 13), + ffmpeg, g++, libprotobuf-dev, libudev-dev, @@ -13,14 +14,28 @@ Build-Depends: pkg-config, protobuf-compiler, qmake6, + qml6-module-qt-labs-folderlistmodel, + qml6-module-qtqml, + qml6-module-qtqml-models, + qml6-module-qtqml-workerscript, + qml6-module-qtquick, + qml6-module-qtquick-controls, + qml6-module-qtquick-dialogs, + qml6-module-qtquick-layouts, + qml6-module-qtquick-shapes, + qml6-module-qtquick-templates, + qml6-module-qtquick-window, + qml6-module-qttest, qt6-base-dev, + qt6-declarative-dev, + qt6-declarative-dev-tools, qt6-l10n-tools, systemd-dev Standards-Version: 4.6.2 Rules-Requires-Root: no -Homepage: https://github.com/DXVSI/tryx-panorama-se-360-linux-gui -Vcs-Browser: https://github.com/DXVSI/tryx-panorama-se-360-linux-gui -Vcs-Git: https://github.com/DXVSI/tryx-panorama-se-360-linux-gui.git +Homepage: https://github.com/DXVSI/Tryx-Linux-GUI +Vcs-Browser: https://github.com/DXVSI/Tryx-Linux-GUI +Vcs-Git: https://github.com/DXVSI/Tryx-Linux-GUI.git Package: tryx-panorama-manager Architecture: amd64 @@ -28,6 +43,17 @@ Depends: dbus-user-session, ffmpeg, hicolor-icon-theme, + qml6-module-qt-labs-folderlistmodel, + qml6-module-qtqml, + qml6-module-qtqml-models, + qml6-module-qtqml-workerscript, + qml6-module-qtquick, + qml6-module-qtquick-controls, + qml6-module-qtquick-dialogs, + qml6-module-qtquick-layouts, + qml6-module-qtquick-shapes, + qml6-module-qtquick-templates, + qml6-module-qtquick-window, systemd, udev, ${misc:Depends}, diff --git a/debian/copyright b/debian/copyright index 46f3bc0..fb68247 100644 --- a/debian/copyright +++ b/debian/copyright @@ -1,6 +1,6 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: TRYX Panorama Manager -Source: https://github.com/DXVSI/tryx-panorama-se-360-linux-gui +Source: https://github.com/DXVSI/Tryx-Linux-GUI Files: * Copyright: 2025 Fadli Arsani diff --git a/debian/rules b/debian/rules index 7b9cfdd..304d601 100755 --- a/debian/rules +++ b/debian/rules @@ -6,10 +6,10 @@ export DEB_BUILD_MAINT_OPTIONS = hardening=+all dh $@ override_dh_auto_configure: - qmake6 tryx-panorama.pro + qmake6 tryx-panorama-all.pro override_dh_auto_test: - dbus-run-session -- $(MAKE) check + dbus-run-session -- $(MAKE) package-check override_dh_auto_install: $(MAKE) INSTALL_ROOT=$(CURDIR)/debian/tryx-panorama-manager install diff --git a/include/panorama/config.hpp b/include/panorama/config.hpp index 52325cb..fcb2f55 100644 --- a/include/panorama/config.hpp +++ b/include/panorama/config.hpp @@ -10,7 +10,7 @@ struct Config { std::string port; // Empty = auto-detect int brightness = 75; //default lower than max setting to reduce burn-in risk on display int keepalive_interval = 10; - std::string language = "system"; + std::string language = "en"; std::string pase_overlay_lease_mode = "ping-and-overlay-lease"; }; diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 82d90dc..bac7dbf 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -2,11 +2,11 @@ # PKGBUILD metadata and directory variables are consumed or provided by makepkg. # shellcheck disable=SC2034,SC2154 pkgname=tryx-panorama-manager -pkgver=2.0.1 +pkgver=2.1.0 pkgrel=1 pkgdesc='Control TRYX Panorama cooler displays on Linux' arch=('x86_64') -url='https://github.com/DXVSI/tryx-panorama-se-360-linux-gui' +url='https://github.com/DXVSI/Tryx-Linux-GUI' license=('MIT' 'BSD-2-Clause') depends=( 'dbus' @@ -15,6 +15,7 @@ depends=( 'libusb' 'protobuf' 'qt6-base' + 'qt6-declarative' 'systemd' ) optdepends=( @@ -41,13 +42,13 @@ _source_dir="${pkgname}-${pkgver}" build() { cd "${srcdir}/${_source_dir}" || return 1 - qmake6 tryx-panorama.pro + qmake6 tryx-panorama-all.pro make } check() { cd "${srcdir}/${_source_dir}" || return 1 - dbus-run-session -- make check + dbus-run-session -- make package-check } package() { diff --git a/packaging/metainfo/io.github.dxvsi.tryx_panorama_manager.metainfo.xml b/packaging/metainfo/io.github.dxvsi.tryx_panorama_manager.metainfo.xml index 7fb0135..3b00528 100644 --- a/packaging/metainfo/io.github.dxvsi.tryx_panorama_manager.metainfo.xml +++ b/packaging/metainfo/io.github.dxvsi.tryx_panorama_manager.metainfo.xml @@ -20,10 +20,22 @@ tryx-panorama-manager usb:v391Ap1021d* - https://github.com/DXVSI/tryx-panorama-se-360-linux-gui - https://github.com/DXVSI/tryx-panorama-se-360-linux-gui/issues + https://github.com/DXVSI/Tryx-Linux-GUI + https://github.com/DXVSI/Tryx-Linux-GUI/issues + + +
    +
  • Use one Qt Quick desktop interface backed by a private runtime.
  • +
  • Add native Linux tray integration and preserve legacy serial/ADB control.
  • +
  • Make tray Quit close only the GUI while the runtime stays active.
  • +
  • Add PASE media editing, export, save-as-new, replace, and verified deletion.
  • +
  • Add local firmware validation, an exclusive transport gate, and a crash-persistent recovery interlock.
  • +
  • Keep QML compatible with Qt 6.4 and distribute no bundled vendor media.
  • +
+
+
diff --git a/packaging/rpm/tryx-panorama-manager.spec b/packaging/rpm/tryx-panorama-manager.spec index e565bb1..043031b 100644 --- a/packaging/rpm/tryx-panorama-manager.spec +++ b/packaging/rpm/tryx-panorama-manager.spec @@ -1,10 +1,10 @@ Name: tryx-panorama-manager -Version: 2.0.1 +Version: 2.1.0 Release: 1%{?dist} Summary: Linux manager for supported TRYX Panorama cooler displays License: MIT AND BSD-2-Clause -URL: https://github.com/DXVSI/tryx-panorama-se-360-linux-gui +URL: https://github.com/DXVSI/Tryx-Linux-GUI Source0: %{url}/releases/download/v%{version}/%{name}-%{version}.tar.xz # The first native package release is intentionally limited to the architecture @@ -14,13 +14,16 @@ ExclusiveArch: x86_64 BuildRequires: gcc-c++ BuildRequires: make BuildRequires: dbus-daemon +BuildRequires: ffmpeg-free BuildRequires: qt6-rpm-macros BuildRequires: qt6-linguist +BuildRequires: qt6-qtdeclarative-devel BuildRequires: pkgconfig(Qt6Core) BuildRequires: pkgconfig(Qt6DBus) BuildRequires: pkgconfig(Qt6Gui) -BuildRequires: pkgconfig(Qt6Network) -BuildRequires: pkgconfig(Qt6Widgets) +BuildRequires: pkgconfig(Qt6Qml) +BuildRequires: pkgconfig(Qt6Quick) +BuildRequires: pkgconfig(Qt6QuickControls2) BuildRequires: protobuf-compiler BuildRequires: pkgconfig(protobuf) BuildRequires: pkgconfig(libudev) @@ -37,6 +40,7 @@ Requires: dbus Requires: systemd Requires: systemd-udev Requires: hicolor-icon-theme +Requires: qt6-qtdeclarative%{?_isa} # Media preparation uses the libx264 encoder. Fedora's ffmpeg-free may provide # /usr/bin/ffmpeg without that encoder, so require RPM Fusion's full package. Requires: ffmpeg @@ -68,14 +72,14 @@ upgrade_tool backend. test "$(tr -d '\r\n' < VERSION)" = "%{version}" %build -%qmake_qt6 tryx-panorama.pro +%qmake_qt6 tryx-panorama-all.pro %make_build %install %make_install INSTALL_ROOT=%{buildroot} %check -dbus-run-session -- %make_build check +dbus-run-session -- %make_build package-check packaging/scripts/verify-package-contents.sh %{buildroot} desktop-file-validate \ @@ -103,6 +107,7 @@ udevadm verify --resolve-names=never \ %license %{_licensedir}/%{name}/picojson-BSD-2-Clause.txt %doc README.md %{_bindir}/tryx-panorama-manager +%{_prefix}/lib/tryx-panorama-manager/tryx-panorama-runtime %{_userunitdir}/tryx-panorama.service %{_userpresetdir}/90-tryx-panorama.preset %{_udevrulesdir}/70-tryx-pase-access.rules @@ -113,6 +118,17 @@ udevadm verify --resolve-names=never \ %{_mandir}/man1/tryx-panorama-manager.1* %changelog +* Sun Aug 02 2026 DXVSI - 2.1.0-1 +- Replace the Qt Widgets frontend with one Qt Quick GUI and a private runtime +- Add StatusNotifierItem, DBusMenu, and freedesktop Notifications integration +- Make tray Quit terminate the GUI while leaving the private runtime active +- Preserve legacy serial/ADB control and add PASE media editing, export, + save-as-new, replace, and verified deletion workflows +- Add local firmware validation and a Quick firmware panel protected by an + exclusive device-transport gate; physical flashing is not claimed as verified +- Keep QML compatible with Qt 6.4 +- Use project-owned clean-room protocol schemas and ship no bundled vendor media + * Mon Jul 27 2026 DXVSI - 2.0.1-1 - Fix checksum generation for native GitHub Release assets diff --git a/packaging/scripts/verify-package-contents.sh b/packaging/scripts/verify-package-contents.sh index 82f33ed..9887139 100755 --- a/packaging/scripts/verify-package-contents.sh +++ b/packaging/scripts/verify-package-contents.sh @@ -30,6 +30,7 @@ require_file() { } require_file /usr/bin/tryx-panorama-manager +require_file /usr/lib/tryx-panorama-manager/tryx-panorama-runtime require_file /usr/lib/systemd/user/tryx-panorama.service require_file /usr/lib/systemd/user-preset/90-tryx-panorama.preset require_file /usr/lib/udev/rules.d/70-tryx-pase-access.rules @@ -40,6 +41,11 @@ require_file /usr/share/metainfo/io.github.dxvsi.tryx_panorama_manager.metainfo. require_file /usr/share/licenses/tryx-panorama-manager/LICENSE require_file /usr/share/licenses/tryx-panorama-manager/picojson-BSD-2-Clause.txt +if [ -e "$staged_root/usr/bin/tryx-panorama-quick" ]; then + echo "package contains the retired secondary Quick launcher" >&2 + exit 1 +fi + manpage_count=0 for manpage in \ "$staged_root/usr/share/man/man1/tryx-panorama-manager.1" \ @@ -54,10 +60,15 @@ if [ "$manpage_count" -ne 1 ]; then exit 1 fi -if [ ! -x "$staged_root/usr/bin/tryx-panorama-manager" ]; then - echo "package binary is not executable" >&2 - exit 1 -fi +for binary in \ + "$staged_root/usr/bin/tryx-panorama-manager" \ + "$staged_root/usr/lib/tryx-panorama-manager/tryx-panorama-runtime" +do + if [ ! -x "$binary" ]; then + echo "package binary is not executable: ${binary#"$staged_root"}" >&2 + exit 1 + fi +done preset=$(sed -e 's/[[:space:]]*$//' \ "$staged_root/usr/lib/systemd/user-preset/90-tryx-panorama.preset") @@ -92,8 +103,37 @@ if [ "$version" != "tryx-panorama-manager $expected_version" ]; then exit 1 fi -if ldd "$staged_root/usr/bin/tryx-panorama-manager" | grep -q 'not found'; then - echo "package binary has unresolved dynamic libraries" >&2 +runtime_version=$( + "$staged_root/usr/lib/tryx-panorama-manager/tryx-panorama-runtime" \ + --version +) +if [ "$runtime_version" != "tryx-panorama-runtime $expected_version" ]; then + echo "unexpected runtime version output: $runtime_version" >&2 + exit 1 +fi + +for binary in \ + "$staged_root/usr/bin/tryx-panorama-manager" \ + "$staged_root/usr/lib/tryx-panorama-manager/tryx-panorama-runtime" +do + if ldd "$binary" | grep -q 'not found'; then + echo "package binary has unresolved dynamic libraries: ${binary#"$staged_root"}" >&2 + exit 1 + fi + if readelf -d "$binary" | + grep -Eq 'Shared library: \[libQt6Widgets\.so'; then + echo "package binary links forbidden Qt Widgets: ${binary#"$staged_root"}" >&2 + exit 1 + fi +done + +service_exec=$( + sed -n 's/^ExecStart=//p' \ + "$staged_root/usr/lib/systemd/user/tryx-panorama.service" +) +if [ "$service_exec" != \ + "/usr/lib/tryx-panorama-manager/tryx-panorama-runtime" ]; then + echo "systemd unit does not start the package-private runtime" >&2 exit 1 fi diff --git a/packaging/tryx-panorama-manager.1 b/packaging/tryx-panorama-manager.1 index 106f5b1..c6efae8 100644 --- a/packaging/tryx-panorama-manager.1 +++ b/packaging/tryx-panorama-manager.1 @@ -1,4 +1,4 @@ -.TH TRYX-PANORAMA-MANAGER 1 "2026-07-27" "TRYX Panorama Manager 2.0.1" "User Commands" +.TH TRYX-PANORAMA-MANAGER 1 "2026-08-02" "TRYX Panorama Manager 2.1.0" "User Commands" .SH NAME tryx-panorama-manager \- control compatible TRYX Panorama cooler displays .SH SYNOPSIS @@ -7,14 +7,15 @@ tryx-panorama-manager \- control compatible TRYX Panorama cooler displays .B tryx-panorama-manager .B \-\-version .br -.B tryx-panorama-manager -.B \-\-daemon .SH DESCRIPTION .B TRYX Panorama Manager -is a Qt 6 application for controlling compatible TRYX Panorama and Panorama SE -cooler displays on Linux. -It manages custom media, display settings, live system metrics, and devices -using the PASE printer-class USB protocol. +is a Qt 6 Quick application for controlling compatible TRYX Panorama and +Panorama SE cooler displays on Linux. +The desktop GUI communicates over the user D-Bus session with a separately +packaged private runtime that owns USB, legacy serial/ADB, media, live-metrics, +and firmware operations. +The application manages custom media, display settings, and devices using the +PASE printer-class USB protocol without bundling vendor media. .PP Starting the graphical application starts the packaged systemd user service on demand. @@ -25,14 +26,14 @@ package. .B \-\-version Print the application version and exit without starting the graphical application or accessing USB. -.TP -.B \-\-daemon -Run the background D-Bus service. -This mode is intended to be started by -.BR systemd (1) -or by the graphical application. .SH FILES .TP +.I /usr/bin/tryx-panorama-manager +Qt Quick desktop GUI. +.TP +.I /usr/lib/tryx-panorama-manager/tryx-panorama-runtime +Private background runtime. +.TP .I /usr/lib/systemd/user/tryx-panorama.service Systemd user unit for the background runtime. .TP @@ -46,7 +47,7 @@ Early PASE device-access rule. Late rule that prevents printer-service activation for the PASE interface. .SH BUGS Report issues at -.UR https://github.com/DXVSI/tryx-panorama-se-360-linux-gui/issues +.UR https://github.com/DXVSI/Tryx-Linux-GUI/issues .UE . .SH SEE ALSO .BR systemctl (1), diff --git a/protocol/wire-v1/transport.proto b/protocol/wire-v1/transport.proto index 9bf5391..9810cc6 100644 --- a/protocol/wire-v1/transport.proto +++ b/protocol/wire-v1/transport.proto @@ -72,6 +72,26 @@ message FileRemoval { string file_type = 2; } +message MediaReadChunkRequest { + bytes remote_path = 1; + uint64 session_id = 2; + uint64 offset = 3; +} + +message MediaReadChunkResponse { + enum Status { + OK = 0; + FILE_ERROR = 1; + } + + Status status = 1; + bytes remote_path = 2; + uint64 session_id = 3; + uint64 offset = 4; + uint64 file_size = 5; + bytes data = 6; +} + message ProtocolError { enum Code { SUCCESS = 0; @@ -108,6 +128,7 @@ message Request { TransferChunk transfer_chunk = 401; TransferEnd transfer_end = 402; FileRemoval file_removal = 403; + MediaReadChunkRequest media_read_chunk = 406; } } @@ -126,6 +147,7 @@ message Response { TransferStatus transfer_begin_status = 800; TransferStatus transfer_chunk_status = 801; TransferStatus transfer_end_status = 802; + MediaReadChunkResponse media_read_chunk = 805; AsynchronousEvent asynchronous_event = 987; } } diff --git a/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..9d940fb --- /dev/null +++ b/qml/Main.qml @@ -0,0 +1,490 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts + +import "components" +import "pages" + +ApplicationWindow { + id: window + + required property var runtime + required property var mediaEditor + required property var deviceMedia + required property var firmware + required property var systemMetrics + required property var settings + required property var windowChrome + required property bool quickSmokeTest + + width: 1420 + height: 900 + minimumWidth: 1060 + minimumHeight: 700 + visible: !quickSmokeTest + title: qsTr("TRYX Panorama Manager") + flags: Qt.Window | Qt.FramelessWindowHint + + Material.theme: Material.Dark + Material.accent: "#def750" + color: "#15181b" + + onClosing: close => { + if (window.windowChrome.handleCloseRequest()) + close.accepted = false + } + + property int currentPage: 0 + readonly property string pageTitle: + currentPage === 0 ? qsTr("Dashboard") + : currentPage === 1 ? qsTr("Display") + : qsTr("Settings") + readonly property string pageDescription: + currentPage === 0 + ? qsTr("Device status and live metrics from this PC") + : currentPage === 1 + ? qsTr("Manage media, layout and screen controls") + : qsTr("Application, startup and device preferences") + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + id: titleBar + + Layout.fillWidth: true + Layout.preferredHeight: 46 + color: "#171a1e" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + spacing: 10 + + Image { + Layout.preferredWidth: 28 + Layout.preferredHeight: 28 + source: "qrc:/icons/tryx-panorama.png" + fillMode: Image.PreserveAspectFit + } + + Label { + text: qsTr("PANORAMA") + color: "#f4f6f7" + font.pixelSize: 15 + font.bold: true + font.letterSpacing: 1 + } + + Item { + id: moveArea + + Layout.fillWidth: true + Layout.fillHeight: true + + DragHandler { + target: null + acceptedButtons: Qt.LeftButton + onActiveChanged: { + if (active) + window.windowChrome.startMove() + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onDoubleTapped: + window.windowChrome.toggleMaximized() + } + } + + Rectangle { + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: window.runtime.displaySessionActive + ? "#66d18f" : "#efb85f" + } + + Label { + Layout.maximumWidth: 340 + text: window.runtime.connectionStatus + color: "#aeb5bb" + elide: Text.ElideRight + font.pixelSize: 12 + } + + ToolButton { + id: minimizeButton + + text: "−" + Accessible.name: qsTr("Minimize") + onClicked: window.windowChrome.minimize() + background: Rectangle { + color: minimizeButton.hovered + ? "#2a3036" : "transparent" + } + } + + ToolButton { + id: maximizeButton + + text: window.windowChrome.maximized ? "❐" : "□" + Accessible.name: window.windowChrome.maximized + ? qsTr("Restore") + : qsTr("Maximize") + onClicked: + window.windowChrome.toggleMaximized() + background: Rectangle { + color: maximizeButton.hovered + ? "#2a3036" : "transparent" + } + } + + ToolButton { + id: closeButton + + text: "×" + Accessible.name: qsTr("Close") + onClicked: window.windowChrome.closeWindow() + background: Rectangle { + color: closeButton.hovered + ? "#b43b43" : "transparent" + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: "#2d3338" + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Rectangle { + Layout.fillHeight: true + Layout.preferredWidth: 214 + color: "#1b1f23" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 8 + + Label { + Layout.leftMargin: 10 + Layout.topMargin: 8 + Layout.bottomMargin: 8 + text: qsTr("CONTROL CENTER") + color: "#6f7880" + font.pixelSize: 10 + font.bold: true + font.letterSpacing: 1 + } + + NavButton { + Layout.fillWidth: true + text: qsTr("Dashboard") + selected: window.currentPage === 0 + onClicked: window.currentPage = 0 + } + + NavButton { + Layout.fillWidth: true + text: qsTr("Display") + selected: window.currentPage === 1 + onClicked: window.currentPage = 1 + } + + Item { Layout.fillHeight: true } + + NavButton { + Layout.fillWidth: true + text: qsTr("Settings") + selected: window.currentPage === 2 + onClicked: window.currentPage = 2 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 70 + radius: 10 + color: "#16191c" + border.width: 1 + border.color: "#30363c" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Label { + text: qsTr("DISPLAY SESSION") + color: "#737d85" + font.pixelSize: 9 + font.bold: true + } + + Label { + Layout.fillWidth: true + text: window.runtime.displaySessionActive + ? qsTr("Ready") + : qsTr("Waiting for device") + color: window.runtime.displaySessionActive + ? "#66d18f" : "#efb85f" + elide: Text.ElideRight + } + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 76 + color: "#1d2125" + + ColumnLayout { + anchors.left: parent.left + anchors.leftMargin: 28 + anchors.right: refreshButton.visible + ? refreshButton.left : parent.right + anchors.rightMargin: refreshButton.visible ? 14 : 28 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Label { + text: window.pageTitle + color: "#f4f6f7" + font.pixelSize: 24 + font.bold: true + } + + Label { + text: window.pageDescription + color: "#8d969e" + font.pixelSize: 12 + } + } + + Button { + id: refreshButton + + objectName: "pageRefreshButton" + anchors.right: parent.right + anchors.rightMargin: 28 + anchors.verticalCenter: parent.verticalCenter + visible: window.currentPage !== 2 + text: qsTr("Refresh") + enabled: !window.runtime.operationBusy + onClicked: { + window.runtime.refreshAll() + window.systemMetrics.refresh() + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: "#2d3338" + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: window.currentPage + + HomePage { + runtime: window.runtime + systemMetrics: window.systemMetrics + onOpenDisplayRequested: + window.currentPage = 1 + } + + PanoramaPage { + runtime: window.runtime + editor: window.mediaEditor + deviceMedia: window.deviceMedia + } + + SettingsPage { + runtime: window.runtime + settings: window.settings + firmware: window.firmware + } + } + } + } + } + + MediaEditor { + id: editor + controller: window.mediaEditor + } + + Popup { + id: toast + + parent: Overlay.overlay + x: parent.width - width - 24 + y: parent.height - height - 24 + width: Math.min(520, parent.width - 48) + padding: 14 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + property bool error: false + property string message: "" + + background: Rectangle { + radius: 8 + color: toast.error ? "#52252c" : "#213e33" + border.color: toast.error ? "#ef6473" : "#43d58b" + } + + contentItem: Label { + text: toast.message + color: "#ffffff" + wrapMode: Text.WordWrap + } + } + + Connections { + target: window.runtime + function onUserMessage(message, isError) { + toast.message = message + toast.error = isError + toast.open() + } + } + + Connections { + target: window.deviceMedia + function onUserMessage(message, error) { + toast.message = message + toast.error = error + toast.open() + } + } + + Rectangle { + anchors.fill: parent + color: "transparent" + border.width: 1 + border.color: "#3a4147" + visible: !window.windowChrome.maximized + z: 999 + } + + WindowResizeHandle { + chrome: window.windowChrome + edges: Qt.LeftEdge + cursorShape: Qt.SizeHorCursor + width: 6 + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.topMargin: 9 + anchors.bottomMargin: 9 + enabled: !window.windowChrome.maximized + } + + WindowResizeHandle { + chrome: window.windowChrome + edges: Qt.RightEdge + cursorShape: Qt.SizeHorCursor + width: 6 + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.topMargin: 9 + anchors.bottomMargin: 9 + enabled: !window.windowChrome.maximized + } + + WindowResizeHandle { + chrome: window.windowChrome + edges: Qt.TopEdge + cursorShape: Qt.SizeVerCursor + height: 6 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 9 + anchors.rightMargin: 9 + enabled: !window.windowChrome.maximized + } + + WindowResizeHandle { + chrome: window.windowChrome + edges: Qt.BottomEdge + cursorShape: Qt.SizeVerCursor + height: 6 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 9 + anchors.rightMargin: 9 + enabled: !window.windowChrome.maximized + } + + WindowResizeHandle { + chrome: window.windowChrome + edges: Qt.LeftEdge | Qt.TopEdge + cursorShape: Qt.SizeFDiagCursor + width: 9 + height: 9 + anchors.left: parent.left + anchors.top: parent.top + enabled: !window.windowChrome.maximized + } + + WindowResizeHandle { + chrome: window.windowChrome + edges: Qt.RightEdge | Qt.TopEdge + cursorShape: Qt.SizeBDiagCursor + width: 9 + height: 9 + anchors.right: parent.right + anchors.top: parent.top + enabled: !window.windowChrome.maximized + } + + WindowResizeHandle { + chrome: window.windowChrome + edges: Qt.LeftEdge | Qt.BottomEdge + cursorShape: Qt.SizeBDiagCursor + width: 9 + height: 9 + anchors.left: parent.left + anchors.bottom: parent.bottom + enabled: !window.windowChrome.maximized + } + + WindowResizeHandle { + chrome: window.windowChrome + edges: Qt.RightEdge | Qt.BottomEdge + cursorShape: Qt.SizeFDiagCursor + width: 9 + height: 9 + anchors.right: parent.right + anchors.bottom: parent.bottom + enabled: !window.windowChrome.maximized + } +} diff --git a/qml/components/FirmwareFilePicker.qml b/qml/components/FirmwareFilePicker.qml new file mode 100644 index 0000000..0c45f72 --- /dev/null +++ b/qml/components/FirmwareFilePicker.qml @@ -0,0 +1,306 @@ +pragma ComponentBehavior: Bound + +import Qt.labs.folderlistmodel +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Popup { + id: root + + objectName: "firmwareFilePicker" + + required property var controller + + property url currentFolder: controller.homeFolder + property url selectedFile: "" + property string selectedName: "" + + function openPicker() { + selectedFile = "" + selectedName = "" + if (String(currentFolder).length === 0) + currentFolder = controller.homeFolder + open() + } + + function navigate(folderUrl) { + selectedFile = "" + selectedName = "" + currentFolder = folderUrl + } + + function acceptSelection() { + if (String(selectedFile).length === 0) + return + controller.setPackagePath(String(selectedFile)) + close() + } + + parent: Overlay.overlay + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + width: parent + ? Math.min(820, Math.max(520, parent.width - 48)) + : 820 + height: parent + ? Math.min(680, Math.max(480, parent.height - 48)) + : 680 + modal: true + focus: true + padding: 20 + closePolicy: Popup.CloseOnEscape + + background: Rectangle { + radius: 12 + color: "#1b1f23" + border.width: 1 + border.color: "#4a535a" + } + + FolderListModel { + id: folderModel + + folder: root.currentFolder + nameFilters: ["*.zip"] + showFiles: true + showDirs: true + showDirsFirst: true + showDotAndDotDot: false + showHidden: hiddenFiles.checked + showOnlyReadable: true + caseSensitive: false + sortField: FolderListModel.Name + sortCaseSensitive: false + } + + contentItem: ColumnLayout { + spacing: 14 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + Layout.fillWidth: true + text: qsTr("Select firmware package") + color: "#f4f6f7" + font.pixelSize: 20 + font.bold: true + } + + Label { + Layout.fillWidth: true + text: qsTr("Choose one local ZIP package to validate") + color: "#9da5ac" + elide: Text.ElideRight + } + } + + ToolButton { + text: "×" + Accessible.name: qsTr("Close") + onClicked: root.close() + } + } + + Frame { + Layout.fillWidth: true + Layout.preferredHeight: 48 + + background: Rectangle { + radius: 7 + color: "#15181b" + border.width: 1 + border.color: "#363d43" + } + + Label { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + text: String(root.currentFolder) + color: "#c7cdd2" + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + text: qsTr("Home") + onClicked: root.navigate(root.controller.homeFolder) + } + + Button { + text: qsTr("Up") + enabled: String(folderModel.parentFolder).length > 0 && + String(folderModel.parentFolder) !== + String(root.currentFolder) + onClicked: root.navigate(folderModel.parentFolder) + } + + Item { Layout.fillWidth: true } + + CheckBox { + id: hiddenFiles + text: qsTr("Show hidden files") + } + } + + Frame { + Layout.fillWidth: true + Layout.fillHeight: true + + background: Rectangle { + radius: 8 + color: "#15181b" + border.width: 1 + border.color: "#363d43" + } + + ListView { + objectName: "firmwareFilePickerList" + anchors.fill: parent + anchors.margins: 4 + clip: true + model: folderModel + currentIndex: -1 + spacing: 2 + + ScrollBar.vertical: ScrollBar {} + + delegate: ItemDelegate { + id: fileDelegate + + required property string fileName + required property url fileUrl + required property double fileSize + required property bool fileIsDir + + width: ListView.view.width + height: 54 + highlighted: + !fileDelegate.fileIsDir && + String(root.selectedFile) === + String(fileDelegate.fileUrl) + + onClicked: { + if (fileDelegate.fileIsDir) { + root.navigate(fileDelegate.fileUrl) + return + } + root.selectedFile = fileDelegate.fileUrl + root.selectedName = fileDelegate.fileName + } + onDoubleClicked: { + if (fileDelegate.fileIsDir) { + root.navigate(fileDelegate.fileUrl) + return + } + root.selectedFile = fileDelegate.fileUrl + root.selectedName = fileDelegate.fileName + root.acceptSelection() + } + + contentItem: RowLayout { + spacing: 12 + + Rectangle { + Layout.preferredWidth: 54 + Layout.preferredHeight: 28 + radius: 5 + color: fileDelegate.fileIsDir + ? "#30372d" : "#252b30" + border.width: 1 + border.color: fileDelegate.fileIsDir + ? "#7b883c" : "#41494f" + + Label { + anchors.centerIn: parent + text: fileDelegate.fileIsDir + ? qsTr("DIR") : qsTr("ZIP") + color: fileDelegate.fileIsDir + ? "#def750" : "#aeb5bb" + font.pixelSize: 9 + font.bold: true + } + } + + Label { + Layout.fillWidth: true + text: fileDelegate.fileName + color: "#eef1f3" + elide: Text.ElideMiddle + } + + Label { + visible: !fileDelegate.fileIsDir + Layout.preferredWidth: 96 + text: qsTr("%1 MiB").arg( + (fileDelegate.fileSize / + 1048576).toFixed(1)) + color: "#8f989f" + horizontalAlignment: Text.AlignRight + } + } + } + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Null + text: qsTr("This folder cannot be opened") + color: "#efb85f" + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Loading + text: qsTr("Loading folder…") + color: "#9da5ac" + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Ready && + folderModel.count === 0 + text: qsTr("No firmware ZIP files in this folder") + color: "#9da5ac" + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Label { + Layout.fillWidth: true + text: String(root.selectedFile).length > 0 + ? qsTr("Selected: %1").arg(root.selectedName) + : qsTr("Select a ZIP package to continue") + color: "#9da5ac" + elide: Text.ElideMiddle + } + + Button { + text: qsTr("Cancel") + onClicked: root.close() + } + + PrimaryButton { + objectName: "firmwarePickerSelectButton" + text: qsTr("Select") + enabled: String(root.selectedFile).length > 0 + onClicked: root.acceptSelection() + } + } + } +} diff --git a/qml/components/FirmwarePanel.qml b/qml/components/FirmwarePanel.qml new file mode 100644 index 0000000..c3853fb --- /dev/null +++ b/qml/components/FirmwarePanel.qml @@ -0,0 +1,393 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Frame { + id: root + + required property var controller + + readonly property bool recoveryActionAvailable: + controller.recoveryRequired && !controller.busy + + padding: 22 + + function formatBytes(bytes) { + if (bytes <= 0) + return qsTr("Unknown size") + const units = [qsTr("B"), qsTr("KiB"), qsTr("MiB"), qsTr("GiB")] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + ++unit + } + return "%1 %2".arg(value.toFixed(unit === 0 ? 0 : 1)) + .arg(units[unit]) + } + + function phaseLabel(phase) { + switch (phase) { + case "Initializing": + return qsTr("Initializing") + case "Idle": + return qsTr("Ready") + case "Validating": + return qsTr("Validating") + case "Approved": + return qsTr("Validated") + case "Expired": + return qsTr("Approval expired") + case "Revalidating": + return qsTr("Final identity check") + case "Flashing": + return qsTr("Flashing") + case "Succeeded": + return qsTr("Completed") + case "Failed": + return qsTr("Failed") + case "RecoveryRequired": + return qsTr("Recovery required") + default: + return qsTr("Unavailable") + } + } + + background: Rectangle { + radius: 12 + color: "#23282d" + border.width: 1 + border.color: "#343b42" + } + + contentItem: ColumnLayout { + spacing: 16 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + text: qsTr("Firmware") + color: "#f4f6f7" + font.pixelSize: 18 + font.bold: true + } + + Label { + Layout.fillWidth: true + text: qsTr("Validate a local package before sending any flash command") + color: "#9ca4ac" + wrapMode: Text.WordWrap + } + } + + Label { + text: root.controller.ready + ? qsTr("Service ready") + : (root.controller.compatible + ? qsTr("Initializing…") + : qsTr("Service unavailable")) + color: root.controller.ready + ? "#66d18f" : "#efb85f" + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: "#343b42" + } + + Label { + Layout.fillWidth: true + text: qsTr("Local firmware ZIP") + color: "#f4f6f7" + font.bold: true + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + TextField { + id: packagePathField + + objectName: "firmwarePackagePath" + Layout.fillWidth: true + placeholderText: qsTr("/path/to/firmware.zip") + text: root.controller.packagePath + selectByMouse: true + enabled: !root.controller.busy + onTextEdited: + root.controller.setPackagePath(text) + } + + Button { + objectName: "firmwareChooseButton" + text: qsTr("Choose…") + enabled: !root.controller.busy + onClicked: firmwareFilePicker.openPicker() + } + + Button { + text: root.controller.validationBusy + ? qsTr("Validating…") + : qsTr("Validate") + enabled: root.controller.canValidate + onClicked: root.controller.validatePackage() + } + + Button { + text: qsTr("Refresh") + enabled: root.controller.serviceAvailable && + !root.controller.busy + onClicked: root.controller.refresh() + } + } + + Label { + Layout.fillWidth: true + visible: !root.controller.serviceAvailable + text: qsTr("The background service must be running to validate or flash firmware.") + color: "#efb85f" + wrapMode: Text.WordWrap + } + + GridLayout { + Layout.fillWidth: true + visible: root.controller.canonicalPath.length > 0 + columns: 2 + columnSpacing: 18 + rowSpacing: 8 + + Label { + text: qsTr("Package") + color: "#9ca4ac" + } + Label { + Layout.fillWidth: true + text: root.controller.canonicalPath + color: "#d8dde1" + elide: Text.ElideMiddle + } + + Label { + text: qsTr("Type") + color: "#9ca4ac" + } + Label { + Layout.fillWidth: true + text: root.controller.kind + color: "#d8dde1" + } + + Label { + text: qsTr("Size") + color: "#9ca4ac" + } + Label { + Layout.fillWidth: true + text: root.formatBytes(root.controller.sizeBytes) + color: "#d8dde1" + } + + Label { + text: qsTr("Product") + color: "#9ca4ac" + visible: root.controller.productCode.length > 0 + } + Label { + Layout.fillWidth: true + text: root.controller.productCode + color: "#d8dde1" + visible: root.controller.productCode.length > 0 + } + + Label { + text: qsTr("Package version") + color: "#9ca4ac" + visible: root.controller.firmwareVersion.length > 0 || + root.controller.appVersion.length > 0 + } + Label { + Layout.fillWidth: true + text: root.controller.firmwareVersion.length > 0 + ? root.controller.firmwareVersion + : root.controller.appVersion + color: "#d8dde1" + visible: root.controller.firmwareVersion.length > 0 || + root.controller.appVersion.length > 0 + } + + Label { + text: qsTr("SHA-256") + color: "#9ca4ac" + } + Label { + Layout.fillWidth: true + text: root.controller.sha256 + color: "#d8dde1" + font.family: "monospace" + elide: Text.ElideMiddle + } + } + + ProgressBar { + Layout.fillWidth: true + visible: root.controller.busy + from: 0 + to: 100 + value: root.controller.progress + indeterminate: root.controller.validationBusy || + (root.controller.flashBusy && + root.controller.progress <= 0) + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + Layout.fillWidth: true + text: root.phaseLabel(root.controller.phase) + color: root.controller.errorMessage.length > 0 + ? "#ef6b73" + : (root.controller.approvalAvailable + ? "#66d18f" : "#d8dde1") + font.bold: true + } + + Label { + Layout.fillWidth: true + text: root.controller.errorMessage.length > 0 + ? root.controller.errorMessage + : root.controller.status + color: root.controller.errorMessage.length > 0 + ? "#ef6b73" : "#9ca4ac" + wrapMode: Text.WordWrap + } + } + + PrimaryButton { + objectName: "firmwareRecoveryAcknowledgeButton" + text: qsTr("I inspected the display; resume connection") + visible: root.recoveryActionAvailable + enabled: root.recoveryActionAvailable + onClicked: + root.controller.acknowledgeFirmwareRecovery() + } + + Button { + visible: root.controller.flashBusy && + root.controller.phase === "Flashing" + text: qsTr("Request cancellation") + onClicked: root.controller.requestCancel() + ToolTip.visible: hovered + ToolTip.text: qsTr("The device may refuse cancellation after an irreversible flashing step") + } + + PrimaryButton { + text: qsTr("Flash firmware…") + enabled: root.controller.canFlash + onClicked: + root.controller.requestFlashConfirmation() + } + } + } + + FirmwareFilePicker { + id: firmwareFilePicker + controller: root.controller + } + + Dialog { + id: confirmationDialog + + parent: Overlay.overlay + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + width: parent ? Math.min(620, parent.width - 48) : 620 + modal: true + focus: true + padding: 22 + closePolicy: Popup.NoAutoClose + title: qsTr("Confirm firmware flash") + + background: Rectangle { + radius: 12 + color: "#1b1f23" + border.width: 1 + border.color: "#7f8d36" + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: qsTr("This operation can make the display unusable if USB or power is interrupted. Cancellation is refused after the updater reaches an irreversible step.") + color: "#f0d27a" + font.bold: true + wrapMode: Text.WordWrap + } + + Label { + Layout.fillWidth: true + text: qsTr("Package: %1") + .arg(root.controller.canonicalPath) + color: "#d8dde1" + wrapMode: Text.WrapAnywhere + } + + Label { + Layout.fillWidth: true + text: qsTr("SHA-256: %1") + .arg(root.controller.sha256) + color: "#9ca4ac" + font.family: "monospace" + wrapMode: Text.WrapAnywhere + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Item { + Layout.fillWidth: true + } + + Button { + text: qsTr("Cancel") + onClicked: + root.controller.cancelFlashConfirmation() + } + + PrimaryButton { + text: qsTr("Flash firmware") + enabled: root.controller.canFlash + onClicked: root.controller.confirmFlash() + } + } + } + } + + Connections { + target: root.controller + + function onConfirmationRequiredChanged() { + if (root.controller.confirmationRequired) + confirmationDialog.open() + else + confirmationDialog.close() + } + } +} diff --git a/qml/components/MediaEditor.qml b/qml/components/MediaEditor.qml new file mode 100644 index 0000000..a58b1ef --- /dev/null +++ b/qml/components/MediaEditor.qml @@ -0,0 +1,542 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts + +Popup { + id: root + + required property var controller + + parent: Overlay.overlay + x: Math.max(16, (parent.width - width) / 2) + y: Math.max(16, (parent.height - height) / 2) + width: Math.min(1040, parent.width - 32) + height: Math.min(800, parent.height - 32) + modal: true + focus: true + closePolicy: Popup.NoAutoClose + padding: 20 + readonly property var sizingModes: [ + {"value": "Fit", "label": qsTr("Fit")}, + {"value": "Fill", "label": qsTr("Fill")}, + {"value": "Crop", "label": qsTr("Crop")}, + {"value": "Stretch", "label": qsTr("Stretch")} + ] + readonly property bool recoveredTransformIsGeometryNeutral: + controller.recoveredDeviceCopy && + controller.rotation === 0 && + (controller.mode !== "Crop" || + controller.zoomPercent === 100) + + function synchronizeVisibility() { + if (controller.open && !root.opened) + root.open() + else if (!controller.open && root.opened) + root.close() + } + + function modeDescription(mode) { + switch (mode) { + case "Fit": + return qsTr("Show the whole image and fill any free space with the selected background color.") + case "Fill": + return qsTr("Fill the screen while preserving proportions; edges are cropped from the center.") + case "Crop": + return qsTr("Fill the screen and adjust zoom and position manually.") + case "Stretch": + return qsTr("Fill the screen exactly without preserving proportions. The image may be distorted.") + default: + return "" + } + } + + Component.onCompleted: synchronizeVisibility() + + Connections { + target: root.controller + function onOpenChanged() { + root.synchronizeVisibility() + } + } + + background: Rectangle { + color: "#1d2125" + radius: 12 + border.color: "#7f8d36" + } + + contentItem: ColumnLayout { + id: editorContent + + objectName: "mediaEditorContent" + spacing: 14 + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: qsTr("Media Editor") + font.pixelSize: 22 + font.bold: true + } + Label { + text: root.controller.sourceName + color: "#9da1b3" + elide: Text.ElideMiddle + Layout.maximumWidth: 420 + } + } + + ScrollView { + id: editorScroll + + objectName: "mediaEditorScroll" + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 160 + clip: true + contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + id: editorScrollableContent + + width: editorScroll.availableWidth + spacing: 14 + + Rectangle { + objectName: "recoveredDeviceCopyNotice" + Layout.fillWidth: true + Layout.preferredHeight: + recoveredNotice.implicitHeight + 20 + visible: root.controller.recoveredDeviceCopy + radius: 7 + color: root.recoveredTransformIsGeometryNeutral + ? "#352d20" : "#2b3024" + border.width: 1 + border.color: + root.recoveredTransformIsGeometryNeutral + ? "#b9833f" : "#7f8d36" + + Label { + id: recoveredNotice + + objectName: + "recoveredDeviceCopyNoticeText" + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: + parent.verticalCenter + anchors.leftMargin: 12 + anchors.rightMargin: 12 + text: + root.recoveredTransformIsGeometryNeutral + ? qsTr("This device copy is already encoded at 2240 × 1080, so the current settings will not visibly change it. Existing padding is baked into the video. Choose Crop and raise Zoom above 100%, or rotate the video. Save as new does not change the active display; select the new copy in the library and apply it. Previously lost areas cannot be restored.") + : qsTr("This is a private working copy recovered from the device. Save as new stores another media item but does not change the active display; select the new copy in the library and apply it. Replace original updates the original item. Saving re-encodes the video; areas lost before the original upload cannot be restored.") + color: + root.recoveredTransformIsGeometryNeutral + ? "#f0c27b" : "#d8ddb9" + wrapMode: Text.WordWrap + } + } + + Item { + id: previewViewport + Layout.fillWidth: true + Layout.preferredHeight: + Math.min(420, width * 1080 / 2240) + Layout.minimumHeight: 220 + + Rectangle { + id: canvas + objectName: "mediaPreviewCanvas" + + anchors.centerIn: parent + width: Math.min( + parent.width, + parent.height * 2240 / 1080) + height: width * 1080 / 2240 + color: "#000000" + border.color: "#6b6f80" + radius: 6 + clip: true + + Image { + id: previewImage + + anchors.fill: parent + source: + root.controller.previewUrl + asynchronous: false + cache: false + smooth: true + fillMode: Image.Stretch + } + + MouseArea { + id: panArea + + anchors.fill: parent + enabled: + root.controller.mode === "Crop" && + root.controller.zoomPercent > 100 && + root.controller.ready && + !root.controller.submissionPending + cursorShape: enabled + ? Qt.OpenHandCursor + : Qt.ArrowCursor + property real previousX: 0 + property real previousY: 0 + + onPressed: mouse => { + previousX = mouse.x + previousY = mouse.y + cursorShape = + Qt.ClosedHandCursor + } + onReleased: + cursorShape = + Qt.OpenHandCursor + onCanceled: + cursorShape = + Qt.OpenHandCursor + onPositionChanged: mouse => { + if (!pressed) + return + const dx = + mouse.x - previousX + const dy = + mouse.y - previousY + previousX = mouse.x + previousY = mouse.y + root.controller.focusX = + Math.round( + root.controller.focusX - + dx / Math.max( + 1, canvas.width) * + 10000) + root.controller.focusY = + Math.round( + root.controller.focusY - + dy / Math.max( + 1, canvas.height) * + 10000) + } + } + + BusyIndicator { + anchors.centerIn: parent + running: root.controller.busy + visible: running + } + + Label { + anchors.centerIn: parent + width: parent.width - 40 + visible: + !root.controller.busy && + !root.controller.ready + text: + root.controller.error.length > 0 + ? root.controller.error + : qsTr( + "Select a supported media file") + color: + root.controller.error.length > 0 + ? "#ef7784" : "#a7aabb" + wrapMode: Text.WordWrap + horizontalAlignment: + Text.AlignHCenter + } + + Label { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 8 + text: "2240 × 1080" + color: "#b7bac7" + font.pixelSize: 11 + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { text: qsTr("Sizing") } + ButtonGroup { id: modeGroup } + Repeater { + model: root.sizingModes + Button { + required property var modelData + + text: modelData.label + checkable: true + enabled: + !root.controller + .submissionPending + checked: + root.controller.mode === + modelData.value + ButtonGroup.group: modeGroup + onClicked: + root.controller.mode = + modelData.value + } + } + Item { Layout.fillWidth: true } + Label { text: qsTr("Rotation") } + ComboBox { + model: ["0°", "90°", "180°", "270°"] + enabled: + !root.controller.submissionPending + currentIndex: + root.controller.rotation / 90 + onActivated: index => { + root.controller.rotation = + index * 90 + } + } + } + + Label { + objectName: "mediaSizingDescription" + Layout.fillWidth: true + text: + root.modeDescription( + root.controller.mode) + color: + root.controller.mode === "Stretch" + ? "#efb85f" : "#aeb5bb" + wrapMode: Text.WordWrap + } + + GridLayout { + Layout.fillWidth: true + columns: 4 + columnSpacing: 12 + + Label { + text: qsTr("Zoom") + enabled: + root.controller.mode === "Crop" && + !root.controller.submissionPending + } + Slider { + Layout.fillWidth: true + from: 100 + to: 400 + stepSize: 1 + value: root.controller.zoomPercent + enabled: + root.controller.mode === "Crop" && + !root.controller.submissionPending + onMoved: + root.controller.zoomPercent = + Math.round(value) + } + Label { + text: + qsTr("%1%").arg( + root.controller.zoomPercent) + enabled: + root.controller.mode === "Crop" && + !root.controller.submissionPending + } + Item { Layout.fillWidth: true } + + Label { + text: qsTr("Horizontal position") + enabled: + root.controller.mode === "Crop" && + root.controller.zoomPercent > 100 && + !root.controller.submissionPending + } + SpinBox { + objectName: + "cropHorizontalPosition" + from: 0 + to: 100 + stepSize: 1 + value: Math.round( + root.controller.focusX / 100) + enabled: + root.controller.mode === "Crop" && + root.controller.zoomPercent > 100 && + !root.controller.submissionPending + editable: true + textFromValue: + function(value, locale) { + return value + "%" + } + valueFromText: + function(text, locale) { + const parsed = parseInt(text) + return isNaN(parsed) + ? 50 : parsed + } + onValueModified: + root.controller.focusX = + value * 100 + } + Label { + text: qsTr("Vertical position") + enabled: + root.controller.mode === "Crop" && + root.controller.zoomPercent > 100 && + !root.controller.submissionPending + } + SpinBox { + objectName: + "cropVerticalPosition" + from: 0 + to: 100 + stepSize: 1 + value: Math.round( + root.controller.focusY / 100) + enabled: + root.controller.mode === "Crop" && + root.controller.zoomPercent > 100 && + !root.controller.submissionPending + editable: true + textFromValue: + function(value, locale) { + return value + "%" + } + valueFromText: + function(text, locale) { + const parsed = parseInt(text) + return isNaN(parsed) + ? 50 : parsed + } + onValueModified: + root.controller.focusY = + value * 100 + } + + Label { + text: qsTr("Fit background") + enabled: + root.controller.mode === "Fit" && + !root.controller.submissionPending + } + Button { + id: backgroundButton + + text: + root.controller.backgroundColor + enabled: + root.controller.mode === "Fit" && + !root.controller.submissionPending + onClicked: colorDialog.open() + background: Rectangle { + radius: 5 + color: + root.controller + .backgroundColor + border.color: "#989baa" + } + contentItem: Label { + text: backgroundButton.text + color: + root.controller + .backgroundColor === + "#000000" + ? "#ffffff" : "#000000" + horizontalAlignment: + Text.AlignHCenter + verticalAlignment: + Text.AlignVCenter + } + } + Item { + Layout.columnSpan: 2 + Layout.fillWidth: true + } + } + + Label { + Layout.fillWidth: true + visible: + root.controller.error.length > 0 + text: root.controller.error + color: "#ef7784" + wrapMode: Text.WordWrap + } + } + } + + RowLayout { + objectName: "mediaEditorActionRow" + Layout.fillWidth: true + Button { + text: qsTr("Reset") + enabled: !root.controller.submissionPending + onClicked: root.controller.reset() + } + Item { Layout.fillWidth: true } + Button { + objectName: "mediaEditorCancelButton" + text: qsTr("Cancel") + enabled: !root.controller.submissionPending + onClicked: root.controller.cancel() + } + PrimaryButton { + objectName: "mediaEditorUploadButton" + visible: !root.controller.recoveredDeviceCopy + text: root.controller.busy + ? qsTr("Please wait…") + : qsTr("Upload") + enabled: root.controller.ready && + !root.controller.busy + onClicked: root.controller.submit() + } + Button { + id: replaceButton + + objectName: "mediaEditorReplaceButton" + visible: root.controller.recoveredDeviceCopy + text: root.controller.submissionPending && + root.controller.submissionAction === "Replace" + ? qsTr("Replacing…") + : qsTr("Replace original") + enabled: root.controller.ready && + root.controller.replaceAllowed && + !root.controller.busy && + !root.controller.submissionPending + ToolTip.visible: hovered && + !root.controller.replaceAllowed && + root.controller + .replaceBlockReason.length > 0 + ToolTip.text: root.controller.replaceBlockReason + onClicked: root.controller.submitReplace() + } + PrimaryButton { + objectName: "mediaEditorSaveAsNewButton" + visible: root.controller.recoveredDeviceCopy + text: root.controller.submissionPending && + root.controller.submissionAction === "SaveAsNew" + ? qsTr("Saving…") + : qsTr("Save as new") + enabled: root.controller.ready && + !root.controller.busy && + !root.controller.submissionPending + ToolTip.visible: hovered + ToolTip.text: qsTr("Creates a new media item without changing the active display") + onClicked: root.controller.submitSaveAsNew() + } + } + } + + ColorDialog { + id: colorDialog + title: qsTr("Fit background color") + selectedColor: root.controller.backgroundColor + onAccepted: root.controller.backgroundColor = + selectedColor.toString() + } +} diff --git a/qml/components/MediaExportPicker.qml b/qml/components/MediaExportPicker.qml new file mode 100644 index 0000000..95229fe --- /dev/null +++ b/qml/components/MediaExportPicker.qml @@ -0,0 +1,302 @@ +pragma ComponentBehavior: Bound + +import Qt.labs.folderlistmodel +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Popup { + id: root + + objectName: "mediaExportPicker" + + required property var workflow + required property url homeFolder + + property url currentFolder: homeFolder + property string mediaId: "" + property string mediaName: "" + property alias fileName: fileNameField.text + + function openFor(targetMediaId, targetMediaName) { + mediaId = targetMediaId + mediaName = targetMediaName + fileName = workflow.suggestedExportFileName(targetMediaName) + if (String(currentFolder).length === 0) + currentFolder = homeFolder + open() + } + + function navigate(folderUrl) { + currentFolder = folderUrl + } + + function exportCopy() { + if (mediaId.length === 0 || fileName.trim().length === 0) + return + const targetId = mediaId + const targetName = mediaName + const folder = currentFolder + const destinationName = fileName.trim() + close() + Qt.callLater(() => root.workflow.beginExport( + targetId, targetName, + folder, destinationName)) + } + + parent: Overlay.overlay + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + width: parent + ? Math.min(760, Math.max(520, parent.width - 48)) + : 760 + height: parent + ? Math.min(680, Math.max(480, parent.height - 48)) + : 680 + modal: true + focus: true + padding: 20 + closePolicy: Popup.CloseOnEscape + + onClosed: { + mediaId = "" + mediaName = "" + } + + background: Rectangle { + radius: 12 + color: "#1b1f23" + border.width: 1 + border.color: "#4a535a" + } + + FolderListModel { + id: folderModel + + folder: root.currentFolder + showFiles: false + showDirs: true + showDirsFirst: true + showDotAndDotDot: false + showHidden: hiddenFolders.checked + showOnlyReadable: true + sortField: FolderListModel.Name + sortCaseSensitive: false + } + + contentItem: ColumnLayout { + spacing: 14 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + Layout.fillWidth: true + text: qsTr("Export device media copy") + color: "#f4f6f7" + font.pixelSize: 20 + font.bold: true + } + + Label { + Layout.fillWidth: true + text: qsTr("Choose a local folder and H.264 file name") + color: "#9da5ac" + elide: Text.ElideRight + } + } + + ToolButton { + text: "×" + Accessible.name: qsTr("Close") + onClicked: root.close() + } + } + + Frame { + Layout.fillWidth: true + Layout.preferredHeight: 48 + + background: Rectangle { + radius: 7 + color: "#15181b" + border.width: 1 + border.color: "#363d43" + } + + Label { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + text: String(root.currentFolder) + color: "#c7cdd2" + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + text: qsTr("Home") + onClicked: root.navigate(root.homeFolder) + } + + Button { + text: qsTr("Up") + enabled: String(folderModel.parentFolder).length > 0 && + String(folderModel.parentFolder) !== + String(root.currentFolder) + onClicked: root.navigate(folderModel.parentFolder) + } + + Item { Layout.fillWidth: true } + + CheckBox { + id: hiddenFolders + text: qsTr("Show hidden folders") + } + } + + Frame { + Layout.fillWidth: true + Layout.fillHeight: true + + background: Rectangle { + radius: 8 + color: "#15181b" + border.width: 1 + border.color: "#363d43" + } + + ListView { + objectName: "mediaExportFolderList" + anchors.fill: parent + anchors.margins: 4 + clip: true + model: folderModel + spacing: 2 + + ScrollBar.vertical: ScrollBar {} + + delegate: ItemDelegate { + id: folderDelegate + + required property string fileName + required property url fileUrl + required property bool fileIsDir + + width: ListView.view.width + height: 52 + visible: fileIsDir + + onClicked: + root.navigate(folderDelegate.fileUrl) + + contentItem: RowLayout { + spacing: 12 + + Rectangle { + Layout.preferredWidth: 54 + Layout.preferredHeight: 28 + radius: 5 + color: "#30372d" + border.width: 1 + border.color: "#7b883c" + + Label { + anchors.centerIn: parent + text: qsTr("DIR") + color: "#def750" + font.pixelSize: 9 + font.bold: true + } + } + + Label { + Layout.fillWidth: true + text: folderDelegate.fileName + color: "#eef1f3" + elide: Text.ElideMiddle + } + } + } + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Null + text: qsTr("This folder cannot be opened") + color: "#efb85f" + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Loading + text: qsTr("Loading folder…") + color: "#9da5ac" + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Ready && + folderModel.count === 0 + text: qsTr("This folder has no subfolders") + color: "#9da5ac" + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Label { + text: qsTr("File name") + } + + TextField { + id: fileNameField + + objectName: "mediaExportFileName" + Layout.fillWidth: true + placeholderText: qsTr("device-media-copy.h264") + selectByMouse: true + onAccepted: root.exportCopy() + } + } + + Label { + Layout.fillWidth: true + text: qsTr("The exported file is the device-ready raw H.264 copy.") + color: "#8f989f" + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Item { Layout.fillWidth: true } + + Button { + text: qsTr("Cancel") + onClicked: root.close() + } + + PrimaryButton { + objectName: "mediaExportSaveButton" + text: qsTr("Export") + enabled: root.mediaId.length > 0 && + root.fileName.trim().length > 0 + onClicked: root.exportCopy() + } + } + } +} diff --git a/qml/components/MediaFilePicker.qml b/qml/components/MediaFilePicker.qml new file mode 100644 index 0000000..d59ed29 --- /dev/null +++ b/qml/components/MediaFilePicker.qml @@ -0,0 +1,339 @@ +pragma ComponentBehavior: Bound + +import Qt.labs.folderlistmodel +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Popup { + id: root + + objectName: "mediaFilePicker" + + required property var editor + + property url currentFolder: editor.homeFolder + property url selectedFile: "" + property string selectedName: "" + property url pendingSource: "" + + function clearSelection() { + selectedFile = "" + selectedName = "" + } + + function openPicker() { + clearSelection() + pendingSource = "" + if (String(currentFolder).length === 0) + currentFolder = editor.homeFolder + open() + } + + function navigate(folderUrl) { + clearSelection() + currentFolder = folderUrl + } + + function selectEntry(fileUrl, isDirectory, fileName) { + if (isDirectory) { + navigate(fileUrl) + return + } + selectedFile = fileUrl + selectedName = fileName + } + + function acceptSelection() { + if (String(selectedFile).length === 0) + return + pendingSource = selectedFile + close() + } + + parent: Overlay.overlay + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + width: parent + ? Math.min(920, Math.max(520, parent.width - 48)) + : 920 + height: parent + ? Math.min(720, Math.max(500, parent.height - 48)) + : 720 + modal: true + focus: true + padding: 20 + closePolicy: Popup.CloseOnEscape + + onClosed: { + const source = pendingSource + pendingSource = "" + clearSelection() + if (String(source).length > 0) + Qt.callLater(() => root.editor.begin(source)) + } + + background: Rectangle { + radius: 12 + color: "#1b1f23" + border.width: 1 + border.color: "#4a535a" + } + + FolderListModel { + id: folderModel + + folder: root.currentFolder + nameFilters: [ + "*.mp4", "*.webm", "*.mkv", "*.avi", "*.mov", + "*.gif", "*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp" + ] + showFiles: true + showDirs: true + showDirsFirst: true + showDotAndDotDot: false + showHidden: hiddenFiles.checked + showOnlyReadable: true + caseSensitive: false + sortField: FolderListModel.Name + sortCaseSensitive: false + } + + contentItem: ColumnLayout { + spacing: 14 + + RowLayout { + objectName: "mediaFilePickerHeader" + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + Layout.fillWidth: true + text: qsTr("Select media file") + color: "#f4f6f7" + font.pixelSize: 20 + font.bold: true + } + + Label { + Layout.fillWidth: true + text: qsTr("Choose one image, GIF or video to edit before upload") + color: "#9da5ac" + elide: Text.ElideRight + } + } + + ToolButton { + text: "×" + Accessible.name: qsTr("Close") + onClicked: root.close() + } + } + + Frame { + Layout.fillWidth: true + Layout.preferredHeight: 48 + + background: Rectangle { + radius: 7 + color: "#15181b" + border.width: 1 + border.color: "#363d43" + } + + Label { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + text: String(root.currentFolder) + color: "#c7cdd2" + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + text: qsTr("Home") + onClicked: root.navigate(root.editor.homeFolder) + } + + Button { + text: qsTr("Up") + enabled: String(folderModel.parentFolder).length > 0 && + String(folderModel.parentFolder) !== + String(root.currentFolder) + onClicked: root.navigate(folderModel.parentFolder) + } + + Item { + Layout.fillWidth: true + } + + CheckBox { + id: hiddenFiles + text: qsTr("Show hidden files") + onCheckedChanged: root.clearSelection() + } + } + + Frame { + Layout.fillWidth: true + Layout.fillHeight: true + + background: Rectangle { + radius: 8 + color: "#15181b" + border.width: 1 + border.color: "#363d43" + } + + ListView { + id: fileList + + objectName: "mediaFilePickerList" + anchors.fill: parent + anchors.margins: 4 + clip: true + model: folderModel + currentIndex: -1 + spacing: 2 + + ScrollBar.vertical: ScrollBar {} + + delegate: ItemDelegate { + id: fileDelegate + + required property string fileName + required property url fileUrl + required property double fileSize + required property bool fileIsDir + + width: ListView.view.width + height: 54 + highlighted: + !fileDelegate.fileIsDir && + String(root.selectedFile) === + String(fileDelegate.fileUrl) + + onClicked: + root.selectEntry( + fileDelegate.fileUrl, + fileDelegate.fileIsDir, + fileDelegate.fileName) + onDoubleClicked: { + root.selectEntry( + fileDelegate.fileUrl, + fileDelegate.fileIsDir, + fileDelegate.fileName) + if (!fileDelegate.fileIsDir) + root.acceptSelection() + } + + contentItem: RowLayout { + spacing: 12 + + Rectangle { + Layout.preferredWidth: 54 + Layout.preferredHeight: 28 + radius: 5 + color: fileDelegate.fileIsDir + ? "#30372d" : "#252b30" + border.width: 1 + border.color: fileDelegate.fileIsDir + ? "#7b883c" : "#41494f" + + Label { + anchors.centerIn: parent + text: fileDelegate.fileIsDir + ? qsTr("DIR") : qsTr("FILE") + color: fileDelegate.fileIsDir + ? "#def750" : "#aeb5bb" + font.pixelSize: 9 + font.bold: true + } + } + + Label { + Layout.fillWidth: true + text: fileDelegate.fileName + color: "#eef1f3" + elide: Text.ElideMiddle + } + + Label { + visible: !fileDelegate.fileIsDir + Layout.preferredWidth: 96 + text: qsTr("%1 MB").arg( + (fileDelegate.fileSize / + 1048576).toFixed(1)) + color: "#8f989f" + horizontalAlignment: Text.AlignRight + } + } + } + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Null + text: qsTr("This folder cannot be opened") + color: "#efb85f" + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Loading + text: qsTr("Loading folder…") + color: "#9da5ac" + } + + Label { + anchors.centerIn: parent + visible: folderModel.status === FolderListModel.Ready && + folderModel.count === 0 + text: qsTr("No supported media files in this folder") + color: "#9da5ac" + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Label { + Layout.fillWidth: true + text: String(root.selectedFile).length > 0 + ? qsTr("Selected: %1").arg( + root.selectedName.length > 0 + ? root.selectedName + : decodeURIComponent( + String(root.selectedFile) + .split("/").pop())) + : qsTr("Select a media file to continue") + color: String(root.selectedFile).length > 0 + ? "#c7cdd2" : "#7f8990" + elide: Text.ElideMiddle + } + + Button { + text: qsTr("Cancel") + onClicked: root.close() + } + + PrimaryButton { + objectName: "mediaFilePickerOpenButton" + text: qsTr("Open") + enabled: String(root.selectedFile).length > 0 + onClicked: root.acceptSelection() + } + } + } +} diff --git a/qml/components/MetricCard.qml b/qml/components/MetricCard.qml new file mode 100644 index 0000000..b50c68d --- /dev/null +++ b/qml/components/MetricCard.qml @@ -0,0 +1,80 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Frame { + id: root + + required property string title + required property string valueText + property string subtitle: "" + property string details: "" + property real percentage: -1 + property color accentColor: "#def750" + + implicitHeight: 166 + padding: 18 + + background: Rectangle { + radius: 12 + color: "#23282d" + border.width: 1 + border.color: "#343b42" + } + + contentItem: ColumnLayout { + spacing: 8 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f6f7" + font.pixelSize: 15 + font.bold: true + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + visible: root.subtitle.length > 0 + text: root.subtitle + color: "#9ca4ac" + font.pixelSize: 11 + elide: Text.ElideRight + } + + Item { Layout.fillHeight: true } + + Label { + text: root.valueText + color: root.accentColor + font.pixelSize: 30 + font.bold: true + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 6 + visible: root.percentage >= 0 + radius: 3 + color: "#15181b" + + Rectangle { + width: parent.width * + Math.max(0, Math.min(100, + root.percentage)) / 100 + height: parent.height + radius: parent.radius + color: root.accentColor + } + } + + Label { + Layout.fillWidth: true + text: root.details + color: "#9ca4ac" + font.pixelSize: 12 + elide: Text.ElideRight + } + } +} diff --git a/qml/components/NavButton.qml b/qml/components/NavButton.qml new file mode 100644 index 0000000..c6ea0c4 --- /dev/null +++ b/qml/components/NavButton.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Button { + id: root + + property bool selected: false + property color accentColor: "#def750" + + implicitHeight: 46 + leftPadding: 14 + rightPadding: 14 + hoverEnabled: true + flat: true + + contentItem: RowLayout { + spacing: 10 + + Rectangle { + Layout.preferredWidth: 3 + Layout.preferredHeight: 18 + radius: 2 + color: root.selected ? "#11140b" : "transparent" + } + + Label { + Layout.fillWidth: true + text: root.text + color: root.selected ? "#11140b" : "#d7dce0" + font.pixelSize: 14 + font.bold: root.selected + verticalAlignment: Text.AlignVCenter + } + } + + background: Rectangle { + radius: 9 + color: root.selected + ? root.accentColor + : (root.hovered ? "#2a3036" : "transparent") + border.width: root.activeFocus ? 1 : 0 + border.color: root.selected ? "#11140b" : root.accentColor + } +} diff --git a/qml/components/OperationBanner.qml b/qml/components/OperationBanner.qml new file mode 100644 index 0000000..6793c69 --- /dev/null +++ b/qml/components/OperationBanner.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Frame { + id: root + + required property var runtime + + visible: root.runtime.operationBusy + padding: 12 + + background: Rectangle { + radius: 8 + color: "#23282d" + border.color: "#7f8d36" + } + + RowLayout { + anchors.fill: parent + spacing: 12 + + BusyIndicator { + running: root.runtime.operationBusy + visible: root.runtime.operationProgress <= 0 + } + ColumnLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: root.runtime.operationSummary + wrapMode: Text.WordWrap + } + ProgressBar { + Layout.fillWidth: true + visible: root.runtime.operationProgress > 0 + value: root.runtime.operationProgress + } + } + Button { + text: qsTr("Cancel") + onClicked: root.runtime.cancelActiveOperation() + } + } +} diff --git a/qml/components/PrimaryButton.qml b/qml/components/PrimaryButton.qml new file mode 100644 index 0000000..0e3ed8f --- /dev/null +++ b/qml/components/PrimaryButton.qml @@ -0,0 +1,17 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: control + + highlighted: true + + contentItem: Label { + text: control.text + font: control.font + color: control.enabled ? "#171a1e" : "#6f7560" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } +} diff --git a/qml/components/WindowResizeHandle.qml b/qml/components/WindowResizeHandle.qml new file mode 100644 index 0000000..5e89152 --- /dev/null +++ b/qml/components/WindowResizeHandle.qml @@ -0,0 +1,17 @@ +import QtQuick + +MouseArea { + id: root + + required property var chrome + required property int edges + + acceptedButtons: Qt.LeftButton + hoverEnabled: true + z: 1000 + + onPressed: mouse => { + root.chrome.startResize(root.edges) + mouse.accepted = true + } +} diff --git a/qml/pages/HomePage.qml b/qml/pages/HomePage.qml new file mode 100644 index 0000000..eb29a2c --- /dev/null +++ b/qml/pages/HomePage.qml @@ -0,0 +1,363 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "../components" + +ScrollView { + id: root + + required property var runtime + required property var systemMetrics + property url deviceIconSource: + "qrc:/icons/tryx-panorama.png" + + signal openDisplayRequested() + + clip: true + + function percent(available, value) { + return available ? Math.round(value) + "%" : "—" + } + + function temperature(available, value) { + return available ? Math.round(value) + " °C" : "—" + } + + function frequency(available, value) { + if (!available) + return qsTr("Frequency unavailable") + if (value >= 1000) + return qsTr("%1 GHz").arg((value / 1000).toFixed(2)) + return qsTr("%1 MHz").arg(Math.round(value)) + } + + function memory(available, usedMB, totalMB) { + if (!available || totalMB <= 0) + return qsTr("Memory data unavailable") + return qsTr("%1 / %2 GiB") + .arg((usedMB / 1024).toFixed(1)) + .arg((totalMB / 1024).toFixed(1)) + } + + function storage(available, usedGB, totalGB) { + if (!available || totalGB <= 0) + return qsTr("Storage data unavailable") + return qsTr("%1 / %2 GiB").arg(usedGB).arg(totalGB) + } + + function rate(available, value) { + if (!available) + return "—" + if (value >= 1024) + return qsTr("%1 MiB/s").arg((value / 1024).toFixed(1)) + return qsTr("%1 KiB/s").arg(value.toFixed(1)) + } + + ColumnLayout { + objectName: "dashboardContent" + x: 28 + y: 24 + width: Math.max(0, root.availableWidth - 56) + spacing: 20 + + Frame { + Layout.fillWidth: true + Layout.preferredHeight: 230 + padding: 24 + + background: Rectangle { + radius: 14 + color: "#23282d" + border.width: 1 + border.color: "#3d454c" + + Rectangle { + width: 5 + height: parent.height - 36 + anchors.left: parent.left + anchors.leftMargin: 1 + anchors.verticalCenter: parent.verticalCenter + radius: 3 + color: "#def750" + } + } + + contentItem: RowLayout { + spacing: 24 + + Rectangle { + Layout.preferredWidth: 138 + Layout.preferredHeight: 138 + radius: 18 + color: "#171a1e" + border.width: 1 + border.color: "#343b42" + + Image { + anchors.centerIn: parent + width: 92 + height: 92 + source: root.deviceIconSource + fillMode: Image.PreserveAspectFit + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: qsTr("PANORAMA SE") + color: "#f4f6f7" + font.pixelSize: 25 + font.bold: true + } + + RowLayout { + spacing: 8 + + Rectangle { + Layout.preferredWidth: 9 + Layout.preferredHeight: 9 + radius: 5 + color: root.runtime.displaySessionActive + ? "#66d18f" : "#efb85f" + } + + Label { + text: root.runtime.connectionStatus + color: root.runtime.displaySessionActive + ? "#bceccc" : "#efcf94" + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + Label { + Layout.fillWidth: true + text: qsTr("Layout: %1 · Playback: %2") + .arg(root.runtime.currentScreenMode || + qsTr("Unknown")) + .arg(root.runtime.currentPlayMode || + qsTr("Unknown")) + color: "#9ca4ac" + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + text: root.runtime.displayedMedia.length > 0 + ? root.runtime.displayedMedia.join(", ") + : qsTr("No media is currently selected") + color: "#9ca4ac" + elide: Text.ElideMiddle + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 10 + + Label { + Layout.alignment: Qt.AlignHCenter + text: root.runtime.displayStateValid + ? qsTr("%1% brightness") + .arg(root.runtime.brightness) + : qsTr("Display state unavailable") + color: "#d7dce0" + } + + PrimaryButton { + text: qsTr("Manage display") + onClicked: root.openDisplayRequested() + } + } + } + } + + RowLayout { + Layout.fillWidth: true + + Label { + text: qsTr("This PC") + color: "#f4f6f7" + font.pixelSize: 20 + font.bold: true + } + + Item { Layout.fillWidth: true } + + Label { + text: root.systemMetrics.sampled + ? qsTr("Updated automatically") + : qsTr("Reading sensors…") + color: "#7f8991" + font.pixelSize: 12 + } + } + + GridLayout { + id: metricsGrid + + objectName: "systemMetricsGrid" + Layout.fillWidth: true + columns: width >= 1040 ? 4 : 2 + columnSpacing: 14 + rowSpacing: 14 + + MetricCard { + objectName: "cpuMetricCard" + Layout.fillWidth: true + title: qsTr("CPU") + subtitle: root.systemMetrics.cpuName || + qsTr("Processor") + valueText: root.percent( + root.systemMetrics.cpuUsageAvailable, + root.systemMetrics.cpuUsage) + percentage: + root.systemMetrics.cpuUsageAvailable + ? root.systemMetrics.cpuUsage : -1 + details: root.temperature( + root.systemMetrics.cpuTemperatureAvailable, + root.systemMetrics.cpuTemperature) + + " · " + root.frequency( + root.systemMetrics.cpuFrequencyAvailable, + root.systemMetrics.cpuFrequencyMHz) + } + + MetricCard { + objectName: "gpuMetricCard" + Layout.fillWidth: true + title: qsTr("GPU") + subtitle: root.systemMetrics.gpuName || + qsTr("Graphics processor") + valueText: root.percent( + root.systemMetrics.gpuUsageAvailable, + root.systemMetrics.gpuUsage) + percentage: + root.systemMetrics.gpuUsageAvailable + ? root.systemMetrics.gpuUsage : -1 + details: root.temperature( + root.systemMetrics.gpuTemperatureAvailable, + root.systemMetrics.gpuTemperature) + + " · " + root.frequency( + root.systemMetrics.gpuFrequencyAvailable, + root.systemMetrics.gpuFrequencyMHz) + } + + MetricCard { + objectName: "memoryMetricCard" + Layout.fillWidth: true + title: qsTr("Memory") + subtitle: qsTr("System RAM") + valueText: root.percent( + root.systemMetrics.ramUsageAvailable, + root.systemMetrics.ramUsage) + percentage: + root.systemMetrics.ramUsageAvailable + ? root.systemMetrics.ramUsage : -1 + details: root.memory( + root.systemMetrics.ramUsageAvailable, + root.systemMetrics.ramUsedMB, + root.systemMetrics.ramTotalMB) + } + + MetricCard { + objectName: "storageMetricCard" + Layout.fillWidth: true + title: qsTr("Storage") + subtitle: qsTr("System disk") + valueText: root.percent( + root.systemMetrics.diskUsageAvailable, + root.systemMetrics.diskUsage) + percentage: + root.systemMetrics.diskUsageAvailable + ? root.systemMetrics.diskUsage : -1 + details: root.storage( + root.systemMetrics.diskUsageAvailable, + root.systemMetrics.diskUsedGB, + root.systemMetrics.diskTotalGB) + } + } + + Frame { + Layout.fillWidth: true + Layout.preferredHeight: 116 + padding: 18 + + background: Rectangle { + radius: 12 + color: "#23282d" + border.width: 1 + border.color: "#343b42" + } + + contentItem: RowLayout { + spacing: 20 + + ColumnLayout { + Layout.fillWidth: true + Label { + text: qsTr("Network") + color: "#f4f6f7" + font.pixelSize: 15 + font.bold: true + } + Label { + text: root.systemMetrics.networkAvailable + ? qsTr("Current transfer rate") + : qsTr("Waiting for the next sample") + color: "#9ca4ac" + font.pixelSize: 12 + } + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: "#343b42" + } + + ColumnLayout { + Layout.preferredWidth: 190 + Label { + text: qsTr("Download") + color: "#9ca4ac" + font.pixelSize: 11 + } + Label { + text: root.rate( + root.systemMetrics.networkAvailable, + root.systemMetrics.rxSpeedKBs) + color: "#def750" + font.pixelSize: 23 + font.bold: true + } + } + + ColumnLayout { + Layout.preferredWidth: 190 + Label { + text: qsTr("Upload") + color: "#9ca4ac" + font.pixelSize: 11 + } + Label { + text: root.rate( + root.systemMetrics.networkAvailable, + root.systemMetrics.txSpeedKBs) + color: "#d7dce0" + font.pixelSize: 23 + font.bold: true + } + } + } + } + + Item { + Layout.fillHeight: true + Layout.minimumHeight: 20 + } + } +} diff --git a/qml/pages/PanoramaPage.qml b/qml/pages/PanoramaPage.qml new file mode 100644 index 0000000..6e10c09 --- /dev/null +++ b/qml/pages/PanoramaPage.qml @@ -0,0 +1,1051 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "../components" + +ScrollView { + id: root + + required property var runtime + required property var editor + required property var deviceMedia + + clip: true + + property bool splitMode: false + property var selectedMedia: [] + property var fullMetrics: [] + property var leftMetrics: [] + property var rightMetrics: [] + property var fullBadges: [] + property var leftBadges: [] + property var rightBadges: [] + property int brightnessDraft: runtime.brightness + property bool mirrorDraft: runtime.mirrorMode + property bool waterfallDraft: runtime.waterfallMode + property string playMode: "Single" + property string metricsAlignment: runtime.metricsAlignment || "Left" + property string metricsColor: runtime.metricsColor || "#dcdcdc" + property string pendingDeleteName: "" + readonly property var alignmentOptions: [ + {"value": "Left", "label": qsTr("Left")}, + {"value": "Center", "label": qsTr("Center")}, + {"value": "Right", "label": qsTr("Right")} + ] + + function toggled(list, value, limit) { + const next = list.slice(0) + const existing = next.indexOf(value) + if (existing >= 0) { + next.splice(existing, 1) + return next + } + if (next.length >= limit) + next.shift() + next.push(value) + return next + } + + function toggleMedia(value) { + selectedMedia = toggled(selectedMedia, value, + splitMode ? 2 : 1) + } + + function toggleMetric(group, value) { + if (group === "full") + fullMetrics = toggled(fullMetrics, value, 3) + else if (group === "left") + leftMetrics = toggled(leftMetrics, value, 3) + else + rightMetrics = toggled(rightMetrics, value, 3) + } + + function toggleBadge(group, value) { + if (group === "full") + fullBadges = toggled(fullBadges, value, 2) + else if (group === "left") + leftBadges = toggled(leftBadges, value, 2) + else + rightBadges = toggled(rightBadges, value, 2) + } + + function requestDelete(mediaName) { + if (!runtime.mediaModel.canDelete(mediaName)) + return + pendingDeleteName = mediaName + deleteConfirmation.open() + } + + function metricLabel(value) { + switch (value) { + case "CPU Temperature": + return qsTr("CPU temperature") + case "CPU Frequency": + return qsTr("CPU frequency") + case "CPU Usage": + return qsTr("CPU usage") + case "CPU Power": + return qsTr("CPU power") + case "GPU Temperature": + return qsTr("GPU temperature") + case "GPU Frequency": + return qsTr("GPU frequency") + case "GPU Usage": + return qsTr("GPU usage") + case "GPU Power": + return qsTr("GPU power") + case "Memory Usage": + return qsTr("Memory usage") + case "DateTime": + return qsTr("Date and time") + default: + return value + } + } + + function selected(group, value) { + if (group === "full") + return fullMetrics.indexOf(value) >= 0 + if (group === "left") + return leftMetrics.indexOf(value) >= 0 + return rightMetrics.indexOf(value) >= 0 + } + + function badgeSelected(group, value) { + if (group === "full") + return fullBadges.indexOf(value) >= 0 + if (group === "left") + return leftBadges.indexOf(value) >= 0 + return rightBadges.indexOf(value) >= 0 + } + + function synchronizeDisplayDraft() { + brightnessDraft = runtime.brightness + mirrorDraft = runtime.mirrorMode + waterfallDraft = runtime.waterfallMode + splitMode = runtime.currentScreenMode === + "Screen Splitting" + const media = runtime.displayedMedia || [] + selectedMedia = media.slice( + 0, splitMode ? 2 : 1) + if (runtime.currentPlayMode.length > 0) + playMode = runtime.currentPlayMode + fullMetrics = + (runtime.displayLeftMetrics || []).slice(0) + leftMetrics = + (runtime.displayLeftMetrics || []).slice(0) + rightMetrics = + (runtime.displayRightMetrics || []).slice(0) + fullBadges = + (runtime.displayLeftBadges || []).slice(0) + leftBadges = + (runtime.displayLeftBadges || []).slice(0) + rightBadges = + (runtime.displayRightBadges || []).slice(0) + } + + Component.onCompleted: { + synchronizeDisplayDraft() + fullMetrics = + (runtime.activeMetrics || fullMetrics).slice(0) + } + + Connections { + target: root.runtime + function onDisplayChanged() { + root.synchronizeDisplayDraft() + } + function onMetricsChanged() { + root.metricsAlignment = + root.runtime.metricsAlignment || "Left" + root.metricsColor = + root.runtime.metricsColor || "#dcdcdc" + if (!root.splitMode) { + root.fullMetrics = + (root.runtime.activeMetrics || []).slice(0) + } + } + } + + ColumnLayout { + objectName: "panoramaContent" + x: 24 + y: 24 + width: Math.max(0, root.availableWidth - 48) + spacing: 16 + + OperationBanner { + Layout.fillWidth: true + runtime: root.runtime + } + + GridLayout { + id: displayWorkspace + + objectName: "displayWorkspace" + Layout.fillWidth: true + columns: root.availableWidth >= 1120 ? 2 : 1 + columnSpacing: 16 + rowSpacing: 16 + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + Layout.minimumWidth: + displayWorkspace.columns === 2 ? 600 : 0 + Layout.preferredWidth: + displayWorkspace.columns === 2 ? 700 : 0 + spacing: 16 + + RowLayout { + Layout.fillWidth: true + spacing: 10 + PrimaryButton { + objectName: "uploadMediaButton" + text: qsTr("Upload media…") + enabled: root.runtime.displaySessionActive && + !root.runtime.operationBusy + onClicked: mediaPicker.openPicker() + } + Button { + text: qsTr("Reload media library") + enabled: root.runtime.compatible && + !root.runtime.operationBusy + onClicked: root.runtime.refreshMedia() + } + Item { Layout.fillWidth: true } + Label { + text: qsTr("%1 selected") + .arg(root.selectedMedia.length) + color: "#9da1b3" + } + } + + Frame { + id: dropFrame + + Layout.fillWidth: true + Layout.preferredHeight: 72 + background: Rectangle { + radius: 8 + color: dropArea.containsDrag + ? "#30372d" : "#23282d" + border.width: 2 + border.color: dropArea.containsDrag + ? "#def750" : "#4a535a" + } + Label { + anchors.centerIn: parent + width: parent.width - 24 + text: qsTr("Drop one MP4, WebM, MKV, AVI, MOV, GIF, JPG, PNG, BMP or WebP file here") + color: "#b8bbca" + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + DropArea { + id: dropArea + anchors.fill: parent + enabled: !root.runtime.operationBusy + onDropped: drop => { + root.editor.beginDropped(drop.urls) + drop.acceptProposedAction() + } + } + } + + GroupBox { + objectName: "mediaLibraryGroup" + Layout.fillWidth: true + Layout.preferredHeight: Math.min( + 470, + Math.max( + 235, + Math.ceil( + mediaGrid.count / + Math.max( + 1, + Math.floor( + mediaGrid.width / + mediaGrid.cellWidth))) * + mediaGrid.cellHeight + 45)) + title: qsTr("Media Library") + + GridView { + id: mediaGrid + + anchors.fill: parent + cellWidth: 220 + cellHeight: 158 + clip: true + model: root.runtime.mediaModel + + delegate: ItemDelegate { + id: mediaDelegate + + required property string mediaName + required property string mediaId + required property var thumbnailUrl + required property bool deleteAllowed + required property string deleteBlockReason + required property bool deviceCopyAllowed + required property string deviceCopyBlockReason + required property double mediaSize + + width: mediaGrid.cellWidth - 10 + height: mediaGrid.cellHeight - 10 + highlighted: + root.selectedMedia.indexOf(mediaName) >= 0 + onClicked: root.toggleMedia(mediaName) + ToolTip.visible: hovered && + !deleteAllowed && + deleteBlockReason.length > 0 + ToolTip.text: deleteBlockReason + + contentItem: ColumnLayout { + spacing: 6 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 92 + color: "#15181b" + radius: 5 + Image { + anchors.fill: parent + anchors.margins: 3 + source: mediaDelegate.thumbnailUrl + fillMode: Image.PreserveAspectFit + asynchronous: true + } + Label { + anchors.centerIn: parent + visible: + String(mediaDelegate.thumbnailUrl) + .length === 0 + text: qsTr("No preview") + color: "#6f7383" + } + ToolButton { + id: mediaActionButton + + objectName: "mediaActionButton" + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 6 + width: 30 + height: 30 + padding: 0 + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + leftInset: 0 + rightInset: 0 + topInset: 0 + bottomInset: 0 + text: "⋯" + Accessible.name: + qsTr("Media actions") + enabled: + !root.runtime.operationBusy && + !root.deviceMedia.busy + ToolTip.visible: hovered + ToolTip.text: + !mediaDelegate.deviceCopyAllowed && + mediaDelegate + .deviceCopyBlockReason + .length > 0 + ? mediaDelegate + .deviceCopyBlockReason + : qsTr("Media actions") + background: Rectangle { + anchors.fill: parent + radius: width / 2 + color: + mediaActionButton.hovered + ? "#485057" + : "#343a3f" + border.width: 1 + border.color: + mediaActionButton.hovered + ? "#8d979f" + : "#515960" + } + contentItem: Label { + text: mediaActionButton.text + color: "#f4f6f7" + font.pixelSize: 20 + horizontalAlignment: + Text.AlignHCenter + verticalAlignment: + Text.AlignVCenter + } + onClicked: mediaActions.open() + + Menu { + id: mediaActions + + y: mediaActionButton.height + 4 + + MenuItem { + objectName: + "editDeviceMediaAction" + text: qsTr("Edit") + enabled: + mediaDelegate + .deviceCopyAllowed + onTriggered: + root.deviceMedia + .beginEdit( + mediaDelegate + .mediaId, + mediaDelegate + .mediaName) + } + + MenuItem { + objectName: + "exportDeviceMediaAction" + text: qsTr("Export copy…") + enabled: + mediaDelegate + .deviceCopyAllowed + onTriggered: + exportPicker.openFor( + mediaDelegate.mediaId, + mediaDelegate + .mediaName) + } + + MenuSeparator {} + + MenuItem { + objectName: + "deleteDeviceMediaAction" + text: qsTr("Delete") + enabled: + mediaDelegate + .deleteAllowed + onTriggered: + root.requestDelete( + mediaDelegate + .mediaName) + } + } + } + } + Label { + Layout.fillWidth: true + text: mediaDelegate.mediaName + elide: Text.ElideMiddle + font.pixelSize: 12 + } + Label { + text: qsTr("%1 MiB").arg( + (mediaDelegate.mediaSize / + 1024 / 1024).toFixed(1)) + color: "#85899a" + font.pixelSize: 10 + } + } + } + + Label { + anchors.centerIn: parent + visible: mediaGrid.count === 0 + text: qsTr("No uploaded media") + color: "#8d91a1" + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + Layout.minimumWidth: + displayWorkspace.columns === 2 ? 400 : 0 + Layout.preferredWidth: + displayWorkspace.columns === 2 ? 430 : 0 + spacing: 16 + + GroupBox { + objectName: "displayLayoutGroup" + Layout.fillWidth: true + title: qsTr("Display layout") + + ColumnLayout { + anchors.fill: parent + spacing: 12 + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 10 + rowSpacing: 8 + + RadioButton { + Layout.fillWidth: true + text: qsTr("Full screen") + checked: !root.splitMode + onClicked: { + root.splitMode = false + if (root.selectedMedia.length > 1) + root.selectedMedia = + [root.selectedMedia[0]] + } + } + RadioButton { + Layout.fillWidth: true + text: qsTr("Split screen") + checked: root.splitMode + onClicked: root.splitMode = true + } + Label { text: qsTr("Play mode") } + ComboBox { + id: playModeCombo + objectName: "playModeCombo" + Layout.fillWidth: true + + readonly property var playModes: + root.splitMode + ? [ + {"value": "Single", + "label": qsTr("Single")} + ] + : [ + {"value": "Single", + "label": qsTr("Single")}, + {"value": "Loop", + "label": qsTr("Loop")}, + {"value": "Shuffle", + "label": qsTr("Shuffle")} + ] + model: playModes + textRole: "label" + currentIndex: Math.max( + 0, + playModes.findIndex( + item => item.value === + (root.splitMode + ? "Single" + : root.playMode))) + onActivated: index => { + root.playMode = + playModes[index].value + } + } + } + + Label { + Layout.fillWidth: true + text: root.splitMode + ? qsTr("Left: %1 Right: %2") + .arg(root.selectedMedia[0] || qsTr("not selected")) + .arg(root.selectedMedia[1] || qsTr("not selected")) + : qsTr("Media: %1") + .arg(root.selectedMedia[0] || + qsTr("not selected")) + color: "#b8bbca" + elide: Text.ElideMiddle + } + + Label { + text: root.splitMode + ? qsTr("Select up to three metrics per side") + : qsTr("Select up to three overlay metrics") + font.bold: true + } + + Flow { + Layout.fillWidth: true + spacing: 8 + visible: !root.splitMode + Repeater { + model: root.runtime.availableMetrics + CheckBox { + required property string modelData + text: root.metricLabel(modelData) + checked: root.selected("full", modelData) + onClicked: + root.toggleMetric("full", modelData) + } + } + } + + Label { + visible: !root.splitMode + text: qsTr("Hardware badges") + font.bold: true + } + + Flow { + Layout.fillWidth: true + spacing: 8 + visible: !root.splitMode + + CheckBox { + objectName: "fullCpuBadge" + text: qsTr("CPU Badge") + checked: + root.badgeSelected( + "full", "CPU Badge") + onClicked: + root.toggleBadge( + "full", "CPU Badge") + } + CheckBox { + objectName: "fullGpuBadge" + text: qsTr("GPU Badge") + checked: + root.badgeSelected( + "full", "GPU Badge") + onClicked: + root.toggleBadge( + "full", "GPU Badge") + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.splitMode + ColumnLayout { + Layout.fillWidth: true + Label { text: qsTr("Left metrics") } + Flow { + Layout.fillWidth: true + spacing: 6 + Repeater { + model: root.runtime.availableMetrics + CheckBox { + required property string modelData + text: root.metricLabel(modelData) + checked: + root.selected("left", modelData) + onClicked: + root.toggleMetric("left", + modelData) + } + } + } + Label { + text: qsTr("Left badges") + font.bold: true + } + Flow { + Layout.fillWidth: true + spacing: 6 + + CheckBox { + objectName: "leftCpuBadge" + text: qsTr("CPU Badge") + checked: + root.badgeSelected( + "left", "CPU Badge") + onClicked: + root.toggleBadge( + "left", "CPU Badge") + } + CheckBox { + objectName: "leftGpuBadge" + text: qsTr("GPU Badge") + checked: + root.badgeSelected( + "left", "GPU Badge") + onClicked: + root.toggleBadge( + "left", "GPU Badge") + } + } + } + ColumnLayout { + Layout.fillWidth: true + Label { text: qsTr("Right metrics") } + Flow { + Layout.fillWidth: true + spacing: 6 + Repeater { + model: root.runtime.availableMetrics + CheckBox { + required property string modelData + text: root.metricLabel(modelData) + checked: + root.selected("right", modelData) + onClicked: + root.toggleMetric("right", + modelData) + } + } + } + Label { + text: qsTr("Right badges") + font.bold: true + } + Flow { + Layout.fillWidth: true + spacing: 6 + + CheckBox { + objectName: "rightCpuBadge" + text: qsTr("CPU Badge") + checked: + root.badgeSelected( + "right", "CPU Badge") + onClicked: + root.toggleBadge( + "right", "CPU Badge") + } + CheckBox { + objectName: "rightGpuBadge" + text: qsTr("GPU Badge") + checked: + root.badgeSelected( + "right", "GPU Badge") + onClicked: + root.toggleBadge( + "right", "GPU Badge") + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: "#343a40" + } + + ColumnLayout { + objectName: "metricsOverlayControls" + Layout.fillWidth: true + spacing: 10 + + Label { + text: qsTr("Live metrics") + font.bold: true + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 10 + rowSpacing: 8 + + Label { text: qsTr("Alignment") } + ComboBox { + Layout.fillWidth: true + model: root.alignmentOptions + textRole: "label" + currentIndex: + Math.max( + 0, + root.alignmentOptions.findIndex( + item => item.value === + root.metricsAlignment)) + onActivated: index => { + root.metricsAlignment = + root.alignmentOptions[index].value + } + } + Label { text: qsTr("Text color") } + TextField { + Layout.fillWidth: true + text: root.metricsColor + placeholderText: "#dcdcdc" + onEditingFinished: + root.metricsColor = text + } + } + + RowLayout { + Layout.fillWidth: true + + Label { + text: root.runtime.samplingActive + ? qsTr("Sampling is active") + : qsTr("Sampling is inactive") + color: root.runtime.samplingActive + ? "#4bd98d" : "#8d91a1" + } + Item { Layout.fillWidth: true } + Button { + text: root.runtime.metricsEnabled + ? qsTr("Stop metrics overlay") + : qsTr("Start metrics overlay") + enabled: !root.runtime.operationBusy + onClicked: + root.runtime.configureMetrics( + !root.runtime.metricsEnabled, + root.fullMetrics, + root.metricsAlignment, + root.metricsColor) + } + } + } + + PrimaryButton { + Layout.alignment: Qt.AlignRight + text: qsTr("Apply to display") + enabled: !root.runtime.operationBusy && + ((!root.splitMode && + root.selectedMedia.length === 1) || + (root.splitMode && + root.selectedMedia.length === 2)) + onClicked: { + if (root.splitMode) { + root.runtime.applySplitScreen( + root.selectedMedia[0], + root.selectedMedia[1], + "Single", + root.leftMetrics, + root.rightMetrics, + root.leftBadges, + root.rightBadges) + } else { + root.runtime.applyFullScreen( + [root.selectedMedia[0]], + root.playMode, + root.fullMetrics, + root.fullBadges) + } + } + } + } + } + } + } + + GroupBox { + Layout.fillWidth: true + title: qsTr("Screen controls") + + ColumnLayout { + anchors.fill: parent + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Label { + Layout.preferredWidth: 92 + text: qsTr("Brightness") + } + Slider { + objectName: "brightnessSlider" + Layout.preferredWidth: 320 + Layout.maximumWidth: 380 + from: 0 + to: 100 + stepSize: 1 + value: root.brightnessDraft + enabled: root.runtime.displayStateValid && + !root.runtime.operationBusy + onMoved: + root.brightnessDraft = Math.round(value) + } + Label { + Layout.preferredWidth: 34 + text: root.brightnessDraft.toString() + } + Button { + text: qsTr("Apply brightness") + enabled: root.runtime.displayStateValid && + !root.runtime.operationBusy + onClicked: + root.runtime.setBrightness( + root.brightnessDraft) + } + Item { Layout.fillWidth: true } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Label { + Layout.preferredWidth: 92 + text: qsTr("Backlight") + } + Label { + Layout.preferredWidth: 74 + text: root.runtime.backlightEnabled + ? qsTr("On") : qsTr("Off") + color: root.runtime.backlightEnabled + ? "#66d18f" : "#9ca4ac" + } + Button { + text: root.runtime.backlightEnabled + ? qsTr("Turn display off") + : qsTr("Turn display on") + enabled: root.runtime.displayStateValid && + !root.runtime.operationBusy + onClicked: root.runtime.setBacklight( + !root.runtime.backlightEnabled) + } + Item { Layout.fillWidth: true } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Label { + Layout.preferredWidth: 92 + text: qsTr("Orientation") + } + CheckBox { + text: qsTr("Mirror") + checked: root.mirrorDraft + onClicked: root.mirrorDraft = checked + } + CheckBox { + text: qsTr("Waterfall") + checked: root.waterfallDraft + onClicked: root.waterfallDraft = checked + } + Button { + text: qsTr("Apply orientation") + enabled: root.runtime.displayStateValid && + !root.runtime.operationBusy + onClicked: root.runtime.setOrientation( + root.mirrorDraft, + root.waterfallDraft) + } + Item { Layout.fillWidth: true } + } + } + } + + GroupBox { + objectName: "recentOperationsGroup" + Layout.fillWidth: true + Layout.preferredHeight: Math.min( + 310, Math.max(115, operationList.count * 64 + 45)) + title: qsTr("Recent operations") + + ListView { + id: operationList + + anchors.fill: parent + clip: true + model: root.runtime.operationModel + delegate: ItemDelegate { + id: operationDelegate + + required property string operationId + required property string subject + required property string operationState + required property string message + required property bool canRetry + + width: ListView.view.width + height: 62 + contentItem: RowLayout { + Label { + Layout.fillWidth: true + text: (operationDelegate.subject || + operationDelegate.operationId) + + "\n" + + operationDelegate.operationState + + ": " + operationDelegate.message + elide: Text.ElideRight + } + Button { + text: qsTr("Retry") + visible: operationDelegate.canRetry + enabled: !root.runtime.operationBusy + onClicked: + root.runtime.retryOperation( + operationDelegate.operationId) + } + } + } + } + } + + Item { + Layout.fillHeight: true + Layout.minimumHeight: 20 + } + } + + MediaFilePicker { + id: mediaPicker + editor: root.editor + } + + MediaExportPicker { + id: exportPicker + workflow: root.deviceMedia + homeFolder: root.editor.homeFolder + } + + Connections { + target: root.deviceMedia + + function onStateChanged() { + if (root.deviceMedia.overwriteConfirmationPending) { + if (!overwriteConfirmation.opened) + overwriteConfirmation.open() + } else if (overwriteConfirmation.opened) { + overwriteConfirmation.close() + } + } + } + + Dialog { + id: overwriteConfirmation + + objectName: "deviceMediaOverwriteConfirmation" + title: qsTr("Replace exported file?") + modal: true + anchors.centerIn: parent + width: Math.min(520, root.width - 48) + standardButtons: Dialog.Yes | Dialog.Cancel + + contentItem: Label { + text: qsTr("“%1” already exists. Replace it with the device copy?") + .arg(root.deviceMedia.overwriteFileName) + color: "#f4f6f7" + wrapMode: Text.WordWrap + } + + onAccepted: root.deviceMedia.confirmOverwrite() + onRejected: { + if (root.deviceMedia.overwriteConfirmationPending) + root.deviceMedia.cancelOverwrite() + } + } + + Dialog { + id: deleteConfirmation + + objectName: "deleteConfirmation" + title: qsTr("Delete media") + modal: true + anchors.centerIn: parent + width: Math.min(520, root.width - 48) + standardButtons: Dialog.Yes | Dialog.Cancel + + contentItem: Label { + text: qsTr("Delete “%1” from the device? This cannot be undone.") + .arg(root.pendingDeleteName) + color: "#f4f6f7" + wrapMode: Text.WordWrap + } + + onAccepted: { + const target = root.pendingDeleteName + if (target.length > 0) { + root.runtime.deleteMedia([target]) + root.selectedMedia = + root.selectedMedia.filter( + name => name !== target) + } + root.pendingDeleteName = "" + } + onRejected: root.pendingDeleteName = "" + } +} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml new file mode 100644 index 0000000..3b1643c --- /dev/null +++ b/qml/pages/SettingsPage.qml @@ -0,0 +1,432 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "../components" + +ScrollView { + id: root + + required property var runtime + required property var settings + required property var firmware + + clip: true + + readonly property var languages: [ + {"code": "en", "label": qsTr("English")}, + {"code": "ru", "label": qsTr("Russian")}, + {"code": "system", "label": qsTr("System language")} + ] + + ColumnLayout { + objectName: "settingsContent" + x: 28 + y: 24 + width: Math.max(0, root.availableWidth - 56) + spacing: 16 + + Frame { + Layout.fillWidth: true + padding: 22 + + background: Rectangle { + radius: 12 + color: "#23282d" + border.width: 1 + border.color: "#343b42" + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + text: qsTr("General") + color: "#f4f6f7" + font.pixelSize: 18 + font.bold: true + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: "#343b42" + } + + RowLayout { + Layout.fillWidth: true + spacing: 18 + + ColumnLayout { + Layout.fillWidth: true + Label { + text: qsTr("Application language") + color: "#f4f6f7" + font.bold: true + } + Label { + text: qsTr("Changes are applied immediately.") + color: "#9ca4ac" + } + } + + ComboBox { + objectName: "languageCombo" + Layout.preferredWidth: 220 + model: root.languages + textRole: "label" + currentIndex: Math.max( + 0, + root.languages.findIndex( + item => item.code === + root.settings.language)) + onActivated: index => { + root.settings.setLanguage( + root.languages[index].code) + } + } + } + } + } + + FirmwarePanel { + objectName: "firmwarePanel" + Layout.fillWidth: true + controller: root.firmware + } + + Frame { + Layout.fillWidth: true + padding: 22 + + background: Rectangle { + radius: 12 + color: "#23282d" + border.width: 1 + border.color: "#343b42" + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + text: qsTr("Startup") + color: "#f4f6f7" + font.pixelSize: 18 + font.bold: true + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: "#343b42" + } + + RowLayout { + Layout.fillWidth: true + spacing: 18 + + ColumnLayout { + Layout.fillWidth: true + Label { + text: qsTr("Start the background service when you sign in") + color: "#f4f6f7" + font.bold: true + } + Label { + text: root.settings.busy + ? qsTr("Updating autostart…") + : (root.settings.autostartAvailable + ? qsTr("Managed by your user systemd session") + : qsTr("Autostart state is unavailable")) + color: "#9ca4ac" + } + } + + Switch { + objectName: "autostartSwitch" + enabled: !root.settings.busy + text: root.settings.autostartEnabled + ? qsTr("On") : qsTr("Off") + onToggled: root.settings.setAutostartEnabled( + checked) + + Binding on checked { + value: root.settings.autostartEnabled + } + } + } + } + } + + Frame { + Layout.fillWidth: true + padding: 22 + + background: Rectangle { + radius: 12 + color: "#23282d" + border.width: 1 + border.color: "#343b42" + } + + contentItem: ColumnLayout { + spacing: 16 + + RowLayout { + Layout.fillWidth: true + Label { + text: qsTr("Device") + color: "#f4f6f7" + font.pixelSize: 18 + font.bold: true + } + Item { Layout.fillWidth: true } + Button { + text: qsTr("Refresh status") + enabled: !root.runtime.operationBusy + onClicked: { + root.runtime.refreshAll() + root.settings.refreshAutostart() + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: "#343b42" + } + + GridLayout { + Layout.fillWidth: true + columns: 3 + columnSpacing: 16 + rowSpacing: 10 + + Label { + text: qsTr("Legacy serial port") + color: "#9ca4ac" + } + + ComboBox { + id: serialPortCombo + + objectName: "serialPortCombo" + Layout.fillWidth: true + model: [qsTr("Auto")].concat( + root.settings.serialPorts) + currentIndex: { + if (root.settings.devicePort.length === 0) + return 0 + const portIndex = + root.settings.serialPorts.indexOf( + root.settings.devicePort) + return portIndex < 0 ? 0 : portIndex + 1 + } + onActivated: index => { + root.settings.setDevicePort( + index === 0 + ? "" + : root.settings.serialPorts[index - 1]) + } + } + + Button { + text: qsTr("Rescan ports") + onClicked: root.settings.refreshSerialPorts() + } + + Label { + text: qsTr("Keepalive interval") + color: "#9ca4ac" + } + + SpinBox { + id: keepaliveSpin + + objectName: "keepaliveSpin" + from: 5 + to: 60 + value: root.settings.keepaliveInterval + editable: true + textFromValue: value => qsTr("%1 s").arg(value) + valueFromText: text => { + const parsed = parseInt(text) + return isNaN(parsed) + ? root.settings.keepaliveInterval + : parsed + } + onValueModified: + root.settings.setKeepaliveInterval(value) + } + + Label { + Layout.fillWidth: true + text: qsTr("Used by legacy serial/ADB devices. PASE printer-class devices are detected automatically.") + color: "#7f8991" + wrapMode: Text.WordWrap + } + + Button { + objectName: "reconnectDeviceButton" + text: qsTr("Reconnect") + enabled: root.runtime.serviceAvailable && + !root.runtime.operationBusy + onClicked: { + root.runtime.disconnectDevice() + root.runtime.connectDevice( + root.settings.devicePort) + root.runtime.startKeepalive( + root.settings.keepaliveInterval) + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: "#343b42" + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 24 + rowSpacing: 12 + + Label { + text: qsTr("Background service") + color: "#9ca4ac" + } + Label { + Layout.fillWidth: true + text: root.runtime.serviceAvailable + ? qsTr("Running") + : qsTr("Not running") + color: root.runtime.serviceAvailable + ? "#66d18f" : "#efb85f" + } + + Label { + text: qsTr("USB device") + color: "#9ca4ac" + } + Label { + Layout.fillWidth: true + text: root.runtime.printerClassDevicePresent + ? qsTr("Detected") + : qsTr("Not detected") + color: root.runtime.printerClassDevicePresent + ? "#66d18f" : "#efb85f" + } + + Label { + text: qsTr("Transport") + color: "#9ca4ac" + } + Label { + Layout.fillWidth: true + text: root.runtime.printerClassDevicePresent + ? qsTr("PASE printer class") + : (root.runtime.legacyConnected + ? qsTr("Legacy serial / ADB") + : qsTr("Waiting for device")) + color: root.runtime.printerClassDevicePresent || + root.runtime.legacyConnected + ? "#66d18f" : "#efb85f" + } + + Label { + text: qsTr("Display session") + color: "#9ca4ac" + } + Label { + Layout.fillWidth: true + text: root.runtime.displaySessionActive + ? qsTr("Ready") + : qsTr("Waiting") + color: root.runtime.displaySessionActive + ? "#66d18f" : "#efb85f" + } + } + + Label { + Layout.fillWidth: true + visible: root.runtime.diagnostic.length > 0 + text: root.runtime.diagnostic + color: "#9ca4ac" + wrapMode: Text.WordWrap + } + } + } + + Frame { + Layout.fillWidth: true + padding: 22 + + background: Rectangle { + radius: 12 + color: "#23282d" + border.width: 1 + border.color: "#343b42" + } + + contentItem: Item { + implicitHeight: Math.max( + aboutDetails.implicitHeight, + openGitHubButton.implicitHeight) + + ColumnLayout { + id: aboutDetails + + anchors.left: parent.left + anchors.right: openGitHubButton.left + anchors.rightMargin: 18 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Label { + text: qsTr("About") + color: "#f4f6f7" + font.pixelSize: 18 + font.bold: true + } + Label { + text: qsTr("TRYX Panorama Manager %1") + .arg(Qt.application.version) + color: "#9ca4ac" + } + Label { + text: qsTr("Open-source Linux control application") + color: "#9ca4ac" + } + } + + Button { + id: openGitHubButton + + objectName: "openGitHubButton" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Open GitHub") + onClicked: Qt.openUrlExternally( + "https://github.com/DXVSI/Tryx-Linux-GUI") + } + } + } + + Label { + Layout.fillWidth: true + visible: root.settings.errorMessage.length > 0 + text: root.settings.errorMessage + color: "#ef6b73" + wrapMode: Text.WordWrap + } + + Item { + Layout.fillHeight: true + Layout.minimumHeight: 20 + } + } +} diff --git a/resources/quick.qrc b/resources/quick.qrc new file mode 100644 index 0000000..4c1f94a --- /dev/null +++ b/resources/quick.qrc @@ -0,0 +1,19 @@ + + + ../qml/Main.qml + ../qml/components/MediaEditor.qml + ../qml/components/MediaExportPicker.qml + ../qml/components/MediaFilePicker.qml + ../qml/components/FirmwareFilePicker.qml + ../qml/components/FirmwarePanel.qml + ../qml/components/MetricCard.qml + ../qml/components/NavButton.qml + ../qml/components/OperationBanner.qml + ../qml/components/PrimaryButton.qml + ../qml/components/WindowResizeHandle.qml + ../qml/pages/HomePage.qml + ../qml/pages/PanoramaPage.qml + ../qml/pages/SettingsPage.qml + tryx-panorama.png + + diff --git a/resources/resources.qrc b/resources/resources.qrc deleted file mode 100644 index 97230be..0000000 --- a/resources/resources.qrc +++ /dev/null @@ -1,5 +0,0 @@ - - - tryx-panorama.png - - diff --git a/src/applicationpaths.h b/src/applicationpaths.h new file mode 100644 index 0000000..eaf3a06 --- /dev/null +++ b/src/applicationpaths.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include + +namespace panorama { + +inline QString sharedApplicationDataLocation() { + // Keep persistent data compatible with releases where the device runtime + // lived inside TRYX Panorama Manager. The standalone runtime has its own + // application identity, so AppLocalDataLocation cannot be shared safely. + return QDir(QStandardPaths::writableLocation( + QStandardPaths::GenericDataLocation)) + .filePath(QStringLiteral( + "DXVSI/TRYX Panorama Manager")); +} + +} // namespace panorama diff --git a/src/devicemanager.cpp b/src/devicemanager.cpp index 4ebe0a5..624b90d 100644 --- a/src/devicemanager.cpp +++ b/src/devicemanager.cpp @@ -1,6 +1,7 @@ #include "devicemanager.h" +#include "applicationpaths.h" #include "printerprotocol.h" -#include "hudrenderer.h" +#include "mediatransform.h" #include "systemmonitor.h" #include #include @@ -25,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -35,8 +37,10 @@ #include #include #include +#include #include #include +#include #include // --- DeviceWorker --- @@ -44,6 +48,8 @@ namespace { constexpr int kMaxPrinterKeepaliveWriteRetries = 3; +constexpr int kPaseDisplayWidth = 2240; +constexpr int kPaseDisplayHeight = 1080; constexpr int kPrinterKeepaliveRetryBackoffMs = 500; constexpr int kMaxTerminalOperationHistory = 32; constexpr int kRetryCacheFormatVersion = 9; @@ -60,6 +66,364 @@ constexpr qint64 kMaxSourceMediaBytes = 8LL * 1024LL * 1024LL * 1024LL; constexpr int kMediaPreparationDeadlineMs = 15 * 60 * 1000; constexpr int kThumbnailPreparationDeadlineMs = 2 * 60 * 1000; constexpr qint64 kFileTransmitChunkSize = 0x40000; +constexpr qint64 kMediaInboxMaxAgeSeconds = 24LL * 60LL * 60LL; +constexpr qint64 kDeviceMediaUnclaimedTtlMs = 5LL * 60LL * 1000LL; +constexpr qint64 kDeviceMediaClaimLeaseMs = 2LL * 60LL * 1000LL; +constexpr int kDeviceMediaSweepIntervalMs = 5000; +constexpr qint64 kRecoveredMediaValidationDeadlineMs = + 15LL * 60LL * 1000LL; +constexpr qint64 kRecoveredMediaFreeSpaceReserveBytes = + 16LL * 1024LL * 1024LL; + +QString cleanAbsolutePath(const QString &path) { + return QDir::cleanPath(QFileInfo(path).absoluteFilePath()); +} + +bool pathIsInside(const QString &path, const QString &directory) { + if (path.isEmpty() || directory.isEmpty()) { + return false; + } + const QString cleanPath = cleanAbsolutePath(path); + const QString cleanDirectory = cleanAbsolutePath(directory); + return cleanPath == cleanDirectory || + cleanPath.startsWith(cleanDirectory + QLatin1Char('/')); +} + +bool stagedSourceStatIsValid(const struct stat &status) { + return S_ISREG(status.st_mode) && status.st_uid == ::geteuid() && + (status.st_mode & 07777) == (S_IRUSR | S_IWUSR) && + status.st_nlink == 1 && status.st_size > 0 && + status.st_size <= kMaxSourceMediaBytes; +} + +bool privateDirectoryStatIsValid(const struct stat &status) { + return S_ISDIR(status.st_mode) && status.st_uid == ::geteuid() && + (status.st_mode & 07777) == S_IRWXU; +} + +bool ensurePrivateDirectory(const QString &path, bool create, + QString *errorMessage) { + if (path.isEmpty()) { + if (errorMessage) { + *errorMessage = + QObject::tr("The private runtime directory path is empty"); + } + return false; + } + + const QByteArray encoded = QFile::encodeName(path); + struct stat status {}; + if (::lstat(encoded.constData(), &status) != 0) { + if (errno != ENOENT || !create) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Cannot inspect private runtime directory %1: %2") + .arg(path, QString::fromLocal8Bit(std::strerror(errno))); + } + return false; + } + if (::mkdir(encoded.constData(), S_IRWXU) != 0 && + errno != EEXIST) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Cannot create private runtime directory %1: %2") + .arg(path, QString::fromLocal8Bit(std::strerror(errno))); + } + return false; + } + if (::lstat(encoded.constData(), &status) != 0) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Cannot verify private runtime directory %1: %2") + .arg(path, QString::fromLocal8Bit(std::strerror(errno))); + } + return false; + } + } + if (!privateDirectoryStatIsValid(status)) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Private runtime directory %1 must be a direct owner-only 0700 directory") + .arg(path); + } + return false; + } + return true; +} + +bool stagedSourceFileNameIsValid(const QString &fileName) { + const QFileInfo info(fileName); + if (info.fileName() != fileName || info.completeBaseName().isEmpty() || + info.suffix().isEmpty() || + info.suffix() != info.suffix().toLower()) { + return false; + } + static const QSet supportedSuffixes{ + QStringLiteral("mp4"), QStringLiteral("webm"), + QStringLiteral("mkv"), QStringLiteral("avi"), + QStringLiteral("mov"), QStringLiteral("gif"), + QStringLiteral("jpg"), QStringLiteral("jpeg"), + QStringLiteral("png"), QStringLiteral("bmp"), + QStringLiteral("webp"), + }; + if (!supportedSuffixes.contains(info.suffix())) { + return false; + } + const QUuid parsed(info.completeBaseName()); + return !parsed.isNull() && + parsed.toString(QUuid::WithoutBraces) == + info.completeBaseName(); +} + +bool atomicRenameNoReplace(const QString &sourcePath, + const QString &destinationPath, + QString *errorMessage) { + const QByteArray source = QFile::encodeName(sourcePath); + const QByteArray destination = QFile::encodeName(destinationPath); + if (::syscall(SYS_renameat2, AT_FDCWD, source.constData(), + AT_FDCWD, destination.constData(), + RENAME_NOREPLACE) == 0) { + return true; + } + if (errorMessage) { + *errorMessage = errno == EXDEV + ? QObject::tr( + "The staged source and daemon spool are not on the same filesystem") + : QObject::tr("Cannot claim staged media source: %1") + .arg(QString::fromLocal8Bit(std::strerror(errno))); + } + return false; +} + +QString sha256File( + const QString &path, + const std::function &isCancelled); + +bool runBoundedMediaValidationProcess( + const QString &program, const QStringList &arguments, + const std::function &isCancelled, + QByteArray *output, bool *cancelled, + QString *errorMessage) { + if (cancelled) { + *cancelled = false; + } + QProcess process; + process.setProcessChannelMode(QProcess::MergedChannels); + process.setProgram(program); + process.setArguments(arguments); + process.start(); + if (!process.waitForStarted(5000)) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Cannot start recovered media validation tool %1: %2") + .arg(program, process.errorString()); + } + return false; + } + QDeadlineTimer deadline(kRecoveredMediaValidationDeadlineMs); + QByteArray diagnostic; + while (process.state() != QProcess::NotRunning) { + if (isCancelled && isCancelled()) { + process.kill(); + process.waitForFinished(3000); + if (cancelled) { + *cancelled = true; + } + return false; + } + if (deadline.hasExpired()) { + process.kill(); + process.waitForFinished(3000); + if (errorMessage) { + *errorMessage = QObject::tr( + "Recovered media validation exceeded its bounded deadline"); + } + return false; + } + process.waitForFinished(100); + diagnostic.append(process.readAll()); + constexpr qsizetype kMaximumDiagnosticBytes = 16 * 1024; + if (diagnostic.size() > kMaximumDiagnosticBytes) { + diagnostic = diagnostic.right(kMaximumDiagnosticBytes); + } + } + diagnostic.append(process.readAll()); + if (output) { + *output = diagnostic; + } + if (process.exitStatus() != QProcess::NormalExit || + process.exitCode() != 0) { + if (errorMessage) { + QString detail = + QString::fromLocal8Bit(diagnostic).trimmed(); + if (detail.size() > 1000) { + detail = detail.right(1000); + } + *errorMessage = detail.isEmpty() + ? QObject::tr("Recovered media validation failed") + : QObject::tr("Recovered media validation failed: %1") + .arg(detail); + } + return false; + } + return true; +} + +bool recoveredH264HasRequiredNalUnits( + const QString &path, const std::function &isCancelled, + bool *cancelled, QString *errorMessage) { + if (cancelled) { + *cancelled = false; + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Cannot open the recovered H264 stream: %1") + .arg(file.errorString()); + } + return false; + } + bool hasSps = false; + bool hasPps = false; + bool hasVcl = false; + QByteArray pending; + while (!file.atEnd() && !(hasSps && hasPps && hasVcl)) { + if (isCancelled && isCancelled()) { + if (cancelled) { + *cancelled = true; + } + return false; + } + QByteArray bytes = pending; + bytes.append(file.read(256 * 1024)); + if (bytes.isEmpty() && file.error() != QFileDevice::NoError) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Cannot inspect the recovered H264 stream: %1") + .arg(file.errorString()); + } + return false; + } + for (qsizetype index = 0; index + 4 < bytes.size(); ++index) { + qsizetype headerIndex = -1; + if (bytes.at(index) == '\0' && + bytes.at(index + 1) == '\0' && + bytes.at(index + 2) == '\1') { + headerIndex = index + 3; + } else if (index + 5 < bytes.size() && + bytes.at(index) == '\0' && + bytes.at(index + 1) == '\0' && + bytes.at(index + 2) == '\0' && + bytes.at(index + 3) == '\1') { + headerIndex = index + 4; + } + if (headerIndex < 0 || headerIndex >= bytes.size()) { + continue; + } + const int nalType = + static_cast(bytes.at(headerIndex)) & 0x1f; + hasSps = hasSps || nalType == 7; + hasPps = hasPps || nalType == 8; + hasVcl = hasVcl || (nalType >= 1 && nalType <= 5); + } + pending = bytes.right(qMin(5, bytes.size())); + } + if (!(hasSps && hasPps && hasVcl)) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Recovered device media is not a complete Annex B H264 stream"); + } + return false; + } + return true; +} + +bool validateRecoveredH264( + const QString &path, qint64 expectedSize, + const QString &expectedSha256, + const std::function &isCancelled, + bool *cancelled, QString *errorMessage) { + if (cancelled) { + *cancelled = false; + } + const QFileInfo info(path); + if (!info.exists() || !info.isFile() || info.isSymLink() || + info.size() != expectedSize || expectedSize <= 0) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Recovered device media does not match the validated file size"); + } + return false; + } + if (!recoveredH264HasRequiredNalUnits( + path, isCancelled, cancelled, errorMessage)) { + return false; + } + if (isCancelled && isCancelled()) { + if (cancelled) { + *cancelled = true; + } + return false; + } + const QString actualSha256 = sha256File(path, isCancelled); + if (actualSha256.isEmpty() || actualSha256 != expectedSha256) { + if (isCancelled && isCancelled()) { + if (cancelled) { + *cancelled = true; + } + } else if (errorMessage) { + *errorMessage = QObject::tr( + "Recovered device media hash changed before validation"); + } + return false; + } + + const QString ffprobe = + QStandardPaths::findExecutable(QStringLiteral("ffprobe")); + const QString ffmpeg = + QStandardPaths::findExecutable(QStringLiteral("ffmpeg")); + if (ffprobe.isEmpty() || ffmpeg.isEmpty()) { + if (errorMessage) { + *errorMessage = QObject::tr( + "ffprobe and ffmpeg are required to validate recovered device media"); + } + return false; + } + QByteArray probeOutput; + if (!runBoundedMediaValidationProcess( + ffprobe, + {QStringLiteral("-v"), QStringLiteral("error"), + QStringLiteral("-f"), QStringLiteral("h264"), + QStringLiteral("-select_streams"), QStringLiteral("v:0"), + QStringLiteral("-show_entries"), + QStringLiteral("stream=codec_name,width,height"), + QStringLiteral("-of"), + QStringLiteral("default=noprint_wrappers=1"), + path}, + isCancelled, &probeOutput, cancelled, errorMessage)) { + return false; + } + const QString probe = + QString::fromLocal8Bit(probeOutput); + if (!probe.contains(QStringLiteral("codec_name=h264")) || + !probe.contains(QStringLiteral("width=2240")) || + !probe.contains(QStringLiteral("height=1080"))) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Recovered device media is not H264 at 2240x1080"); + } + return false; + } + return runBoundedMediaValidationProcess( + ffmpeg, + {QStringLiteral("-v"), QStringLiteral("error"), + QStringLiteral("-f"), QStringLiteral("h264"), + QStringLiteral("-i"), path, QStringLiteral("-map"), + QStringLiteral("0:v:0"), QStringLiteral("-f"), + QStringLiteral("null"), QStringLiteral("-")}, + isCancelled, nullptr, cancelled, errorMessage); +} struct PrinterProcessClock { PrinterProcessClock() { @@ -292,7 +656,14 @@ QString sourceFingerprint(const QString &path) { QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex()); } -QString printerConversionProfile(const QString &path) { +QString printerConversionProfile( + const QString &path, + const TryxRuntimeMediaTransform &transform) { + const QString transformFingerprint = + tryxMediaTransformFingerprint(transform); + if (transformFingerprint.isEmpty()) { + return {}; + } QString typeName; switch (panorama::Media::detect_type(path.toStdString())) { case panorama::MediaType::Image: @@ -307,9 +678,13 @@ QString printerConversionProfile(const QString &path) { case panorama::MediaType::Unknown: return {}; } - return QStringLiteral( - "pase-h264-v1-%1-2240x1080-yuv420p-30fps-libx264-veryfast-crf23") - .arg(typeName); + const QString base = QStringLiteral( + "pase-h264-%1-%2-2240x1080-yuv420p-30fps-libx264-veryfast-crf23"); + if (tryxMediaTransformIsLegacyFit(transform)) { + return base.arg(QStringLiteral("v1"), typeName); + } + return base.arg(QStringLiteral("v2"), typeName) + + QStringLiteral("-transform-") + transformFingerprint; } QString mutationOutcomeName(PrinterProtocol::MutationOutcome outcome) { @@ -335,8 +710,8 @@ QString mutationOutcomeName(PrinterProtocol::MutationOutcome outcome) { QString h264PrinterName(const QString &baseName) { return QStringLiteral("%1.h264_%2x%3") .arg(baseName) - .arg(HudRenderer::DISPLAY_WIDTH) - .arg(HudRenderer::DISPLAY_HEIGHT); + .arg(kPaseDisplayWidth) + .arg(kPaseDisplayHeight); } QString printerPresetMediaFile(const QString &presetId) { @@ -616,6 +991,47 @@ QJsonObject runtimeApplyRequestToJson( return object; } +QString runtimeApplyRequestFingerprint( + const TryxRuntimeApplyRequest &request) { + const QByteArray canonical = + QJsonDocument(runtimeApplyRequestToJson(request)) + .toJson(QJsonDocument::Compact); + return QString::fromLatin1( + QCryptographicHash::hash(canonical, + QCryptographicHash::Sha256) + .toHex()); +} + +QString runtimeMediaTransformRequestFingerprint( + const TryxRuntimeMediaTransform &transform) { + QJsonObject object; + object.insert(QStringLiteral("schemaVersion"), + static_cast( + transform.schemaVersion)); + object.insert(QStringLiteral("mode"), + transform.mode); + object.insert(QStringLiteral("rotationQuarterTurns"), + static_cast( + transform.rotationQuarterTurns)); + object.insert(QStringLiteral("zoomPermille"), + static_cast( + transform.zoomPermille)); + object.insert(QStringLiteral("focusX"), + static_cast(transform.focusX)); + object.insert(QStringLiteral("focusY"), + static_cast(transform.focusY)); + object.insert(QStringLiteral("backgroundRgb"), + static_cast( + transform.backgroundRgb)); + const QByteArray canonical = + QJsonDocument(object).toJson( + QJsonDocument::Compact); + return QString::fromLatin1( + QCryptographicHash::hash( + canonical, QCryptographicHash::Sha256) + .toHex()); +} + bool runtimeApplyRequestFromJson( const QJsonObject &object, TryxRuntimeApplyRequest *request, bool requireBacklightFields) { @@ -931,7 +1347,8 @@ PrinterMediaPreparer::~PrinterMediaPreparer() { void PrinterMediaPreparer::analyzeSource( const QString &operationId, const QString &localPath, - quint64 generation) { + quint64 generation, + const TryxRuntimeMediaTransform &transform) { const auto isCancelled = [this, operationId, generation]() { const quint64 gate = preparationGenerationGate_.load( std::memory_order_acquire); @@ -941,9 +1358,12 @@ void PrinterMediaPreparer::analyzeSource( QMutexLocker locker(&preparationCancellationMutex_); return cancelledPreparationOperations_.contains(operationId); }; - const QString profile = printerConversionProfile(localPath); + const QString profile = printerConversionProfile(localPath, transform); if (profile.isEmpty()) { - emit failed(operationId, tr("Unsupported media file type"), + emit failed(operationId, + tryxMediaTransformIsValid(transform) + ? tr("Unsupported media file type") + : tr("Media transform is invalid"), generation); return; } @@ -1004,7 +1424,32 @@ void PrinterMediaPreparer::prepare(const QString &operationId, const QString &devicePath, const QString &localPath, const QString &expectedSourceSha256, - quint64 generation) { + quint64 generation, + const TryxRuntimeMediaTransform &transform) { + if (shuttingDown_) { + return; + } + if (active_) { + pendingOperationId_ = operationId; + pendingDevicePath_ = devicePath; + pendingLocalPath_ = localPath; + pendingExpectedSourceSha256_ = expectedSourceSha256; + pendingTransform_ = transform; + pendingRecoveredVideo_ = false; + pendingGeneration_ = generation; + hasPending_ = true; + cancelling_ = true; + process_->kill(); + return; + } + startPreparation(operationId, devicePath, localPath, + expectedSourceSha256, generation, transform, false); +} + +void PrinterMediaPreparer::prepareRecovered( + const QString &operationId, const QString &devicePath, + const QString &localPath, const QString &expectedSourceSha256, + quint64 generation, const TryxRuntimeMediaTransform &transform) { if (shuttingDown_) { return; } @@ -1013,6 +1458,8 @@ void PrinterMediaPreparer::prepare(const QString &operationId, pendingDevicePath_ = devicePath; pendingLocalPath_ = localPath; pendingExpectedSourceSha256_ = expectedSourceSha256; + pendingTransform_ = transform; + pendingRecoveredVideo_ = true; pendingGeneration_ = generation; hasPending_ = true; cancelling_ = true; @@ -1020,14 +1467,16 @@ void PrinterMediaPreparer::prepare(const QString &operationId, return; } startPreparation(operationId, devicePath, localPath, - expectedSourceSha256, generation); + expectedSourceSha256, generation, transform, true); } void PrinterMediaPreparer::startPreparation(const QString &operationId, const QString &devicePath, const QString &localPath, const QString &expectedSourceSha256, - quint64 generation) { + quint64 generation, + const TryxRuntimeMediaTransform &transform, + bool recoveredVideo) { const auto isCancelled = [this, operationId, generation]() { const quint64 gate = preparationGenerationGate_.load( std::memory_order_acquire); @@ -1059,8 +1508,18 @@ void PrinterMediaPreparer::startPreparation(const QString &operationId, generation); return; } + QString transformError; + if (!tryxMediaTransformIsValid(transform, &transformError)) { + emit failed(operationId, + tr("Media transform is invalid: %1") + .arg(transformError), + generation); + return; + } - const auto type = panorama::Media::detect_type(localPath.toStdString()); + const auto type = recoveredVideo + ? panorama::MediaType::Video + : panorama::Media::detect_type(localPath.toStdString()); QString baseExtension; switch (type) { case panorama::MediaType::Image: @@ -1100,13 +1559,17 @@ void PrinterMediaPreparer::startPreparation(const QString &operationId, arguments << QStringLiteral("-loop") << QStringLiteral("1") << QStringLiteral("-framerate") << QStringLiteral("30") << QStringLiteral("-t") << QStringLiteral("60"); + } else if (recoveredVideo) { + arguments << QStringLiteral("-f") << QStringLiteral("h264") + << QStringLiteral("-framerate") << QStringLiteral("30"); + } + const QString filter = tryxMediaTransformFfmpegFilter( + transform, kPaseDisplayWidth, kPaseDisplayHeight); + if (filter.isEmpty()) { + emit failed(operationId, tr("Media transform filter is invalid"), + generation); + return; } - const QString width = QString::number(HudRenderer::DISPLAY_WIDTH); - const QString height = QString::number(HudRenderer::DISPLAY_HEIGHT); - const QString filter = QStringLiteral( - "scale=%1:%2:force_original_aspect_ratio=decrease," - "pad=%1:%2:(ow-iw)/2:(oh-ih)/2:color=black," - "setsar=1,format=yuv420p,fps=30").arg(width, height); arguments << QStringLiteral("-i") << localPath << QStringLiteral("-c:v") << QStringLiteral("libx264") << QStringLiteral("-preset") << QStringLiteral("veryfast") @@ -1127,6 +1590,8 @@ void PrinterMediaPreparer::startPreparation(const QString &operationId, stagedThumbnailPath_ = stagedThumbnailPath; preparedSha256_.clear(); expectedSourceSha256_ = expectedSourceSha256; + transform_ = transform; + recoveredVideo_ = recoveredVideo; generation_ = generation; processOutput_.clear(); cancelling_ = false; @@ -1353,6 +1818,8 @@ void PrinterMediaPreparer::resetPreparationState() { stagedThumbnailPath_.clear(); preparedSha256_.clear(); expectedSourceSha256_.clear(); + transform_ = tryxLegacyFitMediaTransform(); + recoveredVideo_ = false; generation_ = 0; processOutput_.clear(); if (!completedOperationId.isEmpty()) { @@ -1371,15 +1838,20 @@ void PrinterMediaPreparer::startPendingIfAvailable() { const QString localPath = pendingLocalPath_; const QString expectedSourceSha256 = pendingExpectedSourceSha256_; + const TryxRuntimeMediaTransform transform = pendingTransform_; + const bool recoveredVideo = pendingRecoveredVideo_; const quint64 generation = pendingGeneration_; hasPending_ = false; pendingOperationId_.clear(); pendingDevicePath_.clear(); pendingLocalPath_.clear(); pendingExpectedSourceSha256_.clear(); + pendingTransform_ = tryxLegacyFitMediaTransform(); + pendingRecoveredVideo_ = false; pendingGeneration_ = 0; startPreparation(operationId, devicePath, localPath, - expectedSourceSha256, generation); + expectedSourceSha256, generation, transform, + recoveredVideo); } void PrinterMediaPreparer::cancelStale(quint64 currentGeneration) { @@ -1390,6 +1862,8 @@ void PrinterMediaPreparer::cancelStale(quint64 currentGeneration) { pendingDevicePath_.clear(); pendingLocalPath_.clear(); pendingExpectedSourceSha256_.clear(); + pendingTransform_ = tryxLegacyFitMediaTransform(); + pendingRecoveredVideo_ = false; pendingGeneration_ = 0; } if (active_ && generation_ != currentGeneration) { @@ -1407,6 +1881,8 @@ void PrinterMediaPreparer::cancelOperation(const QString &operationId) { pendingDevicePath_.clear(); pendingLocalPath_.clear(); pendingExpectedSourceSha256_.clear(); + pendingTransform_ = tryxLegacyFitMediaTransform(); + pendingRecoveredVideo_ = false; pendingGeneration_ = 0; operationRemovedBeforeStart = true; } @@ -1475,6 +1951,8 @@ void PrinterMediaPreparer::shutdown() { pendingDevicePath_.clear(); pendingLocalPath_.clear(); pendingExpectedSourceSha256_.clear(); + pendingTransform_ = tryxLegacyFitMediaTransform(); + pendingRecoveredVideo_ = false; pendingGeneration_ = 0; if (active_) { cancelling_ = true; @@ -1502,6 +1980,7 @@ void PrinterMediaPreparer::shutdown() { DeviceWorker::DeviceWorker(QObject *parent) : QObject(parent), printerProtocol_(std::make_unique()), + legacyMetricsTimer_(new QTimer(this)), printerKeepaliveTimer_(new QTimer(this)), printerMetricsTimer_(new QTimer(this)), printerRecoveryTimer_(new QTimer(this)), @@ -1509,6 +1988,9 @@ DeviceWorker::DeviceWorker(QObject *parent) printerCancellationFd_(eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC)), printerOperationCancellationFd_( eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC)) { + legacyMetricsTimer_->setInterval(1000); + connect(legacyMetricsTimer_, &QTimer::timeout, + this, &DeviceWorker::sendLegacyMetrics); printerKeepaliveTimer_->setSingleShot(true); printerKeepaliveTimer_->setInterval(2000); connect(printerKeepaliveTimer_, &QTimer::timeout, @@ -1627,6 +2109,7 @@ void DeviceWorker::connectDevice(const QString &port) { } void DeviceWorker::disconnectDevice() { + legacyMetricsTimer_->stop(); if (!device_) { return; } @@ -1653,6 +2136,9 @@ void DeviceWorker::doHandshake() { QString::fromStdString(info->firmware), QString::fromStdString(info->app_version) ); + legacyMetricsTimer_->start(); + QTimer::singleShot( + 0, this, &DeviceWorker::sendLegacyMetrics); } void DeviceWorker::setBrightness(int value) { @@ -1843,6 +2329,22 @@ void DeviceWorker::sendSysinfo(const QStringList &labels, const QStringList &val emit sysinfoSent(); } +void DeviceWorker::sendLegacyMetrics() { + if (!device_ || !device_->is_connected()) { + legacyMetricsTimer_->stop(); + return; + } + + QStringList labels; + QStringList values; + QStringList units; + collectPaseMetricValues( + printerSystemMonitor_, &labels, &values, &units); + if (!labels.isEmpty()) { + sendSysinfo(labels, values, units); + } +} + void DeviceWorker::deleteMedia(const QStringList &files) { std::vector filenames; for (const auto &f : files) { @@ -2148,6 +2650,48 @@ void DeviceWorker::clearPrinterDevice(quint64 generation) { drainAllPrinterCancellations(); } +void DeviceWorker::quiesceForFirmware(const QString &leaseId, + quint64 generation) { + // This slot is deliberately queued on the same worker thread as every + // device command. Reaching it proves that all commands accepted before the + // firmware gate have returned. The generation gate is closed synchronously + // by DeviceManager before this slot is queued, so in-flight printer-class + // transactions are interrupted and no later transaction can start. + legacyMetricsTimer_->stop(); + if (device_) { + if (device_->is_connected()) { + device_->disconnect(); + } + device_.reset(); + } + + stopPrinterSession(); + printerProtocol_ = std::make_unique(); + printerSessionRecoveryAttempt_ = 0; + printerOverlayActivationPending_ = false; + printerOverlayLeaseRefreshNext_ = false; + printerDevicePath_.clear(); + printerDeviceSerial_.clear(); + foregroundPrinterOperationId_.clear(); + printerOverlayConfig_ = {}; + configuredPrinterGeneration_ = qMax( + qMax(configuredPrinterGeneration_, generation), + printerGenerationGate_.load(std::memory_order_acquire)); + printerSessionElapsedTimer_.invalidate(); + drainAllPrinterCancellations(); + + emit firmwareTransportQuiesced(leaseId, generation); +} + +void DeviceWorker::releaseFirmwareQuiesceFence( + const QString &leaseId, quint64 generation) { + // This no-op fence shares the device worker queue with quiesce and every + // transport command. Its ACK proves that a previously queued quiesce can + // no longer run after DeviceManager resumes the transport. + emit firmwareQuiesceReleaseFenceReached( + leaseId, generation); +} + void DeviceWorker::readPrinterDeviceInfo(const QString &devicePath, quint64 generation) { PrinterProtocol::OperationContext context; @@ -2234,46 +2778,290 @@ void DeviceWorker::refreshPrinterMediaList(const QString &devicePath, emit printerMediaListReady(operationId, result.files, generation); } -void DeviceWorker::deletePrinterMedia( - const QString &devicePath, const QStringList &fileNames, - const QString &operationId, const QString &deleteIntentPath, - bool reconcileOnly, quint64 generation) { - PrinterProtocol::OperationContext initialContext; +void DeviceWorker::stagePrinterMedia( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, const QString &outputPath, + const QString &operationId, quint64 generation) { + const auto finish = + [this, &operationId, &mediaName, &outputPath, generation]( + bool success, bool cancelled, qint64 fileSize, + qint64 chunkCount, const QString &rawSha256, + const QString &decodedSha256, + const QString &errorMessage) { + emit printerMediaStaged( + operationId, mediaName, outputPath, success, cancelled, + fileSize, chunkCount, rawSha256, decodedSha256, + errorMessage, generation); + }; + + PrinterProtocol::OperationContext context; QString errorMessage; - if (!preparePrinterOperation(devicePath, generation, operationId, - &initialContext, &errorMessage)) { - emit printerDeleteFinished( - operationId, fileNames, {}, {}, false, - reconcileOnly - ? PrinterProtocol::MutationOutcome::PartialOrUnknown - : printerOperationIsCancelled(operationId) - ? PrinterProtocol::MutationOutcome::Cancelled - : PrinterProtocol::MutationOutcome::NotStarted, - errorMessage, generation); + if (!preparePrinterOperation( + devicePath, generation, operationId, + &context, &errorMessage)) { + finish(false, printerOperationIsCancelled(operationId), + 0, 0, {}, {}, errorMessage); return; } - if (!ensurePrinterSession(devicePath, generation, initialContext, - &errorMessage)) { + if (!ensurePrinterSession( + devicePath, generation, context, &errorMessage)) { schedulePrinterSessionRecovery(errorMessage, generation); - emit printerDeleteFinished( - operationId, fileNames, {}, {}, false, - reconcileOnly - ? PrinterProtocol::MutationOutcome::PartialOrUnknown - : PrinterProtocol::MutationOutcome::NotStarted, - errorMessage, generation); + finish(false, false, 0, 0, {}, {}, errorMessage); return; } + context.maintainKeepalive = true; - PrinterProtocol::OperationContext stableContext; - stableContext.cancellationFd = printerCancellationFd_; - stableContext.isCancelled = [this, generation]() { - return !printerGenerationIsCurrent(generation); - }; - stableContext.maintainKeepalive = true; + const QFileInfo outputInfo(outputPath); + const QString outputDirectory = + outputInfo.absoluteDir().absolutePath(); + if (operationId.isEmpty() || mediaName.isEmpty() || + expectedSize <= 0 || outputInfo.fileName().isEmpty() || + outputInfo.suffix() != QStringLiteral("h264") || + outputInfo.exists() || + !ensurePrivateDirectory( + outputDirectory, false, &errorMessage)) { + if (errorMessage.isEmpty()) { + errorMessage = tr( + "Recovered media output path is not an unused private H264 artifact"); + } + finish(false, false, 0, 0, {}, {}, errorMessage); + return; + } - QStringList confirmedDeleted; - const auto persistIntent = - [this, &deleteIntentPath, &operationId, &fileNames, + QStorageInfo storage(outputDirectory); + storage.refresh(); + const qint64 requiredBytes = + expectedSize + + kRecoveredMediaFreeSpaceReserveBytes; + if (!storage.isValid() || !storage.isReady() || + storage.bytesAvailable() < requiredBytes) { + finish( + false, false, 0, 0, {}, {}, + tr("There is not enough free space to stage this device media copy")); + return; + } + + const QString partialPath = + outputPath + QStringLiteral(".part-") + + QUuid::createUuid().toString(QUuid::WithoutBraces); + QFile partial(partialPath); + if (!partial.open(QIODevice::WriteOnly | QIODevice::NewOnly) || + ::fchmod(partial.handle(), S_IRUSR | S_IWUSR) != 0) { + errorMessage = tr("Cannot create the private recovered media artifact: %1") + .arg(partial.errorString()); + partial.close(); + QFile::remove(partialPath); + finish(false, false, 0, 0, {}, {}, errorMessage); + return; + } + + const auto isCancelled = [this, operationId, generation]() { + return !printerGenerationIsCurrent(generation) || + printerOperationIsCancelled(operationId); + }; + const auto sink = + [&partial](qint64 offset, const QByteArray &decodedChunk, + QString *sinkError) { + if (offset < 0 || partial.pos() != offset || + decodedChunk.isEmpty()) { + if (sinkError) { + *sinkError = QObject::tr( + "Recovered media chunks are not sequential"); + } + return false; + } + const qint64 written = partial.write(decodedChunk); + if (written != decodedChunk.size()) { + if (sinkError) { + *sinkError = QObject::tr( + "Cannot write the recovered media artifact: %1") + .arg(partial.errorString()); + } + return false; + } + return true; + }; + const auto progress = + [this, operationId, generation]( + qint64 bytesDecoded, qint64 totalBytes) { + emit printerForegroundProgress( + operationId, QStringLiteral("PullingDeviceMedia"), + bytesDecoded, totalBytes, + tr("Reading and decoding the device media copy..."), + generation); + }; + + const PrinterProtocol::MediaPullResult result = + printerProtocol_->pullUserMedia( + devicePath, mediaName, expectedSize, + sink, progress, context); + if (!result.success) { + partial.close(); + QFile::remove(partialPath); + if (!result.cancelled && + printerProtocol_->persistentUsbInputFailure()) { + schedulePrinterSessionRecovery(result.error, generation); + } + finish(false, result.cancelled, result.fileSize, + result.chunkCount, result.rawSha256, + result.decodedSha256, result.error); + return; + } + if (!partial.flush() || ::fsync(partial.handle()) != 0) { + errorMessage = tr( + "Cannot commit recovered media bytes to local storage"); + partial.close(); + QFile::remove(partialPath); + finish(false, false, result.fileSize, result.chunkCount, + result.rawSha256, result.decodedSha256, errorMessage); + return; + } + partial.close(); + + bool validationCancelled = false; + if (!validateRecoveredH264( + partialPath, result.fileSize, result.decodedSha256, + isCancelled, &validationCancelled, &errorMessage)) { + QFile::remove(partialPath); + finish(false, validationCancelled, result.fileSize, + result.chunkCount, result.rawSha256, + result.decodedSha256, errorMessage); + return; + } + + const QByteArray source = QFile::encodeName(partialPath); + const QByteArray destination = QFile::encodeName(outputPath); + if (::syscall( + SYS_renameat2, AT_FDCWD, source.constData(), + AT_FDCWD, destination.constData(), + RENAME_NOREPLACE) != 0) { + errorMessage = tr( + "Cannot publish the recovered media artifact atomically: %1") + .arg(QString::fromLocal8Bit(std::strerror(errno))); + QFile::remove(partialPath); + finish(false, false, result.fileSize, result.chunkCount, + result.rawSha256, result.decodedSha256, errorMessage); + return; + } + + const QByteArray encodedOutput = QFile::encodeName(outputPath); + struct stat status {}; + if (::lstat(encodedOutput.constData(), &status) != 0 || + !S_ISREG(status.st_mode) || status.st_uid != ::geteuid() || + (status.st_mode & 07777) != (S_IRUSR | S_IWUSR) || + status.st_nlink != 1 || + status.st_size != result.fileSize) { + QFile::remove(outputPath); + finish( + false, false, result.fileSize, result.chunkCount, + result.rawSha256, result.decodedSha256, + tr("Published recovered media artifact failed its final filesystem validation")); + return; + } + + restartPrinterKeepaliveAfterActivity(); + finish(true, false, result.fileSize, result.chunkCount, + result.rawSha256, result.decodedSha256, {}); +} + +void DeviceWorker::preflightReplacePrinterMedia( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, + const QString &operationId, + quint64 generation) { + PrinterProtocol::OperationContext context; + QString errorMessage; + if (!preparePrinterOperation( + devicePath, generation, operationId, + &context, &errorMessage)) { + emit printerReplacePreflightFinished( + operationId, mediaName, + expectedReplacementName, + expectedReplacementSize, + {}, {}, false, false, false, + errorMessage, generation); + return; + } + if (!ensurePrinterSession( + devicePath, generation, context, + &errorMessage)) { + schedulePrinterSessionRecovery(errorMessage, generation); + emit printerReplacePreflightFinished( + operationId, mediaName, + expectedReplacementName, + expectedReplacementSize, + {}, {}, false, false, false, + errorMessage, generation); + return; + } + context.maintainKeepalive = true; + const PrinterProtocol::MediaReferenceResult result = + printerProtocol_->readUserMediaReferences( + devicePath, mediaName, expectedSize, + expectedReplacementName, + expectedReplacementSize, context); + if (!result.success && + printerProtocol_->persistentUsbInputFailure()) { + schedulePrinterSessionRecovery( + result.error, generation); + } + restartPrinterKeepaliveAfterActivity(); + emit printerReplacePreflightFinished( + operationId, mediaName, + expectedReplacementName, + expectedReplacementSize, + result.references, result.referencingSlots, + result.originalIdentityVerified, + result.replacementIdentityVerified, + result.success, + result.error, generation); +} + +void DeviceWorker::deletePrinterMedia( + const QString &devicePath, const QStringList &fileNames, + const QString &operationId, const QString &deleteIntentPath, + bool reconcileOnly, qint64 expectedSingleSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, + quint64 generation) { + PrinterProtocol::OperationContext initialContext; + QString errorMessage; + if (!preparePrinterOperation(devicePath, generation, operationId, + &initialContext, &errorMessage)) { + emit printerDeleteFinished( + operationId, fileNames, {}, {}, false, + reconcileOnly + ? PrinterProtocol::MutationOutcome::PartialOrUnknown + : printerOperationIsCancelled(operationId) + ? PrinterProtocol::MutationOutcome::Cancelled + : PrinterProtocol::MutationOutcome::NotStarted, + errorMessage, generation); + return; + } + if (!ensurePrinterSession(devicePath, generation, initialContext, + &errorMessage)) { + schedulePrinterSessionRecovery(errorMessage, generation); + emit printerDeleteFinished( + operationId, fileNames, {}, {}, false, + reconcileOnly + ? PrinterProtocol::MutationOutcome::PartialOrUnknown + : PrinterProtocol::MutationOutcome::NotStarted, + errorMessage, generation); + return; + } + + PrinterProtocol::OperationContext stableContext; + stableContext.cancellationFd = printerCancellationFd_; + stableContext.isCancelled = [this, generation]() { + return !printerGenerationIsCurrent(generation); + }; + stableContext.maintainKeepalive = true; + + QStringList confirmedDeleted; + const auto persistIntent = + [this, &deleteIntentPath, &operationId, &fileNames, &confirmedDeleted, generation]( int index, const PrinterProtocol::MediaFile &media, QString *persistenceError) { @@ -2362,7 +3150,10 @@ void DeviceWorker::deletePrinterMedia( devicePath, fileNames, reconcileOnly ? PrinterProtocol::BeforeDeleteDispatch{} : persistIntent, - progress, stableContext, reconcileOnly); + progress, stableContext, reconcileOnly, + expectedSingleSize, + expectedReplacementName, + expectedReplacementSize); if (!result.success && result.outcome == PrinterProtocol::MutationOutcome::PartialOrUnknown) { @@ -3402,6 +4193,150 @@ void DeviceManager::setPrinterOverlayLeaseMode( Qt::QueuedConnection); } +bool DeviceManager::acquireFirmwareExclusive( + const QString &leaseId, QString *errorMessage) { + const auto fail = [errorMessage](const QString &message) { + if (errorMessage) { + *errorMessage = message; + } + return false; + }; + if (QThread::currentThread() != thread()) { + return fail(tr( + "The firmware transport gate must be acquired on the runtime thread")); + } + if (remoteMode_ || !worker_ || !workerThread_.isRunning()) { + return fail(tr( + "The local device transport is unavailable for firmware flashing")); + } + const QString normalizedLease = leaseId.trimmed(); + if (normalizedLease.isEmpty()) { + return fail(tr("The firmware transport lease is invalid")); + } + if (firmwareExclusiveActive()) { + return fail(tr( + "Another firmware operation already owns the device transport")); + } + if (!activeOperationId_.isEmpty()) { + return fail( + tr("Device operation %1 is still active") + .arg(activeOperationId_)); + } + if (!pendingRetryValidationId_.isEmpty()) { + return fail(tr( + "Stored retry media is still being validated")); + } + if (!retryCacheOperationId_.isEmpty()) { + const auto retry = + operations_.constFind(retryCacheOperationId_); + if (retry == operations_.constEnd() || + retry->requiresDeviceRecovery || + retry->uploadFinalizationReconciliationPending || + retry->info.terminalOutcome == + QStringLiteral("PartialOrUnknown") || + retry->info.terminalOutcome == + QStringLiteral("FinalizationUnknown")) { + return fail(tr( + "A previous media transfer has an unresolved device outcome; cancel or reconcile it before firmware flashing")); + } + } + if (!pendingDeleteOperationId_.isEmpty() || + QFileInfo::exists(deleteIntentPath())) { + return fail(tr( + "A previous delete command still requires read-only reconciliation")); + } + if (!pendingReplaceJournalOperationId_.isEmpty() || + QFileInfo::exists(replaceIntentPath())) { + return fail(tr( + "A previous replacement still requires read-only reconciliation")); + } + if (printerRecoveryRequired_) { + return fail(tr( + "The PASE requires physical reconnect recovery before firmware flashing")); + } + if (printerDisplaySessionLost_) { + return fail(tr( + "The PASE display session is lost; physically reconnect the device before firmware flashing")); + } + + // All public device entry points run on this thread. Publishing the lease + // before closing the worker generation gate makes the active-operation + // check and mutation exclusion one indivisible event-loop transition. + firmwareExclusiveLeaseId_ = normalizedLease; + firmwareRecoveryReconnectRequested_ = false; + firmwareResumeAutoConnect_ = autoConnectMode_; + firmwareQuiesceGeneration_ = ++printerGeneration_; + setPrinterDisplaySessionActive(false); + printerSessionResumePending_ = false; + printerSessionResumeSerial_.clear(); + stopKeepalive(); + emit requestCancelPrinterPreparation(printerGeneration_); + worker_->updatePrinterGenerationGate(printerGeneration_, false); + emit requestFirmwareTransportQuiesce( + normalizedLease, firmwareQuiesceGeneration_); + emit uploadStatus(tr( + "Device transport is reserved for firmware flashing")); + return true; +} + +void DeviceManager::releaseFirmwareExclusive( + const QString &leaseId, bool resumeTransport) { + if (QThread::currentThread() != thread() || + leaseId.trimmed().isEmpty() || + leaseId.trimmed() != firmwareExclusiveLeaseId_) { + return; + } + if (!firmwareReleasePendingLeaseId_.isEmpty()) { + if (firmwareReleasePendingLeaseId_ == + leaseId.trimmed()) { + // A later shutdown request may downgrade an already queued resume. + firmwareReleaseResumeTransport_ = + firmwareReleaseResumeTransport_ && + resumeTransport; + } + return; + } + firmwareReleasePendingLeaseId_ = + leaseId.trimmed(); + firmwareReleaseResumeTransport_ = + resumeTransport && firmwareResumeAutoConnect_; + emit requestFirmwareQuiesceReleaseFence( + firmwareReleasePendingLeaseId_, + firmwareQuiesceGeneration_); +} + +void DeviceManager:: + setFirmwareRecoveryInterlockActive( + bool active) { + firmwareRecoveryInterlockActive_ = active; + if (active) { + autoConnectMode_ = false; + stopKeepalive(); + } +} + +void DeviceManager:: + resumeConnectionAfterFirmwareRecoveryAcknowledgement() { + if (firmwareRecoveryInterlockActive_) { + emit deviceError(tr( + "Device connection remains blocked by firmware recovery")); + return; + } + if (remoteMode_) { + connectDevice(); + return; + } + if (firmwareExclusiveActive()) { + // A firmware completion publishes its recovery state before the + // worker-thread release fence necessarily returns. Preserve this + // explicit user action and reconnect only after the old transport + // queue is proven empty. + firmwareRecoveryReconnectRequested_ = true; + return; + } + connectDevice(); +} + void DeviceManager::initializeRemote() { registerTryxRuntimeMetaTypes(); @@ -3787,8 +4722,12 @@ void DeviceManager::remoteOperationCall(const QString &method, } QString operationId; if ((method == QStringLiteral("QueueUpload") || + method == QStringLiteral("QueueUploadWithTransform") || method == QStringLiteral("QueueUploadWithApply") || + method == QStringLiteral("QueueUploadWithApplyAndTransform") || method == QStringLiteral("QueueEnsureMediaAndApply") || + method == + QStringLiteral("QueueEnsureMediaAndApplyWithTransform") || method == QStringLiteral("QueueDeleteMedia") || method == QStringLiteral("QueueApply") || method == QStringLiteral("QueueApplyWithMetrics") || @@ -4214,6 +5153,22 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); + qRegisterMetaType(); + + artifactOwnerWatcher_ = new QDBusServiceWatcher(this); + artifactOwnerWatcher_->setConnection( + QDBusConnection::sessionBus()); + artifactOwnerWatcher_->setWatchMode( + QDBusServiceWatcher::WatchForUnregistration); + connect( + artifactOwnerWatcher_, + &QDBusServiceWatcher::serviceUnregistered, + this, &DeviceManager::handleArtifactOwnerUnregistered); + artifactSweepTimer_ = new QTimer(this); + artifactSweepTimer_->setInterval(kDeviceMediaSweepIntervalMs); + connect( + artifactSweepTimer_, &QTimer::timeout, + this, &DeviceManager::sweepDeviceMediaArtifacts); QString overlayLeaseConfigStatus = QStringLiteral("test-default"); @@ -4272,6 +5227,10 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, connect(worker_, &DeviceWorker::connected, this, [this](const QString &pid, const QString &serial, const QString &fw, const QString &app) { + if (firmwareExclusiveActive() || + firmwareRecoveryInterlockActive_) { + return; + } if (printerSnapshot_.blocksLegacyTransport()) { emit requestDisconnect(); return; @@ -4281,18 +5240,22 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, emit deviceConnected(pid, serial, fw, app); }); connect(worker_, &DeviceWorker::disconnected, this, [this]() { - if (printerClassConnected_) { + if (printerClassConnected_ || firmwareExclusiveActive()) { return; } connected_ = false; emit deviceDisconnected(); }); const auto legacyResultIsCurrent = [this]() { - return connected_ && !printerClassConnected_ && + return !firmwareExclusiveActive() && + !firmwareRecoveryInterlockActive_ && + connected_ && !printerClassConnected_ && !printerSnapshot_.blocksLegacyTransport(); }; connect(worker_, &DeviceWorker::error, this, [this](const QString &message) { - if (!printerSnapshot_.blocksLegacyTransport()) { + if (!firmwareExclusiveActive() && + !firmwareRecoveryInterlockActive_ && + !printerSnapshot_.blocksLegacyTransport()) { emit deviceError(message); } }); @@ -4334,7 +5297,9 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, }); const auto printerResultIsCurrent = [this](quint64 generation) { - return generation == printerGeneration_ && printerClassConnected_ && + return !firmwareExclusiveActive() && + !firmwareRecoveryInterlockActive_ && + generation == printerGeneration_ && printerClassConnected_ && printerSnapshot_.state == PrinterProtocol::DiscoveryState::Ready; }; const auto printerOperationResultIsExpected = @@ -4457,6 +5422,7 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, publishMetricsState(); } resumePendingDeleteReconciliation(); + resumePendingReplaceReconciliation(); }); connect(worker_, &DeviceWorker::printerSessionStopped, this, [this](quint64 generation) { @@ -4470,6 +5436,55 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, } } }); + connect(worker_, &DeviceWorker::firmwareTransportQuiesced, this, + [this](const QString &leaseId, quint64 generation) { + if (leaseId != firmwareExclusiveLeaseId_ || + generation != firmwareQuiesceGeneration_) { + return; + } + const bool wasConnected = connected_; + detachPrinterClassDevice(false); + connected_ = false; + setPrinterDisplaySessionActive(false); + printerSessionResumePending_ = false; + printerSessionResumeSerial_.clear(); + emit mediaListUpdated({}); + if (wasConnected) { + emit deviceDisconnected(); + } + emit firmwareTransportQuiesced( + leaseId, true, + tr("Device transports are closed for firmware flashing")); + }); + connect( + worker_, + &DeviceWorker::firmwareQuiesceReleaseFenceReached, + this, + [this](const QString &leaseId, quint64 generation) { + if (leaseId != firmwareExclusiveLeaseId_ || + leaseId != firmwareReleasePendingLeaseId_ || + generation != firmwareQuiesceGeneration_) { + return; + } + const bool reconnect = + firmwareReleaseResumeTransport_ || + firmwareRecoveryReconnectRequested_; + firmwareExclusiveLeaseId_.clear(); + firmwareReleasePendingLeaseId_.clear(); + firmwareQuiesceGeneration_ = 0; + firmwareResumeAutoConnect_ = false; + firmwareReleaseResumeTransport_ = false; + firmwareRecoveryReconnectRequested_ = false; + if (reconnect) { + connectDevice(); + } else { + // A non-resuming release is a fail-closed recovery boundary, + // not merely "do not reconnect right now". Disable passive + // monitor-triggered reconnects until the user explicitly + // starts a new connection after inspecting the device. + autoConnectMode_ = false; + } + }); connect(worker_, &DeviceWorker::printerSessionLost, this, [this, printerResultIsCurrent](quint64 generation) { if (!printerResultIsCurrent(generation)) { @@ -4527,6 +5542,8 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, record.info.state = stage == QStringLiteral("Beginning") ? QStringLiteral("Beginning") + : stage == QStringLiteral("PullingDeviceMedia") + ? QStringLiteral("Pulling") : stage == QStringLiteral("Transferring") ? QStringLiteral("Transferring") : stage == QStringLiteral("Ending") @@ -4543,113 +5560,611 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, completed > 0 ? (completed - 1) / kFileTransmitChunkSize : -1; + } else if ( + stage == QStringLiteral("PullingDeviceMedia") && + completed > record.info.confirmedBytes) { + record.info.confirmedBytes = completed; + ++record.info.lastConfirmedChunkIndex; } record.info.message = message; publishOperation(operationId); }); - connect(worker_, &DeviceWorker::printerUploadFinished, this, + connect(worker_, &DeviceWorker::printerMediaStaged, this, [this, printerOperationResultIsExpected]( - const QString &operationId, const QString &uploadPath, - const QString &remoteName, bool success, - PrinterProtocol::MutationOutcome outcome, + const QString &operationId, const QString &mediaName, + const QString &outputPath, bool success, bool cancelled, + qint64 fileSize, qint64 chunkCount, + const QString &rawSha256, + const QString &decodedSha256, const QString &errorMessage, quint64 generation) { - if (!printerOperationResultIsExpected(operationId, - generation)) { + if (!printerOperationResultIsExpected( + operationId, generation)) { + QFile::remove(outputPath); return; } OperationRecord &record = operations_[operationId]; - record.preparedPath = uploadPath; - record.remoteName = remoteName; - if (record.originalRemoteName.isEmpty()) { - record.originalRemoteName = remoteName; - } - record.info.resultName = remoteName; - if (!success) { - const QString outcomeName = mutationOutcomeName(outcome); - if (record.info.terminalOutcome.isEmpty()) { - record.info.terminalOutcome = outcomeName; - } - if (record.info.primaryErrorCategory.isEmpty()) { - record.info.primaryErrorCategory = outcomeName; - } - if (record.info.primaryErrorMessage.isEmpty()) { - record.info.primaryErrorMessage = errorMessage; - } - if (outcome == - PrinterProtocol::MutationOutcome:: - FinalizationUnknown) { - record.info.confirmedBytes = - qMax(record.info.confirmedBytes, - record.info.total); - record.info.lastConfirmedChunkIndex = - record.info.confirmedBytes > 0 - ? (record.info.confirmedBytes - 1) / - kFileTransmitChunkSize - : -1; - if (record.uploadDeviceIdentity.isEmpty()) { - record.uploadDeviceIdentity = - printerDeviceSerial_.trimmed(); - } - record.uploadFinalizationReconciliationPending = - true; - record.info.state = QStringLiteral("Refreshing"); - record.info.stage = - QStringLiteral("RecoveringFinalization"); - record.info.message = tr( - "All media data was acknowledged, but the final status was lost. Recovering the session to verify FileList without retransmission..."); - QString cacheError; - if (!writeRetryCache( - operationId, - QStringLiteral("FinalizationUnknown"), - &cacheError)) { - qWarning().noquote() - << tr("Cannot persist pending upload finalization reconciliation: %1") - .arg(cacheError); - } - publishOperation(operationId); - return; - } - if (outcome == - PrinterProtocol::MutationOutcome::PartialOrUnknown) { - record.requiresDeviceRecovery = true; - record.retryMustUseNewRemoteName = true; - requirePrinterRecovery(tr( - "The PASE transfer ended in an unknown partial state. Power-cycle the device before Retry or Save; the prepared media has been preserved.")); + const QString artifactId = record.artifactId; + auto artifact = deviceMediaArtifacts_.find(artifactId); + if (!success || artifact == deviceMediaArtifacts_.end() || + artifact->canonicalPath != outputPath || + artifact->metadata.remoteName != mediaName) { + QFile::remove(outputPath); + if (artifact != deviceMediaArtifacts_.end()) { + removeDeviceMediaArtifact(artifactId); } - handlePreparedUploadFailure(operationId, errorMessage, - outcome); - return; - } - if (record.deviceChangePending) { - handlePreparedUploadFailure( + finishOperation( operationId, - record.deviceChangeMessage.isEmpty() - ? tr("USB changed before uploaded media could be verified") - : record.deviceChangeMessage, - PrinterProtocol::MutationOutcome::PartialOrUnknown); + cancelled ? QStringLiteral("Cancelled") + : QStringLiteral("Failed"), + cancelled + ? QStringLiteral("UserCancelled") + : QStringLiteral("DeviceMediaPullFailed"), + QString(), + errorMessage.isEmpty() + ? tr("The device media copy could not be staged safely") + : errorMessage); return; } - if (record.cancelRequested) { - worker_->clearPrinterOperationCancellation(operationId); + const QByteArray encoded = + QFile::encodeName(outputPath); + struct stat status {}; + if (fileSize <= 0 || chunkCount <= 0 || + !isSha256Hex(rawSha256) || + !isSha256Hex(decodedSha256) || + ::lstat(encoded.constData(), &status) != 0 || + !S_ISREG(status.st_mode) || + status.st_uid != ::geteuid() || + (status.st_mode & 07777) != + (S_IRUSR | S_IWUSR) || + status.st_nlink != 1 || + status.st_size != fileSize || + static_cast(fileSize) != + artifact->metadata.size) { + removeDeviceMediaArtifact(artifactId); + finishOperation( + operationId, QStringLiteral("Failed"), + QStringLiteral("ArtifactValidationFailed"), + QString(), + tr("The staged device media artifact failed its final identity check")); + return; } - record.info.state = QStringLiteral("Refreshing"); - record.info.stage = QStringLiteral("RefreshingMedia"); - record.info.message = - tr("Upload acknowledged; verifying the device file list..."); - publishOperation(operationId); - emit requestPrinterRefreshMedia(currentPrinterPath(), - operationId, - printerGeneration_); + artifact->metadata.decodedSha256 = + decodedSha256; + artifact->metadata.size = + static_cast(fileSize); + artifact->metadata.localPath.clear(); + artifact->metadata.leaseId.clear(); + artifact->metadata.leaseExpiresUtcMs = 0; + artifact->deviceNumber = + static_cast(status.st_dev); + artifact->inodeNumber = + static_cast(status.st_ino); + artifact->expiresUtcMs = + QDateTime::currentMSecsSinceEpoch() + + kDeviceMediaUnclaimedTtlMs; + artifact->claimed = false; + record.info.completed = fileSize; + record.info.total = fileSize; + record.info.confirmedBytes = fileSize; + record.info.resultName = artifactId; + qInfo().noquote() + << QStringLiteral( + "device_media_artifact=%1 operation=%2 bytes=%3 chunks=%4 raw_sha256=%5 decoded_sha256=%6") + .arg(artifactId, operationId) + .arg(fileSize) + .arg(chunkCount) + .arg(rawSha256, decodedSha256); + finishOperation( + operationId, QStringLiteral("Succeeded"), + QString(), QString(), + tr("Device media copy was staged and validated")); }); - connect(worker_, &DeviceWorker::printerMediaListReady, this, - [this, printerResultIsCurrent, - printerOperationResultIsExpected](const QString &operationId, - const QList &mediaFiles, - quint64 generation) { - QStringList files; - QSet seenNames; - for (const PrinterProtocol::MediaFile &media : mediaFiles) { - if (!seenNames.contains(media.name)) { + connect( + worker_, &DeviceWorker::printerReplacePreflightFinished, + this, + [this, printerOperationResultIsExpected]( + const QString &operationId, const QString &mediaName, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, + const QStringList &references, + const QStringList &referencingSlots, + bool originalIdentityVerified, + bool replacementIdentityVerified, + bool success, + const QString &errorMessage, quint64 generation) { + if (!printerOperationResultIsExpected( + operationId, generation)) { + return; + } + OperationRecord &record = + operations_[operationId]; + if (!record.replaceOperation || + record.originalRemoteNameForReplace != + mediaName) { + finishOperation( + operationId, QStringLiteral("Failed"), + QStringLiteral("ReplacePreflightMismatch"), + QString(), + tr("The replace preflight returned a different media identity")); + return; + } + const QString currentDeviceIdentity = + printerDeviceSerial_.trimmed(); + if (generation != printerGeneration_ || + record.deviceChangePending || + currentDeviceIdentity.isEmpty() || + record.uploadDeviceIdentity.trimmed() != + currentDeviceIdentity) { + const bool mutationOutcomeUnknown = + record.replaceJournal.fileRemoveMayHaveStarted || + (record.replaceJournal.applyMayHaveStarted && + !record.replaceJournal.applyVerified); + record.info.terminalOutcome = + mutationOutcomeUnknown + ? QStringLiteral("PartialOrUnknown") + : record.replaceJournal.uploadVerified + ? QStringLiteral("NewCopyReady") + : QStringLiteral("OriginalRetained"); + finishOperation( + operationId, + mutationOutcomeUnknown + ? QStringLiteral("RetryAvailable") + : record.replaceJournal.uploadVerified + ? QStringLiteral("Succeeded") + : QStringLiteral("Failed"), + mutationOutcomeUnknown + ? QStringLiteral("PartialOrUnknown") + : record.replaceJournal.uploadVerified + ? QStringLiteral("OriginalRetained") + : QStringLiteral("DeviceChanged"), + mutationOutcomeUnknown + ? QStringLiteral("ReconcileOnly") + : QString(), + record.deviceChangeMessage.isEmpty() + ? tr("The PASE connection changed before replacement preflight could be associated with the original device") + : record.deviceChangeMessage); + return; + } + const bool reconcilingUnknownApply = + record.info.stage == + QStringLiteral("ReconcilingUnknownApply"); + const bool reconcilingAfterApply = + record.info.stage == + QStringLiteral("ReconcilingReferences"); + const bool replacementProofRequired = + reconcilingUnknownApply || + reconcilingAfterApply; + const bool replacementProofMatchesJournal = + replacementProofRequired && + originalIdentityVerified && + replacementIdentityVerified && + expectedReplacementName == + record.replaceJournal.newRemoteName && + expectedReplacementSize > 0 && + static_cast( + expectedReplacementSize) == + record.replaceJournal.newSize; + if (replacementProofRequired && + !replacementProofMatchesJournal) { + record.info.terminalOutcome = + QStringLiteral("PartialOrUnknown"); + record.info.resultName = + record.replaceJournal.newRemoteName; + finishOperation( + operationId, + QStringLiteral("RetryAvailable"), + QStringLiteral("PartialOrUnknown"), + QStringLiteral("ReconcileOnly"), + tr("The fresh FileList did not prove the exact original and replacement identities. Replace remains unresolved and no mutation was repeated.")); + return; + } + if (success && !originalIdentityVerified) { + finishOperation( + operationId, QStringLiteral("Failed"), + QStringLiteral("ReplacePreflightInvalid"), + QString(), + tr("The replace preflight succeeded without proving the original media identity")); + return; + } + if (!success) { + if (reconcilingAfterApply) { + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + errorMessage.isEmpty() + ? tr("The new copy is active, but the original was retained because its references could not be re-read") + : tr("The new copy is active, but the original was retained: %1") + .arg(errorMessage)); + return; + } + if (reconcilingUnknownApply) { + finishOperation( + operationId, + QStringLiteral("RetryAvailable"), + QStringLiteral("PartialOrUnknown"), + QStringLiteral("ReconcileOnly"), + errorMessage.isEmpty() + ? tr("The previous Apply outcome is still unknown; no mutation was repeated") + : tr("The previous Apply outcome is still unknown: %1") + .arg(errorMessage)); + return; + } + finishOperation( + operationId, QStringLiteral("Failed"), + QStringLiteral("ReplacePreflightFailed"), + QString(), + errorMessage.isEmpty() + ? tr("The original media references could not be verified") + : errorMessage); + return; + } + const QStringList expectedSlots{ + QStringLiteral("PowerOn"), + QStringLiteral("Standby"), + QStringLiteral("Single"), + QStringLiteral("DualLeft"), + QStringLiteral("DualRight"), + QStringLiteral("Kaleidoscope"), + QStringLiteral("FilterSingle"), + QStringLiteral("FilterDualLeft"), + QStringLiteral("FilterDualRight"), + }; + if (references.size() != expectedSlots.size()) { + finishOperation( + operationId, + reconcilingUnknownApply + ? QStringLiteral("RetryAvailable") + : reconcilingAfterApply + ? QStringLiteral("Succeeded") + : QStringLiteral("Failed"), + reconcilingUnknownApply + ? QStringLiteral("PartialOrUnknown") + : reconcilingAfterApply + ? QStringLiteral("OriginalRetained") + : QStringLiteral( + "ReplacePreflightInvalid"), + reconcilingUnknownApply + ? QStringLiteral("ReconcileOnly") + : QString(), + tr("The device returned an incomplete media reference set")); + return; + } + if (reconcilingUnknownApply) { + record.replaceReferences = references; + record.replaceReferenceSlots = + referencingSlots; + record.replaceJournal.disposition = + QStringLiteral("NewCopyReady"); + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral("ReferenceReconciliation"), + &journalError)) { + finishOperation( + operationId, + QStringLiteral("RetryAvailable"), + QStringLiteral("PartialOrUnknown"), + QStringLiteral("ReconcileOnly"), + tr("Apply was not repeated, but the read-only reconciliation could not be persisted: %1") + .arg(journalError)); + return; + } + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + referencingSlots.isEmpty() + ? tr("The previous Apply was not repeated and its outcome remains unknown. The new copy is ready and the original was retained.") + : tr("The previous Apply was not repeated and its outcome remains unknown. The original is still referenced by: %1") + .arg(referencingSlots.join( + QStringLiteral(", ")))); + return; + } + if (reconcilingAfterApply) { + record.replaceReferences = references; + record.replaceReferenceSlots = + referencingSlots; + if (!referencingSlots.isEmpty()) { + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + tr("The new copy is active, but the original media is still referenced by: %1") + .arg(referencingSlots.join( + QStringLiteral(", ")))); + return; + } + + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral("ReferenceReconciliation"), + &journalError)) { + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + tr("The new copy is active, but the original was retained because replace state could not be persisted: %1") + .arg(journalError)); + return; + } + + record.deleteNames = { + record.originalRemoteNameForReplace}; + if (!writeDeleteIntent( + operationId, + QStringLiteral("Preflight"), + false, 0, + record.originalRemoteNameForReplace, + {}, &journalError)) { + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + tr("The new copy is active, but the original was retained because delete intent could not be persisted: %1") + .arg(journalError)); + return; + } + + record.replaceJournal.deleteIntentLinked = true; + if (!writeReplaceJournal( + operationId, + QStringLiteral("DeleteIntentLinked"), + &journalError)) { + QString clearError; + clearDeleteIntent(&clearError); + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + tr("The new copy is active, but the original was retained because the replace/delete link could not be persisted: %1") + .arg(journalError)); + return; + } + + record.replaceJournal.fileRemoveMayHaveStarted = true; + if (!writeReplaceJournal( + operationId, + QStringLiteral("Deleting"), + &journalError)) { + QString clearError; + clearDeleteIntent(&clearError); + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + tr("The new copy is active, but the original was retained because the delete boundary could not be persisted: %1") + .arg(journalError)); + return; + } + + record.info.state = QStringLiteral("Deleting"); + record.info.stage = QStringLiteral("DeletePreflight"); + record.info.message = tr( + "No references to the original remain; deleting it once..."); + publishOperation(operationId); + emit requestPrinterDeleteMedia( + currentPrinterPath(), record.deleteNames, + operationId, deleteIntentPath(), false, + static_cast( + record.replaceJournal.originalSize), + record.replaceJournal.newRemoteName, + static_cast( + record.replaceJournal.newSize), + printerGeneration_); + return; + } + const bool splitScreen = + record.applyRequest.screenMode == + QStringLiteral("Screen Splitting"); + const QSet replaceableSlots = splitScreen + ? QSet{ + QStringLiteral("DualLeft"), + QStringLiteral("DualRight")} + : QSet{QStringLiteral("Single")}; + QStringList blockedSlots; + for (const QString &slot : referencingSlots) { + if (!replaceableSlots.contains(slot)) { + blockedSlots.append(slot); + } + } + if (referencingSlots.isEmpty() || + !blockedSlots.isEmpty()) { + finishOperation( + operationId, QStringLiteral("Failed"), + QStringLiteral("OriginalStillReferenced"), + QString(), + referencingSlots.isEmpty() + ? tr("The original media is not referenced by the active layout; use Save as new instead") + : tr("Replace is blocked because the original media is also referenced by: %1") + .arg(blockedSlots.join( + QStringLiteral(", ")))); + return; + } + record.replaceReferences = references; + record.replaceReferenceSlots = + referencingSlots; + QSet seenReferences; + QStringList uniqueReferences; + for (const QString &reference : references) { + if (!reference.isEmpty() && + !seenReferences.contains(reference)) { + seenReferences.insert(reference); + uniqueReferences.append(reference); + } + } + record.replaceJournal.operationId = operationId; + record.replaceJournal.deviceIdentity = + record.uploadDeviceIdentity; + record.replaceJournal.deviceGeneration = + record.uploadDeviceGeneration; + record.replaceJournal.originalMediaId = + record.originalMediaId; + record.replaceJournal.originalRemoteName = + record.originalRemoteNameForReplace; + record.replaceJournal.originalSize = + static_cast(record.sourceSize); + record.replaceJournal.artifactId = + record.artifactId; + record.replaceJournal.decodedSha256 = + record.sourceContentSha256; + record.replaceJournal.transformFingerprint = + tryxMediaTransformFingerprint( + record.mediaTransform); + record.replaceJournal.applyFingerprint = + runtimeApplyRequestFingerprint( + record.applyRequest); + record.replaceJournal.referenceNames = + uniqueReferences; + record.replaceJournalActive = true; + QString journalError; + if (!writeReplaceJournal( + operationId, QStringLiteral("Preflight"), + &journalError) || + !writeReplaceJournal( + operationId, QStringLiteral("Preparing"), + &journalError)) { + finishOperation( + operationId, QStringLiteral("Failed"), + QStringLiteral("ReplaceJournalWriteFailed"), + QString(), + tr("Replacement was stopped before upload because its journal could not be persisted: %1") + .arg(journalError)); + return; + } + record.info.state = + QStringLiteral("Converting"); + record.info.stage = + QStringLiteral("Converting"); + record.info.message = + tr("Reference preflight passed; preparing the replacement media..."); + publishOperation(operationId); + emit requestEndPrinterForegroundOperation( + operationId, generation); + emit requestPrepareRecoveredPrinterMedia( + operationId, currentPrinterPath(), + record.sourcePath, + record.sourceContentSha256, generation, + record.mediaTransform); + }); + connect(worker_, &DeviceWorker::printerUploadFinished, this, + [this, printerOperationResultIsExpected]( + const QString &operationId, const QString &uploadPath, + const QString &remoteName, bool success, + PrinterProtocol::MutationOutcome outcome, + const QString &errorMessage, quint64 generation) { + if (!printerOperationResultIsExpected(operationId, + generation)) { + return; + } + OperationRecord &record = operations_[operationId]; + record.preparedPath = uploadPath; + record.remoteName = remoteName; + if (record.originalRemoteName.isEmpty()) { + record.originalRemoteName = remoteName; + } + record.info.resultName = remoteName; + if (!success) { + const QString outcomeName = mutationOutcomeName(outcome); + if (record.info.terminalOutcome.isEmpty()) { + record.info.terminalOutcome = outcomeName; + } + if (record.info.primaryErrorCategory.isEmpty()) { + record.info.primaryErrorCategory = outcomeName; + } + if (record.info.primaryErrorMessage.isEmpty()) { + record.info.primaryErrorMessage = errorMessage; + } + if (outcome == + PrinterProtocol::MutationOutcome:: + FinalizationUnknown) { + record.info.confirmedBytes = + qMax(record.info.confirmedBytes, + record.info.total); + record.info.lastConfirmedChunkIndex = + record.info.confirmedBytes > 0 + ? (record.info.confirmedBytes - 1) / + kFileTransmitChunkSize + : -1; + if (record.uploadDeviceIdentity.isEmpty()) { + record.uploadDeviceIdentity = + printerDeviceSerial_.trimmed(); + } + record.uploadFinalizationReconciliationPending = + true; + record.info.state = QStringLiteral("Refreshing"); + record.info.stage = + QStringLiteral("RecoveringFinalization"); + record.info.message = tr( + "All media data was acknowledged, but the final status was lost. Recovering the session to verify FileList without retransmission..."); + QString cacheError; + if (!writeRetryCache( + operationId, + QStringLiteral("FinalizationUnknown"), + &cacheError)) { + qWarning().noquote() + << tr("Cannot persist pending upload finalization reconciliation: %1") + .arg(cacheError); + } + publishOperation(operationId); + return; + } + if (outcome == + PrinterProtocol::MutationOutcome::PartialOrUnknown) { + record.requiresDeviceRecovery = true; + record.retryMustUseNewRemoteName = true; + requirePrinterRecovery(tr( + "The PASE transfer ended in an unknown partial state. Power-cycle the device before Retry or Save; the prepared media has been preserved.")); + } + handlePreparedUploadFailure(operationId, errorMessage, + outcome); + return; + } + if (record.deviceChangePending) { + handlePreparedUploadFailure( + operationId, + record.deviceChangeMessage.isEmpty() + ? tr("USB changed before uploaded media could be verified") + : record.deviceChangeMessage, + PrinterProtocol::MutationOutcome::PartialOrUnknown); + return; + } + if (record.cancelRequested) { + worker_->clearPrinterOperationCancellation(operationId); + } + record.info.state = QStringLiteral("Refreshing"); + record.info.stage = QStringLiteral("RefreshingMedia"); + record.info.message = + tr("Upload acknowledged; verifying the device file list..."); + publishOperation(operationId); + emit requestPrinterRefreshMedia(currentPrinterPath(), + operationId, + printerGeneration_); + }); + connect(worker_, &DeviceWorker::printerMediaListReady, this, + [this, printerResultIsCurrent, + printerOperationResultIsExpected](const QString &operationId, + const QList &mediaFiles, + quint64 generation) { + QStringList files; + QSet seenNames; + for (const PrinterProtocol::MediaFile &media : mediaFiles) { + if (!seenNames.contains(media.name)) { seenNames.insert(media.name); files.append(media.name); } @@ -4694,6 +6209,7 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, if (!reusableName.isEmpty()) { record.remoteName = reusableName; record.mediaFile = reusableName; + releaseOwnedSource(record); record.info.resultName = reusableName; record.info.state = QStringLiteral("Applying"); record.info.stage = QStringLiteral("ReusingExisting"); @@ -4716,7 +6232,7 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, emit requestPreparePrinterMedia( operationId, currentPrinterPath(), record.sourcePath, record.sourceContentSha256, - printerGeneration_); + printerGeneration_, record.mediaTransform); return; } const QFileInfo preparedInfo(record.preparedPath); @@ -5028,6 +6544,34 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, return; } + if (record.replaceOperation) { + record.replaceJournal.newRemoteName = + exactMedia->name; + record.replaceJournal.newSize = + exactMedia->size; + record.replaceJournal.uploadVerified = true; + record.replaceJournal.disposition = + QStringLiteral("NewCopyReady"); + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral("UploadVerified"), + &journalError)) { + updateMediaCatalog(mediaFiles); + removePreparedFileForOperation(operationId); + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, + QStringLiteral("RetryAvailable"), + QStringLiteral("PersistenceFailed"), + QStringLiteral("ReconcileOnly"), + tr("The new copy is verified, but Apply was not started because replace state could not be persisted: %1") + .arg(journalError)); + return; + } + } + TryxRuntimeMediaEntry verifiedOriginEntry; verifiedOriginEntry.name = exactMedia->name; verifiedOriginEntry.size = exactMedia->size; @@ -5076,6 +6620,48 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, } if (record.info.applyAfterUpload) { record.mediaFile = record.remoteName; + if (record.replaceOperation) { + bool replacedReference = false; + for (QString &media : + record.applyRequest.media) { + if (media == + record.originalRemoteNameForReplace) { + media = record.remoteName; + replacedReference = true; + } + } + if (!replacedReference) { + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, + QStringLiteral("Succeeded"), + QStringLiteral( + "OriginalRetained"), + QString(), + tr("The new copy is ready, but the explicit layout no longer references the original media")); + return; + } + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + record.replaceJournal.applyMayHaveStarted = + true; + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral("Applying"), + &journalError)) { + finishOperation( + operationId, + QStringLiteral("Succeeded"), + QStringLiteral( + "OriginalRetained"), + QString(), + tr("The new copy is ready, but Apply was not started because replace state could not be persisted: %1") + .arg(journalError)); + return; + } + } record.info.state = QStringLiteral("Applying"); record.info.stage = QStringLiteral("Applying"); record.info.message = tr("Applying the verified media..."); @@ -5141,7 +6727,29 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, return; } OperationRecord &record = operations_[operationId]; + if (record.replaceOperation && + (generation != printerGeneration_ || + record.deviceChangePending || + record.uploadDeviceIdentity.trimmed().isEmpty() || + record.uploadDeviceIdentity.trimmed() != + printerDeviceSerial_.trimmed())) { + record.info.terminalOutcome = + QStringLiteral("PartialOrUnknown"); + finishOperation( + operationId, + QStringLiteral("RetryAvailable"), + QStringLiteral("PartialOrUnknown"), + QStringLiteral("ReconcileOnly"), + record.deviceChangeMessage.isEmpty() + ? tr("The PASE connection changed before the replacement delete result could be associated with the original device") + : record.deviceChangeMessage); + return; + } record.deletedNames = deletedNames; + const bool replaceJournalOnlyReconciliation = + record.replaceOperation && + record.deleteReconcileOnly && + pendingDeleteOperationId_.isEmpty(); if (!record.deviceChangePending && (success || outcome == PrinterProtocol::MutationOutcome::Rejected || @@ -5159,6 +6767,69 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, updateMediaCatalog(mediaFiles); emit mediaListUpdated(names); } + const auto freshCatalogHasExactWritableUserMedia = + [&mediaFiles](const QString &name, + quint64 size) { + int nameMatches = 0; + bool exactMatch = false; + for (const auto &media : + mediaFiles) { + if (media.name != name) { + continue; + } + ++nameMatches; + exactMatch = + static_cast( + media.size) == + size && + media.source == + PrinterProtocol:: + MediaSource::User && + !media.readOnly; + } + return nameMatches == 1 && + exactMatch; + }; + const bool replacementCopyMatchesFreshCatalog = + !record.replaceOperation || + (record.replaceJournalActive && + PrinterProtocol::isSafeUploadMediaName( + record.replaceJournal.newRemoteName) && + record.replaceJournal.newSize > 0 && + freshCatalogHasExactWritableUserMedia( + record.replaceJournal.newRemoteName, + record.replaceJournal.newSize)); + if (!replacementCopyMatchesFreshCatalog) { + record.info.terminalOutcome = + QStringLiteral("PartialOrUnknown"); + record.info.resultName = record.remoteName; + record.replaceJournal.disposition = + QStringLiteral("PartialOrUnknown"); + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral( + "DeleteReconciliation"), + &journalError)) { + qWarning().noquote() + << "Cannot persist unresolved replacement-copy identity:" + << journalError; + } + const bool hasDeleteIntent = + pendingDeleteOperationId_ == + operationId; + finishOperation( + operationId, + QStringLiteral("RetryAvailable"), + QStringLiteral("PartialOrUnknown"), + hasDeleteIntent + ? QStringLiteral( + "DeleteReconcile") + : QStringLiteral( + "ReconcileOnly"), + tr("The fresh FileList does not contain the exact verified replacement copy. Replace remains unresolved and no mutation was repeated.")); + return; + } if (success) { QString clearError; if (!clearDeleteIntent(&clearError)) { @@ -5172,6 +6843,16 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, .arg(clearError)); return; } + if (record.replaceOperation) { + record.info.terminalOutcome = + QStringLiteral("Replaced"); + finishOperation( + operationId, + QStringLiteral("Succeeded"), + QString(), QString(), + tr("Replacement uploaded, applied and the original media was deleted")); + return; + } finishOperation( operationId, QStringLiteral("Succeeded"), QString(), QString(), @@ -5183,6 +6864,59 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, } if (outcome == PrinterProtocol::MutationOutcome::PartialOrUnknown) { + if (replaceJournalOnlyReconciliation) { + const QString target = + requestedNames.value(0); + const bool targetStillPresent = + freshCatalogHasExactWritableUserMedia( + target, + record.replaceJournal.originalSize); + if (targetStillPresent) { + QString clearError; + if (!clearDeleteIntent(&clearError)) { + finishOperation( + operationId, + QStringLiteral("RetryAvailable"), + QStringLiteral("PersistenceFailed"), + QStringLiteral("ReconcileOnly"), + tr("The original media is still present, but stale delete state could not be cleared: %1") + .arg(clearError)); + return; + } + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, + QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + tr("Read-only FileList confirmed that the original media is still present. FileRemove was not repeated.")); + return; + } + record.info.terminalOutcome = + QStringLiteral("PartialOrUnknown"); + record.replaceJournal.disposition = + QStringLiteral("PartialOrUnknown"); + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral("DeleteReconciliation"), + &journalError)) { + qWarning().noquote() + << "Cannot persist read-only Replace delete reconciliation:" + << journalError; + } + finishOperation( + operationId, + QStringLiteral("RetryAvailable"), + QStringLiteral("PartialOrUnknown"), + QStringLiteral("ReconcileOnly"), + errorMessage.isEmpty() + ? tr("The previous FileRemove outcome is still unknown; only FileList reconciliation may be retried") + : tr("The previous FileRemove outcome is still unknown: %1") + .arg(errorMessage)); + return; + } const int currentIndex = qBound( 0, deletedNames.size(), qMax(0, requestedNames.size() - 1)); @@ -5198,6 +6932,26 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, pendingDeleteIntent_.insert( QStringLiteral("mayHaveStarted"), true); record.info.resultName = currentName; + if (record.replaceOperation) { + record.info.terminalOutcome = + QStringLiteral( + "PartialOrUnknown"); + record.info.resultName = + record.remoteName; + record.replaceJournal.disposition = + QStringLiteral( + "PartialOrUnknown"); + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral( + "DeleteReconciliation"), + &journalError)) { + qWarning().noquote() + << "Cannot persist uncertain Replace delete outcome:" + << journalError; + } + } finishOperation( operationId, QStringLiteral("RetryAvailable"), @@ -5219,6 +6973,18 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, .arg(clearError)); return; } + if (record.replaceOperation) { + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, + QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), + QString(), + tr("The replacement is active, but the original media was retained: %1") + .arg(errorMessage)); + return; + } const QString terminalState = outcome == PrinterProtocol::MutationOutcome::Cancelled ? QStringLiteral("Cancelled") @@ -5326,6 +7092,65 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, updateDisplayState(state, overlay); } } + if (record.replaceOperation) { + record.replaceJournal.applyVerified = true; + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral("ApplyVerification"), + &journalError)) { + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, + QStringLiteral("Succeeded"), + QStringLiteral( + "OriginalRetained"), + QString(), + tr("The new copy is active, but the original was retained because Apply verification could not be persisted: %1") + .arg(journalError)); + emit screenConfigChanged(); + return; + } + if (!writeReplaceJournal( + operationId, + QStringLiteral( + "ReferenceReconciliation"), + &journalError)) { + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + finishOperation( + operationId, + QStringLiteral("Succeeded"), + QStringLiteral( + "OriginalRetained"), + QString(), + tr("The new copy is active, but the original was retained because reference reconciliation could not be persisted: %1") + .arg(journalError)); + emit screenConfigChanged(); + return; + } + record.info.state = + QStringLiteral("Preflight"); + record.info.stage = + QStringLiteral( + "ReconcilingReferences"); + record.info.message = tr( + "The replacement is active; re-reading every device reference before deletion..."); + publishOperation(operationId); + emit requestPrinterReplacePreflight( + currentPrinterPath(), + record.originalRemoteNameForReplace, + static_cast( + record.replaceJournal.originalSize), + record.replaceJournal.newRemoteName, + static_cast( + record.replaceJournal.newSize), + operationId, + printerGeneration_); + emit screenConfigChanged(); + return; + } finishOperation(operationId, QStringLiteral("Succeeded"), QString(), QString(), record.cancelRequested @@ -5334,6 +7159,52 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, emit screenConfigChanged(); return; } + if (record.replaceOperation) { + const bool applyOutcomeUnknown = + outcome == + PrinterProtocol::MutationOutcome:: + PartialOrUnknown || + outcome == + PrinterProtocol::MutationOutcome:: + FinalizationUnknown || + outcome == + PrinterProtocol::MutationOutcome:: + VerificationFailed; + if (applyOutcomeUnknown) { + record.replaceJournal.disposition = + QStringLiteral("PartialOrUnknown"); + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral("ApplyVerification"), + &journalError)) { + qWarning().noquote() + << "Cannot persist uncertain Replace Apply outcome:" + << journalError; + } + } + record.info.terminalOutcome = + applyOutcomeUnknown + ? QStringLiteral("PartialOrUnknown") + : QStringLiteral("NewCopyReady"); + finishOperation( + operationId, + applyOutcomeUnknown + ? QStringLiteral("RetryAvailable") + : QStringLiteral("Succeeded"), + applyOutcomeUnknown + ? QStringLiteral("PartialOrUnknown") + : QStringLiteral("OriginalRetained"), + applyOutcomeUnknown + ? QStringLiteral("ReconcileOnly") + : QString(), + applyOutcomeUnknown + ? tr("The new copy is present, but the Apply outcome is uncertain. The original was not deleted: %1") + .arg(errorMessage) + : tr("The new copy is ready, but Apply did not complete. The original was retained: %1") + .arg(errorMessage)); + return; + } if (record.cancelRequested && (outcome == PrinterProtocol::MutationOutcome::Cancelled || outcome == PrinterProtocol::MutationOutcome::NotStarted)) { @@ -5559,6 +7430,13 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, worker_, &DeviceWorker::endPrinterForegroundOperation); connect(this, &DeviceManager::requestClearPrinter, worker_, &DeviceWorker::clearPrinterDevice); + connect(this, &DeviceManager::requestFirmwareTransportQuiesce, + worker_, &DeviceWorker::quiesceForFirmware); + connect( + this, + &DeviceManager::requestFirmwareQuiesceReleaseFence, + worker_, + &DeviceWorker::releaseFirmwareQuiesceFence); connect(this, &DeviceManager::requestPrinterDeviceInfo, worker_, &DeviceWorker::readPrinterDeviceInfo); connect(this, &DeviceManager::requestPrinterDisplayState, @@ -5569,6 +7447,10 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, worker_, &DeviceWorker::uploadPreparedPrinterMedia); connect(this, &DeviceManager::requestPrinterRefreshMedia, worker_, &DeviceWorker::refreshPrinterMediaList); + connect(this, &DeviceManager::requestPrinterStageMedia, + worker_, &DeviceWorker::stagePrinterMedia); + connect(this, &DeviceManager::requestPrinterReplacePreflight, + worker_, &DeviceWorker::preflightReplacePrinterMedia); connect(this, &DeviceManager::requestPrinterDeleteMedia, worker_, &DeviceWorker::deletePrinterMedia); connect(this, &DeviceManager::requestPrinterApplyMedia, @@ -5613,6 +7495,9 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, connect(this, &DeviceManager::requestPreparePrinterMedia, printerMediaPreparer_, &PrinterMediaPreparer::prepare); + connect(this, &DeviceManager::requestPrepareRecoveredPrinterMedia, + printerMediaPreparer_, + &PrinterMediaPreparer::prepareRecovered); connect( this, &DeviceManager::requestCancelPrinterPreparation, this, @@ -5743,6 +7628,11 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, emit requestReleasePrinterPreparation( stagedThumbnailPath); } + auto staleRecord = operations_.find(operationId); + if (staleRecord != operations_.end() && + staleRecord->sourcePath == sourcePath) { + releaseOwnedSource(*staleRecord); + } return; } OperationRecord &record = operations_[operationId]; @@ -5770,11 +7660,29 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, record.stagedThumbnailSha256 = stagedThumbnailSha256; record.remoteName = remoteName; record.originalRemoteName = remoteName; + releaseOwnedSource(record); record.info.resultName = remoteName; record.info.total = QFileInfo(uploadPath).size(); record.info.state = QStringLiteral("Preflight"); record.info.stage = QStringLiteral("EnsuringSession"); record.info.message = tr("Prepared media is ready for upload"); + if (record.replaceOperation) { + QString journalError; + if (!writeReplaceJournal( + operationId, + QStringLiteral("Uploading"), + &journalError)) { + removePreparedFileForOperation(operationId); + finishOperation( + operationId, QStringLiteral("Failed"), + QStringLiteral( + "ReplaceJournalWriteFailed"), + QString(), + tr("Replacement was stopped before upload because its journal could not be persisted: %1") + .arg(journalError)); + return; + } + } publishOperation(operationId); emit requestBeginPrinterForegroundOperation(operationId, generation); @@ -5785,7 +7693,9 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, }); connect(keepaliveTimer_, &QTimer::timeout, this, [this]() { - if (!printerClassConnected_) { + if (!firmwareExclusiveActive() && + !firmwareRecoveryInterlockActive_ && + !printerClassConnected_) { emit requestKeepalive(); } }); @@ -5834,10 +7744,14 @@ DeviceManager::DeviceManager(PrinterDeviceMonitor *printerMonitor, // scheduler before the D-Bus service can accept foreground work; the // potentially large SHA-256 validation remains queued on the // preparation thread. + cleanupMediaRuntimeStaging(); + cleanupDeviceMediaOutbox(); loadMediaCatalogIndex(); loadPaseMetricsConfig(); loadRetryCache(); + loadReplaceJournal(); loadDeleteIntent(); + artifactSweepTimer_->start(); printerMonitor_->start(); } } @@ -5857,6 +7771,11 @@ DeviceManager *DeviceManager::createForTesting( manager->paseMetricsConfigDirectoryOverride_ = QDir(QFileInfo(sysfsRoot).absolutePath()) .filePath(QStringLiteral("pase-config")); + manager->mediaRuntimeRootOverride_ = + QDir(QFileInfo(sysfsRoot).absolutePath()) + .filePath(QStringLiteral("runtime-staging")); + manager->cleanupDeviceMediaOutbox(); + manager->artifactSweepTimer_->start(); manager->loadMediaCatalogIndex(); return manager; } @@ -5986,6 +7905,9 @@ DeviceManager::~DeviceManager() { if (remoteMode_) { return; } + if (artifactSweepTimer_) { + artifactSweepTimer_->stop(); + } if (!pendingRetryValidationId_.isEmpty()) { printerMediaPreparer_->cancelRetryValidation( pendingRetryValidationId_); @@ -6016,6 +7938,14 @@ DeviceManager::~DeviceManager() { } workerThread_.quit(); workerThread_.wait(); + for (auto record = operations_.begin(); + record != operations_.end(); ++record) { + releaseOwnedSource(*record); + } + const QStringList artifactIds = deviceMediaArtifacts_.keys(); + for (const QString &artifactId : artifactIds) { + removeDeviceMediaArtifact(artifactId); + } } void DeviceManager::setPrinterDisplaySessionActive(bool active) { @@ -6029,6 +7959,7 @@ void DeviceManager::setPrinterDisplaySessionActive(bool active) { void DeviceManager::handlePrinterSnapshot( const PrinterProtocol::DiscoverySnapshot &snapshot) { const bool oldPresence = isPrinterClassDevicePresent(); + const bool wasConnected = connected_; const bool wasPrinterConnected = printerClassConnected_; const QString oldPath = printerDevicePath_; const QString oldSerial = printerDeviceSerial_; @@ -6058,6 +7989,25 @@ void DeviceManager::handlePrinterSnapshot( if (wasPrinterConnected) { emit printerOperationsCancelled(); } + if (firmwareExclusiveActive() || + firmwareRecoveryInterlockActive_) { + worker_->updatePrinterGenerationGate( + printerGeneration_, false); + detachPrinterClassDevice(false); + connected_ = false; + printerSessionResumePending_ = false; + printerSessionResumeSerial_.clear(); + emit mediaListUpdated({}); + if (wasConnected) { + emit deviceDisconnected(); + } + const bool newPresence = + isPrinterClassDevicePresent(); + if (oldPresence != newPresence) { + emit printerPresenceChanged(newPresence); + } + return; + } const bool ready = snapshot.state == PrinterProtocol::DiscoveryState::Ready && snapshot.devices.size() == 1; const bool endpointSelected = ready && @@ -6226,6 +8176,15 @@ void DeviceManager::connectDevice(const QString &port) { remoteCall(QStringLiteral("ConnectDevice"), {port}); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } + if (firmwareRecoveryInterlockActive_) { + emit deviceError(tr( + "Device connection is blocked until firmware recovery is explicitly acknowledged")); + return; + } if (!port.isEmpty() && printerSnapshot_.blocksLegacyTransport()) { emit deviceError( tr("A TRYX printer-class or Rockchip gadget device is present; use Auto connection.")); @@ -6338,6 +8297,10 @@ void DeviceManager::disconnectDevice() { remoteCall(QStringLiteral("DisconnectDevice")); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } autoConnectMode_ = false; setPrinterDisplaySessionActive(false); printerSessionResumePending_ = false; @@ -6367,6 +8330,11 @@ void DeviceManager::requestDeviceInfo() { remoteCall(QStringLiteral("RequestDeviceInfo")); return; } + if (firmwareExclusiveActive()) { + emit printerDeviceInfoFailed( + firmwareExclusiveStatusText()); + return; + } const QString devicePath = currentPrinterPath(); if (!devicePath.isEmpty()) { if (!pendingRetryValidationId_.isEmpty() || @@ -6457,6 +8425,9 @@ QString DeviceManager::printerUnavailableStatusText() const { } QString DeviceManager::printerMutationUnavailableStatusText() const { + if (firmwareExclusiveActive()) { + return firmwareExclusiveStatusText(); + } if (!pendingRetryValidationId_.isEmpty()) { return tr( "Stored retry media is still being validated; wait for validation to finish before using the PASE display session."); @@ -6476,8 +8447,15 @@ QString DeviceManager::printerMutationUnavailableStatusText() const { return printerUnavailableStatusText(); } +QString DeviceManager::firmwareExclusiveStatusText() const { + return tr( + "Device controls are unavailable while firmware flashing owns the USB transport"); +} + void DeviceManager::resumePrinterSessionAfterRetryCacheValidation() { if (remoteMode_ || !worker_ || + firmwareExclusiveActive() || + firmwareRecoveryInterlockActive_ || !pendingRetryValidationId_.isEmpty() || printerRecoveryRequired_) { return; @@ -6499,113 +8477,617 @@ void DeviceManager::resumePrinterSessionAfterRetryCacheValidation() { emit requestRestorePrinterOverlay( restoredOverlay, printerGeneration_); } - printerDisplaySessionLost_ = false; - printerSessionLossRemovalObserved_ = false; - emit requestStartPrinterSession(devicePath, printerGeneration_); + printerDisplaySessionLost_ = false; + printerSessionLossRemovalObserved_ = false; + emit requestStartPrinterSession(devicePath, printerGeneration_); +} + +void DeviceManager::requirePrinterRecovery(const QString &message) { + const bool enteringRecovery = !printerRecoveryRequired_; + printerRecoveryRequired_ = true; + if (enteringRecovery) { + printerRecoveryRemovalObserved_ = false; + } + printerDisplaySessionLost_ = true; + printerSessionLossRemovalObserved_ = false; + setPrinterDisplaySessionActive(false); + printerSessionResumePending_ = false; + printerSessionResumeSerial_.clear(); + if (!remoteMode_ && worker_) { + if (enteringRecovery) { + ++printerGeneration_; + emit requestCancelPrinterPreparation(printerGeneration_); + } + worker_->updatePrinterGenerationGate(printerGeneration_, false); + emit requestClearPrinter(printerGeneration_); + } + if (!message.isEmpty()) { + emit uploadStatus(message); + } +} + +bool DeviceManager::completePrinterRecoveryAfterRemoval( + const QString ¤tDeviceIdentity) { + if (!printerRecoveryRequired_) { + return true; + } + if (!printerRecoveryRemovalObserved_) { + return false; + } + + if (!retryCacheOperationId_.isEmpty() && + operations_.contains(retryCacheOperationId_)) { + OperationRecord &record = operations_[retryCacheOperationId_]; + const QString observedIdentity = currentDeviceIdentity.trimmed(); + const QString expectedIdentity = + record.uploadDeviceIdentity.trimmed(); + if (expectedIdentity.isEmpty()) { + record.info.message = tr( + "The original PASE identity is unavailable. Prepared media cannot be retried automatically."); + publishOperation(retryCacheOperationId_); + emit deviceError(record.info.message); + return false; + } + if (observedIdentity.isEmpty()) { + record.info.message = tr( + "PASE was reconnected, but its device identity is unavailable. Retry remains blocked."); + publishOperation(retryCacheOperationId_); + emit deviceError(record.info.message); + return false; + } + if (!expectedIdentity.isEmpty() && + expectedIdentity != observedIdentity) { + record.info.message = tr( + "A different PASE was connected after the incomplete transfer. Reconnect the original device before Retry."); + publishOperation(retryCacheOperationId_); + emit deviceError(record.info.message); + return false; + } + const bool previousRecoveryState = record.requiresDeviceRecovery; + const QString previousMessage = record.info.message; + const QString previousIdentity = record.uploadDeviceIdentity; + record.requiresDeviceRecovery = false; + record.uploadDeviceIdentity = observedIdentity; + record.info.message = tr( + "The same PASE was physically reconnected after the incomplete transfer. Prepared media can now be transferred again under a new device filename."); + QString cacheError; + if (!writeRetryCache( + retryCacheOperationId_, + record.info.terminalOutcome.isEmpty() + ? QStringLiteral("PartialOrUnknown") + : record.info.terminalOutcome, + &cacheError)) { + record.requiresDeviceRecovery = previousRecoveryState; + record.info.message = previousMessage; + record.uploadDeviceIdentity = previousIdentity; + emit deviceError( + tr("PASE reconnected, but the recovery state could not be saved: %1") + .arg(cacheError)); + return false; + } + publishOperation(retryCacheOperationId_); + } + + printerRecoveryRequired_ = false; + printerRecoveryRemovalObserved_ = false; + printerDisplaySessionLost_ = false; + printerSessionLossRemovalObserved_ = false; + emit uploadStatus( + tr("PASE power-cycle was observed; starting a clean display session")); + return true; +} + +QString DeviceManager::normalizedOperationId(const QString &requestedId) const { + const QString trimmed = requestedId.trimmed(); + if (trimmed.isEmpty()) { + return QUuid::createUuid().toString( + QUuid::WithoutBraces); + } + const QUuid parsed(trimmed); + if (!parsed.isNull()) { + return parsed.toString(QUuid::WithoutBraces); + } + return {}; +} + +QString DeviceManager::mediaInboxDirectory() const { +#ifdef TRYX_PROTOCOL_TESTING + if (!mediaRuntimeRootOverride_.isEmpty()) { + return QDir(mediaRuntimeRootOverride_) + .filePath(QStringLiteral("media-inbox")); + } +#endif + return tryxRuntimeMediaInboxPath(); +} + +QString DeviceManager::mediaSpoolDirectory() const { +#ifdef TRYX_PROTOCOL_TESTING + if (!mediaRuntimeRootOverride_.isEmpty()) { + return QDir(mediaRuntimeRootOverride_) + .filePath(QStringLiteral("media-spool")); + } +#endif + return tryxRuntimeMediaSpoolPath(); +} + +bool DeviceManager::ensureMediaRuntimeDirectories( + QString *errorMessage) const { + const QString inbox = mediaInboxDirectory(); + const QString spool = mediaSpoolDirectory(); + if (inbox.isEmpty() || spool.isEmpty() || + QFileInfo(inbox).absolutePath() != + QFileInfo(spool).absolutePath()) { + if (errorMessage) { + *errorMessage = tr( + "The shared media staging directories are unavailable"); + } + return false; + } + + const QString applicationRoot = QFileInfo(inbox).absolutePath(); + const QString runtimeRoot = QFileInfo(applicationRoot).absolutePath(); + return ensurePrivateDirectory(runtimeRoot, false, errorMessage) && + ensurePrivateDirectory(applicationRoot, true, errorMessage) && + ensurePrivateDirectory(inbox, true, errorMessage) && + ensurePrivateDirectory(spool, true, errorMessage); +} + +bool DeviceManager::claimQuickStagedSource( + const QString &operationId, const QString &sourcePath, + QString *claimedPath, bool *owned, + QString *errorMessage) const { + if (!claimedPath || !owned) { + if (errorMessage) { + *errorMessage = tr( + "The staged source ownership destination is unavailable"); + } + return false; + } + + *claimedPath = cleanAbsolutePath(sourcePath); + *owned = false; + const QString inbox = mediaInboxDirectory(); + const QString spool = mediaSpoolDirectory(); + if (inbox.isEmpty() || spool.isEmpty()) { + return true; + } + const QString managedRoot = QFileInfo(inbox).absolutePath(); + const QString canonicalSource = + QFileInfo(sourcePath).canonicalFilePath(); + const bool managedPath = + pathIsInside(sourcePath, managedRoot) || + (!canonicalSource.isEmpty() && + pathIsInside(canonicalSource, managedRoot)); + if (!managedPath) { + return true; + } + + QString directoryError; + if (!ensureMediaRuntimeDirectories(&directoryError)) { + if (errorMessage) { + *errorMessage = directoryError; + } + return false; + } + + const QFileInfo sourceInfo(*claimedPath); + if (cleanAbsolutePath(sourceInfo.absolutePath()) != + cleanAbsolutePath(inbox) || + !stagedSourceFileNameIsValid(sourceInfo.fileName())) { + if (errorMessage) { + *errorMessage = tr( + "Staged media must be one validated direct child of the shared inbox"); + } + return false; + } + + const QByteArray encodedSource = + QFile::encodeName(*claimedPath); + struct stat before {}; + if (::lstat(encodedSource.constData(), &before) != 0 || + !stagedSourceStatIsValid(before)) { + if (errorMessage) { + *errorMessage = tr( + "Staged media must be a non-linked regular file owned by this user with mode 0600 and a supported size"); + } + return false; + } + + const QString suffix = sourceInfo.suffix(); + const QString destination = + QDir(spool).filePath(operationId + QLatin1Char('.') + suffix); + QString renameError; + if (!atomicRenameNoReplace( + *claimedPath, destination, &renameError)) { + if (errorMessage) { + *errorMessage = renameError; + } + return false; + } + + const QByteArray encodedDestination = + QFile::encodeName(destination); + struct stat after {}; + const bool sameValidatedFile = + ::lstat(encodedDestination.constData(), &after) == 0 && + stagedSourceStatIsValid(after) && + before.st_dev == after.st_dev && + before.st_ino == after.st_ino && + before.st_size == after.st_size && + before.st_uid == after.st_uid && + (before.st_mode & 07777) == (after.st_mode & 07777); + if (!sameValidatedFile) { + QString rollbackError; + if (!atomicRenameNoReplace( + destination, *claimedPath, &rollbackError)) { + ::unlink(encodedDestination.constData()); + } + if (errorMessage) { + *errorMessage = tr( + "The staged media identity changed while daemon ownership was acquired"); + } + return false; + } + + *claimedPath = destination; + *owned = true; + return true; +} + +void DeviceManager::releaseOwnedSource( + OperationRecord &record) { + if (!record.ownsSourcePath) { + return; + } + const QString sourcePath = cleanAbsolutePath(record.sourcePath); + const QString spool = cleanAbsolutePath(mediaSpoolDirectory()); + if (cleanAbsolutePath(QFileInfo(sourcePath).absolutePath()) == spool) { + const QByteArray encoded = QFile::encodeName(sourcePath); + if (::unlink(encoded.constData()) != 0 && errno != ENOENT) { + qWarning().noquote() + << tr("Could not remove daemon-owned staged source %1: %2") + .arg(sourcePath, + QString::fromLocal8Bit(std::strerror(errno))); + } + } else { + qWarning().noquote() + << tr("Refusing to remove an owned source outside the daemon spool: %1") + .arg(sourcePath); + } + record.ownsSourcePath = false; +} + +void DeviceManager::cleanupMediaRuntimeStaging() { + QString directoryError; + if (!ensureMediaRuntimeDirectories(&directoryError)) { + qWarning().noquote() + << tr("Could not initialize media staging: %1") + .arg(directoryError); + return; + } + + const auto sweep = [](const QString &directory, + bool removeAll) { + const qint64 now = QDateTime::currentSecsSinceEpoch(); + const QFileInfoList entries = QDir(directory).entryInfoList( + QDir::AllEntries | QDir::Hidden | QDir::System | + QDir::NoDotAndDotDot, + QDir::Name); + for (const QFileInfo &entry : entries) { + const QByteArray encoded = + QFile::encodeName(entry.absoluteFilePath()); + struct stat status {}; + if (::lstat(encoded.constData(), &status) != 0 || + (!S_ISREG(status.st_mode) && + !S_ISLNK(status.st_mode)) || + status.st_uid != ::geteuid()) { + continue; + } + const bool aged = + status.st_mtim.tv_sec <= + now - kMediaInboxMaxAgeSeconds; + if (removeAll || aged) { + ::unlink(encoded.constData()); + } + } + }; + + sweep(mediaSpoolDirectory(), true); + sweep(mediaInboxDirectory(), false); +} + +QString DeviceManager::deviceMediaOutboxDirectory() const { +#ifdef TRYX_PROTOCOL_TESTING + if (!mediaRuntimeRootOverride_.isEmpty()) { + return QDir(mediaRuntimeRootOverride_) + .filePath(QStringLiteral("device-media-outbox")); + } +#endif + return tryxRuntimeDeviceMediaOutboxPath(); +} + +bool DeviceManager::ensureDeviceMediaOutbox( + QString *errorMessage) const { + const QString outbox = deviceMediaOutboxDirectory(); + const QString parent = QFileInfo(outbox).absolutePath(); + return ensurePrivateDirectory(parent, true, errorMessage) && + ensurePrivateDirectory(outbox, true, errorMessage); +} + +void DeviceManager::cleanupDeviceMediaOutbox() { + deviceMediaArtifacts_.clear(); + QString errorMessage; + if (!ensureDeviceMediaOutbox(&errorMessage)) { + qWarning().noquote() + << tr("Could not initialize the device media outbox: %1") + .arg(errorMessage); + return; + } + const QFileInfoList entries = QDir(deviceMediaOutboxDirectory()) + .entryInfoList( + QDir::AllEntries | + QDir::Hidden | + QDir::System | + QDir::NoDotAndDotDot, + QDir::Name); + constexpr qsizetype kMaximumOutboxSweepEntries = 4096; + const qsizetype count = + qMin(entries.size(), kMaximumOutboxSweepEntries); + for (qsizetype index = 0; index < count; ++index) { + const QByteArray encoded = + QFile::encodeName(entries.at(index).absoluteFilePath()); + struct stat status {}; + if (::lstat(encoded.constData(), &status) != 0 || + status.st_uid != ::geteuid() || + (!S_ISREG(status.st_mode) && + !S_ISLNK(status.st_mode))) { + continue; + } + if (::unlink(encoded.constData()) != 0 && errno != ENOENT) { + qWarning().noquote() + << tr("Could not remove stale device media artifact %1: %2") + .arg( + entries.at(index).absoluteFilePath(), + QString::fromLocal8Bit(std::strerror(errno))); + } + } + if (entries.size() > kMaximumOutboxSweepEntries) { + qWarning() + << "Device media outbox startup sweep reached its bounded entry limit"; + } +} + +bool DeviceManager::isValidDbusUniqueName( + const QString &ownerUniqueName) { + if (!ownerUniqueName.startsWith(QLatin1Char(':')) || + ownerUniqueName.size() < 4 || + ownerUniqueName.size() > 255 || + !ownerUniqueName.contains(QLatin1Char('.'))) { + return false; + } + for (qsizetype index = 1; + index < ownerUniqueName.size(); ++index) { + const QChar character = ownerUniqueName.at(index); + if (!character.isLetterOrNumber() && + character != QLatin1Char('.') && + character != QLatin1Char('_') && + character != QLatin1Char('-')) { + return false; + } + } + return true; +} + +const TryxRuntimeMediaEntry *DeviceManager::findMediaById( + const QString &mediaId) const { + if (!isSha256Hex(mediaId)) { + return nullptr; + } + const auto found = std::find_if( + mediaCatalog_.entries.cbegin(), + mediaCatalog_.entries.cend(), + [&mediaId](const TryxRuntimeMediaEntry &entry) { + return entry.mediaId == mediaId; + }); + return found == mediaCatalog_.entries.cend() + ? nullptr + : &(*found); } -void DeviceManager::requirePrinterRecovery(const QString &message) { - const bool enteringRecovery = !printerRecoveryRequired_; - printerRecoveryRequired_ = true; - if (enteringRecovery) { - printerRecoveryRemovalObserved_ = false; +void DeviceManager::watchArtifactOwner( + const QString &ownerUniqueName) { + if (!artifactOwnerWatcher_ || + !isValidDbusUniqueName(ownerUniqueName) || + artifactOwnerWatcher_->watchedServices().contains( + ownerUniqueName)) { + return; } - printerDisplaySessionLost_ = true; - printerSessionLossRemovalObserved_ = false; - setPrinterDisplaySessionActive(false); - printerSessionResumePending_ = false; - printerSessionResumeSerial_.clear(); - if (!remoteMode_ && worker_) { - if (enteringRecovery) { - ++printerGeneration_; - emit requestCancelPrinterPreparation(printerGeneration_); + artifactOwnerWatcher_->addWatchedService(ownerUniqueName); +} + +void DeviceManager::handleArtifactOwnerUnregistered( + const QString &ownerUniqueName) { + QStringList removeNow; + QStringList cancelFirst; + for (auto artifact = deviceMediaArtifacts_.begin(); + artifact != deviceMediaArtifacts_.end(); ++artifact) { + if (artifact->ownerUniqueName != ownerUniqueName) { + continue; + } + artifact->expiresUtcMs = 0; + if (artifact->inUseOperationId.isEmpty()) { + removeNow.append(artifact.key()); + } else { + cancelFirst.append(artifact->inUseOperationId); } - worker_->updatePrinterGenerationGate(printerGeneration_, false); - emit requestClearPrinter(printerGeneration_); } - if (!message.isEmpty()) { - emit uploadStatus(message); + for (const QString &operationId : std::as_const(cancelFirst)) { + cancelOperation(operationId); + } + for (const QString &artifactId : std::as_const(removeNow)) { + removeDeviceMediaArtifact(artifactId); } } -bool DeviceManager::completePrinterRecoveryAfterRemoval( - const QString ¤tDeviceIdentity) { - if (!printerRecoveryRequired_) { - return true; - } - if (!printerRecoveryRemovalObserved_) { +bool DeviceManager::validateArtifactRecord( + const QString &artifactId, const QString &ownerUniqueName, + const QString &leaseId, bool verifyHash, + QString *errorMessage) const { + const auto artifact = + deviceMediaArtifacts_.constFind(artifactId); + const auto reject = [errorMessage](const QString &message) { + if (errorMessage) { + *errorMessage = message; + } return false; + }; + if (artifact == deviceMediaArtifacts_.constEnd() || + artifactId.isEmpty()) { + return reject(tr("The device media artifact does not exist")); + } + if (!isValidDbusUniqueName(ownerUniqueName) || + artifact->ownerUniqueName != ownerUniqueName) { + return reject(tr("The device media artifact belongs to another caller")); + } + if (artifact->claimed) { + if (leaseId.isEmpty() || + artifact->leaseId != leaseId) { + return reject(tr("The device media artifact lease is invalid")); + } + } else if (!leaseId.isEmpty()) { + return reject(tr("The unclaimed device media artifact has no lease")); + } + if (artifact->expiresUtcMs <= + QDateTime::currentMSecsSinceEpoch()) { + return reject(tr("The device media artifact lease has expired")); + } + const QString cleanOutbox = + cleanAbsolutePath(deviceMediaOutboxDirectory()); + const QString cleanPath = + cleanAbsolutePath(artifact->canonicalPath); + if (cleanAbsolutePath(QFileInfo(cleanPath).absolutePath()) != + cleanOutbox || + !pathIsInside(cleanPath, cleanOutbox)) { + return reject(tr("The device media artifact escaped its private outbox")); + } + const QByteArray encoded = QFile::encodeName(cleanPath); + struct stat status {}; + if (::lstat(encoded.constData(), &status) != 0 || + !S_ISREG(status.st_mode) || + status.st_uid != ::geteuid() || + (status.st_mode & 07777) != + (S_IRUSR | S_IWUSR) || + status.st_nlink != 1 || + status.st_size <= 0 || + static_cast(status.st_size) != + artifact->metadata.size || + static_cast(status.st_dev) != + artifact->deviceNumber || + static_cast(status.st_ino) != + artifact->inodeNumber) { + return reject(tr("The device media artifact identity changed")); + } + if (verifyHash && + sha256File(cleanPath) != + artifact->metadata.decodedSha256) { + return reject(tr("The device media artifact hash changed")); } + return true; +} - if (!retryCacheOperationId_.isEmpty() && - operations_.contains(retryCacheOperationId_)) { - OperationRecord &record = operations_[retryCacheOperationId_]; - const QString observedIdentity = currentDeviceIdentity.trimmed(); - const QString expectedIdentity = - record.uploadDeviceIdentity.trimmed(); - if (expectedIdentity.isEmpty()) { - record.info.message = tr( - "The original PASE identity is unavailable. Prepared media cannot be retried automatically."); - publishOperation(retryCacheOperationId_); - emit deviceError(record.info.message); - return false; - } - if (observedIdentity.isEmpty()) { - record.info.message = tr( - "PASE was reconnected, but its device identity is unavailable. Retry remains blocked."); - publishOperation(retryCacheOperationId_); - emit deviceError(record.info.message); - return false; - } - if (!expectedIdentity.isEmpty() && - expectedIdentity != observedIdentity) { - record.info.message = tr( - "A different PASE was connected after the incomplete transfer. Reconnect the original device before Retry."); - publishOperation(retryCacheOperationId_); - emit deviceError(record.info.message); - return false; +void DeviceManager::removeDeviceMediaArtifact( + const QString &artifactId) { + auto artifact = deviceMediaArtifacts_.find(artifactId); + if (artifact == deviceMediaArtifacts_.end()) { + return; + } + const QString owner = artifact->ownerUniqueName; + const QString cleanOutbox = + cleanAbsolutePath(deviceMediaOutboxDirectory()); + const QString cleanPath = + cleanAbsolutePath(artifact->canonicalPath); + if (cleanAbsolutePath(QFileInfo(cleanPath).absolutePath()) == + cleanOutbox && + pathIsInside(cleanPath, cleanOutbox)) { + const QByteArray encoded = QFile::encodeName(cleanPath); + struct stat status {}; + if (::lstat(encoded.constData(), &status) == 0 && + status.st_uid == ::geteuid() && + (S_ISREG(status.st_mode) || + S_ISLNK(status.st_mode))) { + ::unlink(encoded.constData()); + } + const QFileInfoList partials = + QDir(cleanOutbox).entryInfoList( + QStringList{ + QFileInfo(cleanPath).fileName() + + QStringLiteral(".part-*")}, + QDir::Files | QDir::System | + QDir::NoDotAndDotDot, + QDir::Name); + for (const QFileInfo &partial : + partials) { + const QByteArray encodedPartial = + QFile::encodeName(partial.absoluteFilePath()); + struct stat partialStatus {}; + if (::lstat( + encodedPartial.constData(), + &partialStatus) == 0 && + partialStatus.st_uid == ::geteuid() && + (S_ISREG(partialStatus.st_mode) || + S_ISLNK(partialStatus.st_mode))) { + ::unlink(encodedPartial.constData()); + } } - const bool previousRecoveryState = record.requiresDeviceRecovery; - const QString previousMessage = record.info.message; - const QString previousIdentity = record.uploadDeviceIdentity; - record.requiresDeviceRecovery = false; - record.uploadDeviceIdentity = observedIdentity; - record.info.message = tr( - "The same PASE was physically reconnected after the incomplete transfer. Prepared media can now be transferred again under a new device filename."); - QString cacheError; - if (!writeRetryCache( - retryCacheOperationId_, - record.info.terminalOutcome.isEmpty() - ? QStringLiteral("PartialOrUnknown") - : record.info.terminalOutcome, - &cacheError)) { - record.requiresDeviceRecovery = previousRecoveryState; - record.info.message = previousMessage; - record.uploadDeviceIdentity = previousIdentity; - emit deviceError( - tr("PASE reconnected, but the recovery state could not be saved: %1") - .arg(cacheError)); - return false; + } + deviceMediaArtifacts_.erase(artifact); + if (artifactOwnerWatcher_ && !owner.isEmpty()) { + const bool ownerStillUsed = std::any_of( + deviceMediaArtifacts_.cbegin(), + deviceMediaArtifacts_.cend(), + [&owner]( + const DeviceMediaArtifactRecord &candidate) { + return candidate.ownerUniqueName == owner; + }); + if (!ownerStillUsed) { + artifactOwnerWatcher_->removeWatchedService(owner); } - publishOperation(retryCacheOperationId_); } +} - printerRecoveryRequired_ = false; - printerRecoveryRemovalObserved_ = false; - printerDisplaySessionLost_ = false; - printerSessionLossRemovalObserved_ = false; - emit uploadStatus( - tr("PASE power-cycle was observed; starting a clean display session")); - return true; +void DeviceManager::sweepDeviceMediaArtifacts() { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + QStringList expired; + for (auto artifact = deviceMediaArtifacts_.cbegin(); + artifact != deviceMediaArtifacts_.cend(); ++artifact) { + if (artifact->inUseOperationId.isEmpty() && + artifact->expiresUtcMs <= now) { + expired.append(artifact.key()); + } + } + for (const QString &artifactId : std::as_const(expired)) { + removeDeviceMediaArtifact(artifactId); + } } -QString DeviceManager::normalizedOperationId(const QString &requestedId) const { - const QString trimmed = requestedId.trimmed(); - const QUuid parsed(trimmed); - if (!trimmed.isEmpty() && !parsed.isNull()) { - return parsed.toString(QUuid::WithoutBraces); +void DeviceManager::releaseArtifactOperationHold( + const QString &operationId) { + const auto operation = operations_.constFind(operationId); + if (operation == operations_.constEnd() || + operation->artifactId.isEmpty()) { + return; + } + const QString artifactId = operation->artifactId; + auto artifact = deviceMediaArtifacts_.find(artifactId); + if (artifact == deviceMediaArtifacts_.end() || + artifact->inUseOperationId != operationId) { + return; + } + artifact->inUseOperationId.clear(); + if (artifact->expiresUtcMs <= + QDateTime::currentMSecsSinceEpoch()) { + removeDeviceMediaArtifact(artifactId); } - return QUuid::createUuid().toString(QUuid::WithoutBraces); } bool DeviceManager::operationIsTerminal(const QString &state) const { @@ -6731,8 +9213,7 @@ QString DeviceManager::mediaCatalogDirectory() const { return mediaCatalogDirectoryOverride_; } #endif - return QDir(QStandardPaths::writableLocation( - QStandardPaths::AppLocalDataLocation)) + return QDir(panorama::sharedApplicationDataLocation()) .filePath(QStringLiteral("media-catalog")); } @@ -7066,6 +9547,7 @@ void DeviceManager::updateMediaCatalog( : 1U; entry.readOnly = media.readOnly; const QString key = mediaThumbnailKey(snapshot.deviceIdentity, entry); + entry.mediaId = key; if (!key.isEmpty() && mediaCatalogIndex_.contains(key)) { const QJsonObject stored = mediaCatalogIndex_.value(key).toObject(); @@ -7113,8 +9595,7 @@ QString DeviceManager::paseMetricsConfigDirectory() const { return paseMetricsConfigDirectoryOverride_; } #endif - return QStandardPaths::writableLocation( - QStandardPaths::AppLocalDataLocation); + return panorama::sharedApplicationDataLocation(); } QString DeviceManager::paseMetricsConfigPath() const { @@ -7684,74 +10165,714 @@ void DeviceManager::finishOperation(const QString &operationId, if (found == operations_.end() || operationIsTerminal(found->info.state)) { return; } - found->info.state = state; - found->info.stage = state; - found->info.errorCategory = errorCategory; - found->info.retryMode = retryMode; - found->info.message = message; - emit requestEndPrinterForegroundOperation( - operationId, found->info.deviceGeneration); - if (activeOperationId_ == operationId) { - activeOperationId_.clear(); + QString terminalMessage = message; + if (found->replaceOperation && + found->replaceJournalActive) { + const bool replacementMutationMayHaveStarted = + found->replaceJournal.applyMayHaveStarted || + found->replaceJournal.fileRemoveMayHaveStarted; + const bool reconciliationRequired = + retryMode == QStringLiteral("DeleteReconcile") || + (replacementMutationMayHaveStarted && + (retryMode == QStringLiteral("ReconcileOnly") || + errorCategory == QStringLiteral("PartialOrUnknown"))); + QString journalError; + if (reconciliationRequired) { + found->replaceJournal.disposition = + QStringLiteral("PartialOrUnknown"); + const QString journalStage = + found->replaceJournal.fileRemoveMayHaveStarted + ? QStringLiteral("DeleteReconciliation") + : found->replaceJournal.applyMayHaveStarted + ? QStringLiteral("ApplyVerification") + : found->replaceJournal.uploadVerified + ? QStringLiteral("UploadVerified") + : found->replaceJournal.stage; + if (!writeReplaceJournal( + operationId, journalStage, + &journalError)) { + terminalMessage += tr( + " Replace reconciliation state could not be persisted: %1") + .arg(journalError); + } + } else { + found->replaceJournal.disposition = + found->info.terminalOutcome == + QStringLiteral("Replaced") + ? QStringLiteral("Replaced") + : found->replaceJournal.uploadVerified + ? QStringLiteral("NewCopyReady") + : QStringLiteral("OriginalRetained"); + if (!writeReplaceJournal( + operationId, QStringLiteral("Terminal"), + &journalError)) { + terminalMessage += tr( + " Terminal replace state could not be persisted: %1") + .arg(journalError); + } else if (!clearReplaceJournal(&journalError)) { + terminalMessage += tr( + " Terminal replace journal could not be removed: %1") + .arg(journalError); + } + } + } + found->info.state = state; + found->info.stage = state; + found->info.errorCategory = errorCategory; + found->info.retryMode = retryMode; + found->info.message = terminalMessage; + emit requestEndPrinterForegroundOperation( + operationId, found->info.deviceGeneration); + if (activeOperationId_ == operationId) { + activeOperationId_.clear(); + } + if (worker_) { + worker_->clearPrinterOperationCancellation(operationId); + } + releaseArtifactOperationHold(operationId); + releaseOwnedSource(*found); + publishOperation(operationId); + pruneOperationHistory(); +} + +void DeviceManager::rejectOperation(const QString &operationId, + const QString &kind, + const QString &subject, + const QString &category, + const QString &message) { + OperationRecord record; + record.info.id = operationId; + record.info.kind = kind; + record.info.state = QStringLiteral("Failed"); + record.info.stage = QStringLiteral("Rejected"); + record.info.errorCategory = category; + record.info.subject = subject; + record.info.message = message; + record.info.deviceGeneration = printerGeneration_; + operations_.insert(operationId, record); + operationOrder_.append(operationId); + publishOperation(operationId); + pruneOperationHistory(); +} + +void DeviceManager::pruneOperationHistory() { + int terminalCount = 0; + for (const QString &operationId : std::as_const(operationOrder_)) { + const auto found = operations_.constFind(operationId); + if (found != operations_.constEnd() && + operationIsTerminal(found->info.state)) { + ++terminalCount; + } + } + while (terminalCount > kMaxTerminalOperationHistory) { + bool removed = false; + for (qsizetype index = 0; index < operationOrder_.size(); ++index) { + const QString operationId = operationOrder_.at(index); + const auto found = operations_.constFind(operationId); + if (found == operations_.constEnd() || + !operationIsTerminal(found->info.state) || + operationId == retryCacheOperationId_ || + (!found->artifactId.isEmpty() && + deviceMediaArtifacts_.contains( + found->artifactId))) { + continue; + } + operations_.remove(operationId); + operationOrder_.removeAt(index); + --terminalCount; + const quint64 revision = ++operationRevision_; + emit operationRemoved(operationId, revision); + removed = true; + break; + } + if (!removed) { + break; + } + } +} + +QString DeviceManager::queueStageDeviceMediaOperation( + const QString &requestedOperationId, const QString &mediaId, + const QString &ownerUniqueName) { + if (remoteMode_ || + !isValidDbusUniqueName(ownerUniqueName)) { + return {}; + } + const QString operationId = + normalizedOperationId(requestedOperationId); + if (operationId.isEmpty()) { + return {}; + } + const QString kind = QStringLiteral("StageDeviceMedia"); + if (operations_.contains(operationId)) { + const OperationRecord &existing = + operations_.value(operationId); + const auto existingArtifact = + deviceMediaArtifacts_.constFind(existing.artifactId); + return existing.info.kind == kind && + existing.artifactOwner == ownerUniqueName && + existing.originalMediaId == mediaId && + (existing.info.state == + QStringLiteral("Failed") || + (existingArtifact != + deviceMediaArtifacts_.constEnd() && + existingArtifact->metadata.mediaId == + mediaId)) + ? operationId + : QString(); + } + const auto reject = + [this, &operationId, &kind, &mediaId, + &ownerUniqueName]( + const QString &category, + const QString &message) { + rejectOperation(operationId, kind, mediaId, + category, message); + OperationRecord &record = + operations_[operationId]; + record.originalMediaId = mediaId; + record.artifactOwner = ownerUniqueName; + return operationId; + }; + if (firmwareExclusiveActive()) { + return reject( + QStringLiteral("FirmwareUpdateActive"), + firmwareExclusiveStatusText()); + } + if (!isSha256Hex(mediaId)) { + return reject( + QStringLiteral("InvalidMediaId"), + tr("The selected device media identity is invalid")); + } + if (!pendingRetryValidationId_.isEmpty()) { + return reject( + QStringLiteral("RetryCacheValidationPending"), + tr("Stored retry media is still being validated; retry this operation when startup validation finishes")); + } + if (!activeOperationId_.isEmpty()) { + return reject( + QStringLiteral("Busy"), + tr("Another operation is active: %1") + .arg(activeOperationId_)); + } + if (printerRecoveryRequired_ || + printerDisplaySessionLost_) { + return reject( + printerRecoveryRequired_ + ? QStringLiteral("DeviceRecoveryRequired") + : QStringLiteral("SessionLost"), + printerUnavailableStatusText()); + } + if (!printerDisplaySessionActive_) { + return reject( + QStringLiteral("SessionNotReady"), + printerUnavailableStatusText()); + } + const QString devicePath = currentPrinterPath(); + const QString deviceIdentity = + printerDeviceSerial_.trimmed(); + if (devicePath.isEmpty() || + deviceIdentity.isEmpty() || + mediaCatalog_.deviceIdentity != deviceIdentity) { + return reject( + QStringLiteral("DeviceIdentityUnavailable"), + tr("The current PASE media catalog is not associated with the active device")); + } + const TryxRuntimeMediaEntry *entry = + findMediaById(mediaId); + if (!entry || + mediaThumbnailKey(deviceIdentity, *entry) != mediaId || + entry->source != 1U || entry->readOnly || + entry->size == 0 || + entry->size > + static_cast(kMaxRetryCacheBytes) || + !PrinterProtocol::isSafeUploadMediaName(entry->name)) { + return reject( + QStringLiteral("DeviceMediaNotEligible"), + tr("Only an exact writable user media entry can be exported or edited")); + } + QString outboxError; + if (!ensureDeviceMediaOutbox(&outboxError)) { + return reject( + QStringLiteral("OutboxUnavailable"), + tr("The private device media outbox is unavailable: %1") + .arg(outboxError)); + } + + QString artifactId; + QString artifactPath; + for (int attempt = 0; attempt < 8; ++attempt) { + artifactId = QUuid::createUuid() + .toString(QUuid::WithoutBraces); + artifactPath = + QDir(deviceMediaOutboxDirectory()) + .filePath(artifactId + + QStringLiteral(".h264")); + struct stat status {}; + const QByteArray encoded = + QFile::encodeName(artifactPath); + if (!deviceMediaArtifacts_.contains(artifactId) && + ::lstat(encoded.constData(), &status) != 0 && + errno == ENOENT) { + break; + } + artifactId.clear(); + artifactPath.clear(); + } + if (artifactId.isEmpty()) { + return reject( + QStringLiteral("ArtifactAllocationFailed"), + tr("Could not allocate a unique device media artifact")); + } + + DeviceMediaArtifactRecord artifact; + artifact.metadata.schemaVersion = 1; + artifact.metadata.operationId = operationId; + artifact.metadata.artifactId = artifactId; + artifact.metadata.mediaId = mediaId; + artifact.metadata.deviceIdentity = deviceIdentity; + artifact.metadata.remoteName = entry->name; + artifact.metadata.size = entry->size; + artifact.metadata.logicalType = + QStringLiteral("Video"); + artifact.ownerUniqueName = ownerUniqueName; + artifact.canonicalPath = + cleanAbsolutePath(artifactPath); + artifact.inUseOperationId = operationId; + artifact.expiresUtcMs = + QDateTime::currentMSecsSinceEpoch() + + kDeviceMediaUnclaimedTtlMs; + deviceMediaArtifacts_.insert(artifactId, artifact); + watchArtifactOwner(ownerUniqueName); + + OperationRecord record; + record.info.id = operationId; + record.info.kind = kind; + record.info.state = QStringLiteral("Pulling"); + record.info.stage = + QStringLiteral("FreshCatalogPreflight"); + record.info.subject = entry->name; + record.info.message = + tr("Validating the current device media entry..."); + record.info.total = + static_cast(entry->size); + record.info.deviceGeneration = printerGeneration_; + record.artifactId = artifactId; + record.artifactOwner = ownerUniqueName; + record.originalMediaId = mediaId; + record.uploadDeviceIdentity = deviceIdentity; + record.uploadDeviceGeneration = printerGeneration_; + operations_.insert(operationId, record); + operationOrder_.append(operationId); + activeOperationId_ = operationId; + publishOperation(operationId); + emit requestBeginPrinterForegroundOperation( + operationId, printerGeneration_); + emit requestPrinterStageMedia( + devicePath, entry->name, + static_cast(entry->size), + artifact.canonicalPath, operationId, + printerGeneration_); + return operationId; +} + +TryxRuntimeDeviceMediaArtifact +DeviceManager::claimDeviceMediaArtifact( + const QString &operationId, const QString &artifactId, + const QString &ownerUniqueName, QString *errorMessage) { + const auto operation = operations_.constFind(operationId); + auto artifact = deviceMediaArtifacts_.find(artifactId); + if (operation == operations_.constEnd() || + operation->info.kind != + QStringLiteral("StageDeviceMedia") || + operation->info.state != QStringLiteral("Succeeded") || + operation->info.resultName != artifactId || + artifact == deviceMediaArtifacts_.end() || + artifact->metadata.operationId != operationId) { + if (errorMessage) { + *errorMessage = tr( + "The requested stage operation has no unclaimed artifact"); + } + return {}; + } + if (artifact->claimed) { + if (!validateArtifactRecord( + artifactId, ownerUniqueName, artifact->leaseId, + true, errorMessage)) { + return {}; + } + artifact->metadata.localPath = + artifact->canonicalPath; + artifact->metadata.leaseId = + artifact->leaseId; + artifact->metadata.leaseExpiresUtcMs = + artifact->expiresUtcMs; + return artifact->metadata; + } + if (!validateArtifactRecord( + artifactId, ownerUniqueName, QString(), + true, errorMessage)) { + return {}; + } + QString leaseId; + while (leaseId.isEmpty()) { + leaseId = QUuid::createUuid() + .toString(QUuid::WithoutBraces); + } + artifact->claimed = true; + artifact->leaseId = leaseId; + artifact->expiresUtcMs = + QDateTime::currentMSecsSinceEpoch() + + kDeviceMediaClaimLeaseMs; + artifact->metadata.localPath = + artifact->canonicalPath; + artifact->metadata.leaseId = leaseId; + artifact->metadata.leaseExpiresUtcMs = + artifact->expiresUtcMs; + return artifact->metadata; +} + +bool DeviceManager::renewDeviceMediaArtifactLease( + const QString &artifactId, const QString &leaseId, + const QString &ownerUniqueName, QString *errorMessage) { + const auto claimedArtifact = + deviceMediaArtifacts_.constFind(artifactId); + if (claimedArtifact == deviceMediaArtifacts_.constEnd() || + !claimedArtifact->claimed || leaseId.isEmpty()) { + if (errorMessage) { + *errorMessage = tr( + "The device media artifact has not been claimed"); + } + return false; + } + if (!validateArtifactRecord( + artifactId, ownerUniqueName, leaseId, + false, errorMessage)) { + return false; + } + auto artifact = deviceMediaArtifacts_.find(artifactId); + artifact->expiresUtcMs = + QDateTime::currentMSecsSinceEpoch() + + kDeviceMediaClaimLeaseMs; + artifact->metadata.leaseExpiresUtcMs = + artifact->expiresUtcMs; + return true; +} + +bool DeviceManager::releaseDeviceMediaArtifact( + const QString &artifactId, const QString &leaseId, + const QString &ownerUniqueName, QString *errorMessage) { + const auto claimedArtifact = + deviceMediaArtifacts_.constFind(artifactId); + if (claimedArtifact == deviceMediaArtifacts_.constEnd() || + !claimedArtifact->claimed || leaseId.isEmpty()) { + if (errorMessage) { + *errorMessage = tr( + "The device media artifact has not been claimed"); + } + return false; + } + if (!validateArtifactRecord( + artifactId, ownerUniqueName, leaseId, + false, errorMessage)) { + return false; } - if (worker_) { - worker_->clearPrinterOperationCancellation(operationId); + const auto artifact = + deviceMediaArtifacts_.constFind(artifactId); + if (artifact != deviceMediaArtifacts_.constEnd() && + !artifact->inUseOperationId.isEmpty()) { + if (errorMessage) { + *errorMessage = tr( + "The device media artifact is held by an active operation"); + } + return false; } - publishOperation(operationId); + removeDeviceMediaArtifact(artifactId); pruneOperationHistory(); + return true; } -void DeviceManager::rejectOperation(const QString &operationId, - const QString &kind, - const QString &subject, - const QString &category, - const QString &message) { +QString DeviceManager::queueRecoveredMediaUploadOperation( + const QString &requestedOperationId, + const QString &artifactId, const QString &leaseId, + const QString &ownerUniqueName, + const TryxRuntimeMediaTransform &transform) { + return queueRecoveredOperation( + requestedOperationId, artifactId, leaseId, + ownerUniqueName, transform, false, QString(), + TryxRuntimeApplyRequest{}); +} + +QString DeviceManager::queueReplaceDeviceMediaOperation( + const QString &requestedOperationId, + const QString &artifactId, const QString &leaseId, + const QString &originalMediaId, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform, + const QString &ownerUniqueName) { + return queueRecoveredOperation( + requestedOperationId, artifactId, leaseId, + ownerUniqueName, transform, true, + originalMediaId, request); +} + +QString DeviceManager::queueRecoveredOperation( + const QString &requestedOperationId, + const QString &artifactId, const QString &leaseId, + const QString &ownerUniqueName, + const TryxRuntimeMediaTransform &transform, bool replace, + const QString &originalMediaId, + const TryxRuntimeApplyRequest &applyRequest) { + if (remoteMode_ || + !isValidDbusUniqueName(ownerUniqueName)) { + return {}; + } + const QString operationId = + normalizedOperationId(requestedOperationId); + if (operationId.isEmpty()) { + return {}; + } + const QString kind = replace + ? QStringLiteral("ReplaceDeviceMedia") + : QStringLiteral("RecoveredMediaUpload"); + QString transformError; + const QString transformFingerprint = + tryxMediaTransformFingerprint(transform); + const QString transformRequestFingerprint = + runtimeMediaTransformRequestFingerprint( + transform); + const bool transformValid = + tryxMediaTransformIsValid( + transform, &transformError) && + !transformFingerprint.isEmpty(); + const QString requestedApplyFingerprint = + runtimeApplyRequestFingerprint(applyRequest); + if (operations_.contains(operationId)) { + const OperationRecord &existing = + operations_.value(operationId); + const bool sameReplaceRequest = + !replace || + (existing.originalMediaId == originalMediaId && + existing.requestedApplyFingerprint == + requestedApplyFingerprint); + return existing.info.kind == kind && + existing.artifactId == artifactId && + existing.artifactOwner == + ownerUniqueName && + existing.artifactLeaseId == leaseId && + runtimeMediaTransformRequestFingerprint( + existing.mediaTransform) == + transformRequestFingerprint && + sameReplaceRequest + ? operationId + : QString(); + } + QString artifactError; + if (!validateArtifactRecord( + artifactId, ownerUniqueName, leaseId, + true, &artifactError)) { + return {}; + } + auto artifact = + deviceMediaArtifacts_.find(artifactId); + if (artifact == deviceMediaArtifacts_.end() || + !artifact->claimed || leaseId.isEmpty() || + !artifact->inUseOperationId.isEmpty()) { + return {}; + } + const auto reject = + [this, &operationId, &kind, &artifact, + &artifactId, &leaseId, &ownerUniqueName, + &transform, &originalMediaId, + &requestedApplyFingerprint]( + const QString &category, + const QString &message) { + rejectOperation( + operationId, kind, + artifact->metadata.remoteName, + category, message); + OperationRecord &record = + operations_[operationId]; + record.artifactId = artifactId; + record.artifactOwner = ownerUniqueName; + record.artifactLeaseId = leaseId; + record.mediaTransform = transform; + record.originalMediaId = originalMediaId; + record.requestedApplyFingerprint = + requestedApplyFingerprint; + return operationId; + }; + if (firmwareExclusiveActive()) { + return reject( + QStringLiteral("FirmwareUpdateActive"), + firmwareExclusiveStatusText()); + } + if (!transformValid) { + return reject( + QStringLiteral("InvalidMediaTransform"), + tr("Media transform is invalid: %1") + .arg(transformError)); + } + if (!activeOperationId_.isEmpty()) { + return reject( + QStringLiteral("Busy"), + tr("Another operation is active: %1") + .arg(activeOperationId_)); + } + if (!pendingRetryValidationId_.isEmpty()) { + return reject( + QStringLiteral("RetryCacheValidationPending"), + tr("Stored retry media is still being validated; retry this operation when startup validation finishes")); + } + if (replace && + (!pendingDeleteOperationId_.isEmpty() || + QFileInfo::exists(deleteIntentPath()) || + !pendingReplaceJournalOperationId_.isEmpty() || + QFileInfo::exists(replaceIntentPath()))) { + return reject( + QStringLiteral("ReplaceReconciliationPending"), + tr("A previous replacement still requires read-only reconciliation")); + } + if (printerRecoveryRequired_ || + printerDisplaySessionLost_ || + !printerDisplaySessionActive_) { + return reject( + QStringLiteral("SessionNotReady"), + printerMutationUnavailableStatusText()); + } + const QString devicePath = currentPrinterPath(); + if (devicePath.isEmpty() || + artifact->metadata.deviceIdentity != + printerDeviceSerial_.trimmed()) { + return reject( + QStringLiteral("DeviceChanged"), + tr("The recovered copy belongs to a different PASE device")); + } + + TryxRuntimeApplyRequest effectiveApplyRequest = + applyRequest; + const TryxRuntimeMediaEntry *originalEntry = nullptr; + if (replace) { + originalEntry = findMediaById(originalMediaId); + if (!originalEntry || + originalMediaId != + artifact->metadata.mediaId || + originalEntry->name != + artifact->metadata.remoteName || + originalEntry->size != + artifact->metadata.size || + originalEntry->source != 1U || + originalEntry->readOnly || + mediaThumbnailKey( + printerDeviceSerial_.trimmed(), + *originalEntry) != originalMediaId) { + return reject( + QStringLiteral("OriginalMediaChanged"), + tr("The original media identity changed after the device copy was staged")); + } + const bool fullScreen = + effectiveApplyRequest.screenMode == + QStringLiteral("Full Screen"); + const bool splitScreen = + effectiveApplyRequest.screenMode == + QStringLiteral("Screen Splitting"); + const int expectedCount = splitScreen ? 2 : 1; + const int originalCount = + effectiveApplyRequest.media.count( + originalEntry->name); + const bool mediaNamesSafe = std::all_of( + effectiveApplyRequest.media.cbegin(), + effectiveApplyRequest.media.cend(), + [](const QString &name) { + return PrinterProtocol:: + isSafeUploadMediaName(name); + }); + if ((!fullScreen && !splitScreen) || + effectiveApplyRequest.media.size() != + expectedCount || + originalCount <= 0 || !mediaNamesSafe || + (splitScreen && + effectiveApplyRequest.playMode != + QStringLiteral("Single")) || + (fullScreen && + effectiveApplyRequest.playMode != + QStringLiteral("Single") && + effectiveApplyRequest.playMode != + QStringLiteral("Loop") && + effectiveApplyRequest.playMode != + QStringLiteral("Shuffle")) || + effectiveApplyRequest.display.standbyPresent) { + return reject( + QStringLiteral("UnsupportedReplaceConfiguration"), + tr("Replace requires an explicit current full-screen or split-screen layout that references the original media")); + } + } + + artifact->inUseOperationId = operationId; OperationRecord record; record.info.id = operationId; record.info.kind = kind; - record.info.state = QStringLiteral("Failed"); - record.info.stage = QStringLiteral("Rejected"); - record.info.errorCategory = category; - record.info.subject = subject; - record.info.message = message; + record.info.state = replace + ? QStringLiteral("Preflight") + : QStringLiteral("Converting"); + record.info.stage = replace + ? QStringLiteral("ReadingReferences") + : QStringLiteral("Converting"); + record.info.subject = + artifact->metadata.remoteName; + record.info.message = replace + ? tr("Checking every device reference before replacement...") + : tr("Preparing the recovered device copy as new media..."); record.info.deviceGeneration = printerGeneration_; + record.info.applyAfterUpload = replace; + record.sourcePath = artifact->canonicalPath; + record.sourceContentSha256 = + artifact->metadata.decodedSha256; + record.sourceSize = + static_cast(artifact->metadata.size); + record.conversionProfile = + QStringLiteral( + "pase-h264-v2-recovered-video-2240x1080-yuv420p-30fps-libx264-veryfast-crf23-transform-") + + transformFingerprint; + record.mediaTransform = transform; + record.sourceFingerprint = + sourceFingerprint(record.sourcePath); + record.artifactId = artifactId; + record.artifactOwner = ownerUniqueName; + record.artifactLeaseId = leaseId; + record.requestedApplyFingerprint = + requestedApplyFingerprint; + record.recoveredSource = true; + record.replaceOperation = replace; + record.originalMediaId = originalMediaId; + record.originalRemoteNameForReplace = + originalEntry ? originalEntry->name : QString(); + record.applyRequest = effectiveApplyRequest; + record.updateMetrics = + effectiveApplyRequest.replaceOverlay; + record.uploadDeviceIdentity = + printerDeviceSerial_.trimmed(); + record.uploadDeviceGeneration = printerGeneration_; operations_.insert(operationId, record); operationOrder_.append(operationId); + activeOperationId_ = operationId; publishOperation(operationId); - pruneOperationHistory(); -} - -void DeviceManager::pruneOperationHistory() { - int terminalCount = 0; - for (const QString &operationId : std::as_const(operationOrder_)) { - const auto found = operations_.constFind(operationId); - if (found != operations_.constEnd() && - operationIsTerminal(found->info.state)) { - ++terminalCount; - } - } - while (terminalCount > kMaxTerminalOperationHistory) { - bool removed = false; - for (qsizetype index = 0; index < operationOrder_.size(); ++index) { - const QString operationId = operationOrder_.at(index); - const auto found = operations_.constFind(operationId); - if (found == operations_.constEnd() || - !operationIsTerminal(found->info.state) || - operationId == retryCacheOperationId_) { - continue; - } - operations_.remove(operationId); - operationOrder_.removeAt(index); - --terminalCount; - const quint64 revision = ++operationRevision_; - emit operationRemoved(operationId, revision); - removed = true; - break; - } - if (!removed) { - break; - } + if (replace) { + emit requestBeginPrinterForegroundOperation( + operationId, printerGeneration_); + emit requestPrinterReplacePreflight( + devicePath, + record.originalRemoteNameForReplace, + record.sourceSize, + QString(), 0, + operationId, printerGeneration_); + } else { + emit requestPrepareRecoveredPrinterMedia( + operationId, devicePath, record.sourcePath, + record.sourceContentSha256, printerGeneration_, + record.mediaTransform); } + return operationId; } QString DeviceManager::queueUploadOperation(const QString &requestedOperationId, @@ -7759,14 +10880,47 @@ QString DeviceManager::queueUploadOperation(const QString &requestedOperationId, bool applyAfterUpload, const TryxRuntimeApplyRequest &applyRequest, bool updateMetrics, - bool ensureExisting) { + bool ensureExisting, + const TryxRuntimeMediaTransform &transform) { const QString operationId = normalizedOperationId(requestedOperationId); + if (operationId.isEmpty()) { + return {}; + } const QString kind = ensureExisting ? QStringLiteral("EnsureMediaAndApply") : applyAfterUpload ? QStringLiteral("UploadAndApply") : QStringLiteral("Upload"); const QString subject = QFileInfo(localPath).fileName(); + const QString inbox = mediaInboxDirectory(); + const QString managedRoot = inbox.isEmpty() + ? QString() + : QFileInfo(inbox).absolutePath(); + const QString canonicalSource = + QFileInfo(localPath).canonicalFilePath(); + const bool quickStagedRequest = + !remoteMode_ && !managedRoot.isEmpty() && + (pathIsInside(localPath, managedRoot) || + (!canonicalSource.isEmpty() && + pathIsInside(canonicalSource, managedRoot))); + const auto rejectedResult = [&]() { + return quickStagedRequest ? QString() : operationId; + }; + QString transformError; + if (!tryxMediaTransformIsValid(transform, &transformError)) { + if (!operations_.contains(operationId)) { + rejectOperation( + operationId, kind, subject, + QStringLiteral("InvalidMediaTransform"), + tr("Media transform is invalid: %1").arg(transformError)); + } + return operations_.contains(operationId) && + pathIsInside( + operations_.value(operationId).sourcePath, + mediaSpoolDirectory()) + ? operationId + : rejectedResult(); + } if (remoteMode_) { TryxRuntimeOperationInfo pending; pending.id = operationId; @@ -7776,36 +10930,51 @@ QString DeviceManager::queueUploadOperation(const QString &requestedOperationId, trackRemoteOperationRequest(pending); if (ensureExisting) { remoteOperationCall( - QStringLiteral("QueueEnsureMediaAndApply"), + QStringLiteral("QueueEnsureMediaAndApplyWithTransform"), {operationId, localPath, - QVariant::fromValue(applyRequest)}); + QVariant::fromValue(applyRequest), + QVariant::fromValue(transform)}); } else if (applyAfterUpload) { remoteOperationCall( - QStringLiteral("QueueUploadWithApply"), - {operationId, localPath, QVariant::fromValue(applyRequest)}); + QStringLiteral("QueueUploadWithApplyAndTransform"), + {operationId, localPath, QVariant::fromValue(applyRequest), + QVariant::fromValue(transform)}); } else { - remoteOperationCall(QStringLiteral("QueueUpload"), - {operationId, localPath, applyAfterUpload}); + remoteOperationCall( + QStringLiteral("QueueUploadWithTransform"), + {operationId, localPath, QVariant::fromValue(transform)}); } return operationId; } if (operations_.contains(operationId)) { - return operationId; + return quickStagedRequest && + !pathIsInside( + operations_.value(operationId).sourcePath, + mediaSpoolDirectory()) + ? QString() + : operationId; } + if (firmwareExclusiveActive()) { + rejectOperation( + operationId, kind, subject, + QStringLiteral("FirmwareUpdateActive"), + firmwareExclusiveStatusText()); + return rejectedResult(); + } if (!pendingRetryValidationId_.isEmpty()) { rejectOperation( operationId, kind, subject, QStringLiteral("RetryCacheValidationPending"), tr("Stored retry media is still being validated; retry this operation when startup validation finishes")); - return operationId; + return rejectedResult(); } if (!activeOperationId_.isEmpty()) { rejectOperation( operationId, kind, subject, QStringLiteral("Busy"), tr("Another operation is active: %1").arg(activeOperationId_)); - return operationId; + return rejectedResult(); } if (printerRecoveryRequired_ || printerDisplaySessionLost_) { rejectOperation( @@ -7814,28 +10983,28 @@ QString DeviceManager::queueUploadOperation(const QString &requestedOperationId, ? QStringLiteral("DeviceRecoveryRequired") : QStringLiteral("SessionLost"), printerMutationUnavailableStatusText()); - return operationId; + return rejectedResult(); } if (!printerDisplaySessionActive_) { rejectOperation( operationId, kind, subject, QStringLiteral("SessionNotReady"), printerMutationUnavailableStatusText()); - return operationId; + return rejectedResult(); } const QString devicePath = currentPrinterPath(); if (devicePath.isEmpty()) { rejectOperation(operationId, kind, subject, QStringLiteral("DeviceUnavailable"), printerUnavailableStatusText()); - return operationId; + return rejectedResult(); } if (printerDeviceSerial_.trimmed().isEmpty()) { rejectOperation( operationId, kind, subject, QStringLiteral("DeviceIdentityUnavailable"), tr("PASE identity is unavailable; upload cannot start safely")); - return operationId; + return rejectedResult(); } const QFileInfo sourceInfo(localPath); if (!sourceInfo.exists() || !sourceInfo.isFile() || @@ -7843,7 +11012,7 @@ QString DeviceManager::queueUploadOperation(const QString &requestedOperationId, rejectOperation(operationId, kind, subject, QStringLiteral("InvalidSource"), tr("Media file does not exist")); - return operationId; + return rejectedResult(); } TryxRuntimeApplyRequest normalizedApplyRequest = applyRequest; @@ -7914,10 +11083,25 @@ QString DeviceManager::queueUploadOperation(const QString &requestedOperationId, operationId, kind, subject, QStringLiteral("UnsupportedConfiguration"), tr("PASE upload-and-apply requires one full-screen media file, a supported play mode, up to three metrics and CPU/GPU badges")); - return operationId; + return rejectedResult(); } } + QString effectiveSourcePath = sourceInfo.absoluteFilePath(); + bool ownsSourcePath = false; + QString claimError; + if (!claimQuickStagedSource( + operationId, effectiveSourcePath, + &effectiveSourcePath, &ownsSourcePath, &claimError)) { + rejectOperation( + operationId, kind, subject, + QStringLiteral("InvalidStagedSource"), + claimError.isEmpty() + ? tr("The staged media source could not be claimed safely") + : claimError); + return rejectedResult(); + } + OperationRecord record; record.info.id = operationId; record.info.kind = kind; @@ -7936,31 +11120,36 @@ QString DeviceManager::queueUploadOperation(const QString &requestedOperationId, record.uploadDeviceIdentity = printerDeviceSerial_.trimmed(); record.uploadDeviceGeneration = printerGeneration_; record.applyRequest = normalizedApplyRequest; + record.mediaTransform = transform; record.updateMetrics = updateMetrics || normalizedApplyRequest.replaceOverlay; record.ensureExisting = ensureExisting; - record.sourcePath = sourceInfo.absoluteFilePath(); + record.sourcePath = effectiveSourcePath; record.sourceFingerprint = sourceFingerprint(record.sourcePath); + record.ownsSourcePath = ownsSourcePath; operations_.insert(operationId, record); operationOrder_.append(operationId); activeOperationId_ = operationId; publishOperation(operationId); if (ensureExisting) { emit requestAnalyzePrinterSource(operationId, record.sourcePath, - printerGeneration_); + printerGeneration_, + record.mediaTransform); } else { emit requestPreparePrinterMedia(operationId, devicePath, record.sourcePath, QString(), - printerGeneration_); + printerGeneration_, + record.mediaTransform); } return operationId; } QString DeviceManager::queueEnsureMediaAndApplyOperation( const QString &operationId, const QString &localPath, - const TryxRuntimeApplyRequest &applyRequest) { + const TryxRuntimeApplyRequest &applyRequest, + const TryxRuntimeMediaTransform &transform) { return queueUploadOperation(operationId, localPath, true, - applyRequest, true, true); + applyRequest, true, true, transform); } QString DeviceManager::queueDeleteMediaOperation( @@ -7968,6 +11157,9 @@ QString DeviceManager::queueDeleteMediaOperation( const QStringList &fileNames) { const QString operationId = normalizedOperationId(requestedOperationId); + if (operationId.isEmpty()) { + return {}; + } const QString kind = QStringLiteral("DeleteMedia"); const QString subject = fileNames.join(QStringLiteral(", ")); if (remoteMode_) { @@ -7983,6 +11175,13 @@ QString DeviceManager::queueDeleteMediaOperation( if (operations_.contains(operationId)) { return operationId; } + if (firmwareExclusiveActive()) { + rejectOperation( + operationId, kind, subject, + QStringLiteral("FirmwareUpdateActive"), + firmwareExclusiveStatusText()); + return operationId; + } if (!pendingDeleteOperationId_.isEmpty() || QFileInfo::exists(deleteIntentPath())) { rejectOperation( @@ -8078,6 +11277,7 @@ QString DeviceManager::queueDeleteMediaOperation( printerGeneration_); emit requestPrinterDeleteMedia( devicePath, fileNames, operationId, deleteIntentPath(), false, + 0, QString(), 0, printerGeneration_); return operationId; } @@ -8086,6 +11286,9 @@ QString DeviceManager::queueApplyOperation(const QString &requestedOperationId, const TryxRuntimeApplyRequest &request, bool updateMetrics) { const QString operationId = normalizedOperationId(requestedOperationId); + if (operationId.isEmpty()) { + return {}; + } TryxRuntimeApplyRequest normalizedRequest = request; if (normalizedRequest.screenMode.isEmpty()) { normalizedRequest.screenMode = QStringLiteral("Full Screen"); @@ -8151,6 +11354,13 @@ QString DeviceManager::queueApplyOperation(const QString &requestedOperationId, return operationId; } + if (firmwareExclusiveActive()) { + rejectOperation( + operationId, QStringLiteral("Apply"), subject, + QStringLiteral("FirmwareUpdateActive"), + firmwareExclusiveStatusText()); + return operationId; + } if (!pendingRetryValidationId_.isEmpty()) { rejectOperation( operationId, QStringLiteral("Apply"), subject, @@ -8282,6 +11492,9 @@ QString DeviceManager::queueMetricsConfigOperation( const QString &requestedOperationId, const TryxRuntimeMetricsConfigRequest &request) { const QString operationId = normalizedOperationId(requestedOperationId); + if (operationId.isEmpty()) { + return {}; + } const QString subject = request.enabled ? request.metrics.join(QStringLiteral(", ")) : tr("Disabled"); @@ -8299,6 +11512,13 @@ QString DeviceManager::queueMetricsConfigOperation( if (operations_.contains(operationId)) { return operationId; } + if (firmwareExclusiveActive()) { + rejectOperation( + operationId, QStringLiteral("MetricsConfig"), subject, + QStringLiteral("FirmwareUpdateActive"), + firmwareExclusiveStatusText()); + return operationId; + } if (!pendingRetryValidationId_.isEmpty()) { rejectOperation( operationId, QStringLiteral("MetricsConfig"), subject, @@ -8385,6 +11605,9 @@ QString DeviceManager::retryOperation(const QString &sourceOperationId, const QString &requestedNewOperationId) { const QString newOperationId = normalizedOperationId(requestedNewOperationId); + if (newOperationId.isEmpty()) { + return {}; + } if (remoteMode_) { const TryxRuntimeOperationInfo sourceInfo = operationInfo(sourceOperationId); @@ -8403,6 +11626,13 @@ QString DeviceManager::retryOperation(const QString &sourceOperationId, if (operations_.contains(newOperationId)) { return newOperationId; } + if (firmwareExclusiveActive()) { + rejectOperation( + newOperationId, QStringLiteral("UploadRetry"), + QString(), QStringLiteral("FirmwareUpdateActive"), + firmwareExclusiveStatusText()); + return newOperationId; + } const auto source = operations_.constFind(sourceOperationId); if (source == operations_.constEnd() || source->info.state != QStringLiteral("RetryAvailable") || @@ -8498,6 +11728,20 @@ QString DeviceManager::retryOperation(const QString &sourceOperationId, const OperationRecord sourceRecord = source.value(); OperationRecord record = sourceRecord; + if (record.replaceOperation) { + // A prepared-media retry may upload a new copy, but it must never + // resume the Apply/Delete portion of a previous Replace saga. + record.replaceOperation = false; + record.replaceJournalActive = false; + record.replaceJournal = {}; + record.originalMediaId.clear(); + record.originalRemoteNameForReplace.clear(); + record.replaceReferences.clear(); + record.replaceReferenceSlots.clear(); + record.info.applyAfterUpload = false; + record.applyRequest = {}; + record.updateMetrics = false; + } record.info.id = newOperationId; record.info.parentId = sourceOperationId; record.info.kind = QStringLiteral("UploadRetry"); @@ -8514,6 +11758,7 @@ QString DeviceManager::retryOperation(const QString &sourceOperationId, record.deviceChangeMessage.clear(); record.retryValidationPending = true; record.retryPreflight = false; + record.ownsSourcePath = false; record.info.resultName = record.remoteName; operations_.insert(newOperationId, record); operationOrder_.append(newOperationId); @@ -8762,6 +12007,255 @@ QString DeviceManager::deleteIntentPath() const { .filePath(QStringLiteral("delete-intent.json")); } +QString DeviceManager::replaceIntentPath() const { + return QDir(mediaCatalogDirectory()) + .filePath(QStringLiteral("replace-intent.json")); +} + +bool DeviceManager::writeReplaceJournal( + const QString &operationId, const QString &stage, + QString *errorMessage) { + auto found = operations_.find(operationId); + if (found == operations_.end() || + !found->replaceOperation || + !found->replaceJournalActive) { + if (errorMessage) { + *errorMessage = + tr("Replace journal metadata is unavailable"); + } + return false; + } + found->replaceJournal.stage = stage; + TryxReplaceJournal journal(replaceIntentPath()); + if (!journal.write(found->replaceJournal, errorMessage)) { + pendingReplaceJournalOperationId_ = operationId; + return false; + } + pendingReplaceJournalOperationId_ = operationId; + return true; +} + +bool DeviceManager::clearReplaceJournal(QString *errorMessage) { + TryxReplaceJournal journal(replaceIntentPath()); + if (!journal.clear(errorMessage)) { + return false; + } + const QString operationId = + pendingReplaceJournalOperationId_; + pendingReplaceJournalOperationId_.clear(); + if (!operationId.isEmpty()) { + auto found = operations_.find(operationId); + if (found != operations_.end()) { + found->replaceJournalActive = false; + } + } + return true; +} + +void DeviceManager::loadReplaceJournal() { + TryxReplaceJournal journal(replaceIntentPath()); + const TryxReplaceJournalLoadResult loaded = journal.load(); + if (loaded.status == TryxReplaceJournalLoadStatus::Missing) { + pendingReplaceJournalOperationId_.clear(); + return; + } + if (loaded.status == TryxReplaceJournalLoadStatus::Invalid) { + pendingReplaceJournalOperationId_ = + QStringLiteral("invalid-replace-intent"); + qWarning().noquote() + << "Invalid replace journal; replacements remain blocked:" + << loaded.error; + return; + } + if (loaded.record.stage == QStringLiteral("Terminal")) { + QString clearError; + if (!journal.clear(&clearError)) { + pendingReplaceJournalOperationId_ = + loaded.record.operationId; + qWarning().noquote() + << "Cannot clear terminal replace journal:" + << clearError; + } + return; + } + + const bool mutationOutcomeUnknown = + (loaded.record.applyMayHaveStarted && + !loaded.record.applyVerified) || + loaded.record.fileRemoveMayHaveStarted; + if (!mutationOutcomeUnknown) { + TryxReplaceJournalRecord terminal = + loaded.record; + terminal.stage = QStringLiteral("Terminal"); + terminal.disposition = + terminal.uploadVerified + ? QStringLiteral("NewCopyReady") + : QStringLiteral("OriginalRetained"); + QString recoveryError; + if (journal.write(terminal, &recoveryError) && + journal.clear(&recoveryError)) { + pendingReplaceJournalOperationId_.clear(); + OperationRecord recovered; + recovered.info.id = terminal.operationId; + recovered.info.kind = + QStringLiteral("ReplaceDeviceMedia"); + recovered.info.state = terminal.uploadVerified + ? QStringLiteral("Succeeded") + : QStringLiteral("Failed"); + recovered.info.stage = recovered.info.state; + recovered.info.subject = + terminal.originalRemoteName; + recovered.info.resultName = + terminal.newRemoteName; + recovered.info.deviceGeneration = + terminal.deviceGeneration; + recovered.info.terminalOutcome = + terminal.disposition; + recovered.info.errorCategory = + terminal.uploadVerified + ? QStringLiteral("OriginalRetained") + : QStringLiteral( + "InterruptedBeforeVerification"); + recovered.info.message = + terminal.uploadVerified + ? tr("A replacement upload was verified before restart. The new copy is ready; Apply and Delete were not resumed.") + : tr("A replacement stopped before upload was verified. The original media was retained."); + operations_.insert(recovered.info.id, + recovered); + operationOrder_.append(recovered.info.id); + publishOperation(recovered.info.id); + return; + } + qWarning().noquote() + << "Cannot settle safe replace journal after restart:" + << recoveryError; + } + + pendingReplaceJournalOperationId_ = + loaded.record.operationId; + OperationRecord record; + if (operations_.contains(loaded.record.operationId)) { + record = operations_.value(loaded.record.operationId); + } + record.info.id = loaded.record.operationId; + record.info.kind = QStringLiteral("ReplaceDeviceMedia"); + record.info.state = QStringLiteral("RetryAvailable"); + record.info.stage = QStringLiteral("ReconcileOnly"); + record.info.subject = loaded.record.originalRemoteName; + record.info.resultName = loaded.record.newRemoteName; + record.info.deviceGeneration = loaded.record.deviceGeneration; + record.info.retryMode = QStringLiteral("ReconcileOnly"); + record.info.terminalOutcome = + loaded.record.disposition; + record.info.errorCategory = mutationOutcomeUnknown + ? QStringLiteral("PartialOrUnknown") + : loaded.record.uploadVerified + ? QStringLiteral("NewCopyReady") + : QStringLiteral("OriginalRetained"); + record.info.message = mutationOutcomeUnknown + ? tr("A previous replacement stopped after a mutation may have started. Apply and Delete will not be repeated automatically.") + : loaded.record.uploadVerified + ? tr("A replacement upload was verified before restart. The new copy is ready; Apply and Delete were not resumed.") + : tr("A replacement stopped before upload was verified. The original media was retained."); + record.originalMediaId = + loaded.record.originalMediaId; + record.originalRemoteNameForReplace = + loaded.record.originalRemoteName; + record.artifactId = loaded.record.artifactId; + record.sourceContentSha256 = + loaded.record.decodedSha256; + record.uploadDeviceIdentity = + loaded.record.deviceIdentity; + record.uploadDeviceGeneration = + loaded.record.deviceGeneration; + record.remoteName = loaded.record.newRemoteName; + record.replaceOperation = true; + record.replaceJournalActive = true; + record.replaceJournal = loaded.record; + if (!operations_.contains(record.info.id)) { + operations_.insert(record.info.id, record); + operationOrder_.append(record.info.id); + } else { + operations_[record.info.id] = record; + } + publishOperation(record.info.id); +} + +void DeviceManager::resumePendingReplaceReconciliation() { + if (firmwareExclusiveActive() || + pendingReplaceJournalOperationId_.isEmpty() || + !pendingDeleteOperationId_.isEmpty() || + !activeOperationId_.isEmpty() || + !printerDisplaySessionActive_ || + currentPrinterPath().isEmpty() || + !operations_.contains( + pendingReplaceJournalOperationId_)) { + return; + } + OperationRecord &record = + operations_[pendingReplaceJournalOperationId_]; + if (!record.replaceOperation || + !record.replaceJournalActive || + record.replaceJournal.deviceIdentity != + printerDeviceSerial_.trimmed() || + !PrinterProtocol::isSafeUploadMediaName( + record.originalRemoteNameForReplace)) { + return; + } + record.deviceChangePending = false; + record.deviceChangeMessage.clear(); + if (record.replaceJournal.fileRemoveMayHaveStarted) { + record.info.state = QStringLiteral("Refreshing"); + record.info.stage = + QStringLiteral("ReconcilingUnknownDelete"); + record.info.retryMode.clear(); + record.info.message = tr( + "Re-reading FileList without repeating FileRemove..."); + record.info.deviceGeneration = printerGeneration_; + record.deleteNames = { + record.originalRemoteNameForReplace}; + record.deleteReconcileOnly = true; + activeOperationId_ = record.info.id; + publishOperation(record.info.id); + emit requestBeginPrinterForegroundOperation( + record.info.id, printerGeneration_); + emit requestPrinterDeleteMedia( + currentPrinterPath(), record.deleteNames, + record.info.id, deleteIntentPath(), true, + static_cast( + record.replaceJournal.originalSize), + record.replaceJournal.newRemoteName, + static_cast( + record.replaceJournal.newSize), + printerGeneration_); + return; + } + if (!record.replaceJournal.applyMayHaveStarted || + record.replaceJournal.applyVerified) { + return; + } + record.info.state = QStringLiteral("Refreshing"); + record.info.stage = + QStringLiteral("ReconcilingUnknownApply"); + record.info.retryMode.clear(); + record.info.message = tr( + "Re-reading media references without repeating Apply..."); + record.info.deviceGeneration = printerGeneration_; + activeOperationId_ = record.info.id; + publishOperation(record.info.id); + emit requestBeginPrinterForegroundOperation( + record.info.id, printerGeneration_); + emit requestPrinterReplacePreflight( + currentPrinterPath(), + record.originalRemoteNameForReplace, + static_cast( + record.replaceJournal.originalSize), + record.replaceJournal.newRemoteName, + static_cast( + record.replaceJournal.newSize), + record.info.id, printerGeneration_); +} + bool DeviceManager::writeDeleteIntent( const QString &operationId, const QString &stage, bool mayHaveStarted, int currentIndex, @@ -8935,7 +12429,9 @@ void DeviceManager::loadDeleteIntent() { record = operations_.value(operationId); } record.info.id = operationId; - record.info.kind = QStringLiteral("DeleteMedia"); + record.info.kind = record.replaceOperation + ? QStringLiteral("ReplaceDeviceMedia") + : QStringLiteral("DeleteMedia"); record.info.state = QStringLiteral("RetryAvailable"); record.info.stage = QStringLiteral("RetryAvailable"); record.info.errorCategory = QStringLiteral("PartialOrUnknown"); @@ -8957,7 +12453,8 @@ void DeviceManager::loadDeleteIntent() { } void DeviceManager::resumePendingDeleteReconciliation() { - if (pendingDeleteOperationId_.isEmpty() || + if (firmwareExclusiveActive() || + pendingDeleteOperationId_.isEmpty() || !pendingRetryValidationId_.isEmpty() || !activeOperationId_.isEmpty() || !printerDisplaySessionActive_ || @@ -8980,6 +12477,8 @@ void DeviceManager::resumePendingDeleteReconciliation() { } OperationRecord &record = operations_[pendingDeleteOperationId_]; + record.deviceChangePending = false; + record.deviceChangeMessage.clear(); record.info.state = QStringLiteral("Refreshing"); record.info.stage = QStringLiteral("ReconcilingDelete"); record.info.retryMode.clear(); @@ -8993,7 +12492,19 @@ void DeviceManager::resumePendingDeleteReconciliation() { printerGeneration_); emit requestPrinterDeleteMedia( currentPrinterPath(), QStringList{currentName}, record.info.id, - deleteIntentPath(), true, printerGeneration_); + deleteIntentPath(), true, + record.replaceOperation + ? static_cast( + record.replaceJournal.originalSize) + : 0, + record.replaceOperation + ? record.replaceJournal.newRemoteName + : QString(), + record.replaceOperation + ? static_cast( + record.replaceJournal.newSize) + : 0, + printerGeneration_); } QString DeviceManager::retryCacheManifestPath() const { @@ -9988,6 +13499,10 @@ void DeviceManager::setBrightness(int value) { remoteCall(QStringLiteral("SetBrightness"), {boundedValue}); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } if (isPrinterClassDevicePresent()) { TryxRuntimeApplyRequest request; request.display.brightnessPresent = true; @@ -10014,6 +13529,10 @@ void DeviceManager::setScreenConfig( sysinfoLabels2, settingsBadges2, waterfallMode}); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } if (isPrinterClassDevicePresent()) { TryxRuntimeApplyRequest request; request.media = media; @@ -10059,6 +13578,9 @@ void DeviceManager::sendSysinfo(const QStringList &labels, remoteCall(QStringLiteral("SendSysinfo"), {labels, values, units}); return; } + if (firmwareExclusiveActive()) { + return; + } if (isPrinterClassDevicePresent()) { const QString devicePath = currentPrinterPath(); if (devicePath.isEmpty() || !printerDisplaySessionActive_ || @@ -10079,6 +13601,10 @@ void DeviceManager::setRotation(int degrees) { remoteCall(QStringLiteral("SetRotation"), {degrees}); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } if (isPrinterClassDevicePresent()) { emit uploadStatus( tr("Rotation is not supported on printer-class firmware yet.")); @@ -10092,6 +13618,10 @@ void DeviceManager::rebootDevice() { remoteCall(QStringLiteral("RebootDevice")); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } if (isPrinterClassDevicePresent()) { emit uploadStatus( tr("Reboot is not supported on printer-class firmware yet.")); @@ -10105,6 +13635,10 @@ void DeviceManager::deleteMedia(const QStringList &files) { remoteCall(QStringLiteral("DeleteMedia"), {files}); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } if (isPrinterClassDevicePresent()) { emit uploadStatus( tr("Media deletion is disabled because USB file_remove has no dedicated response.")); @@ -10118,6 +13652,10 @@ void DeviceManager::uploadMedia(const QString &localPath) { remoteCall(QStringLiteral("UploadMedia"), {localPath}); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } if (isPrinterClassDevicePresent()) { queueUploadOperation(QString(), localPath, false); return; @@ -10135,6 +13673,10 @@ void DeviceManager::refreshMediaList() { remoteCall(QStringLiteral("RefreshMediaList")); return; } + if (firmwareExclusiveActive()) { + emit deviceError(firmwareExclusiveStatusText()); + return; + } if (isPrinterClassDevicePresent()) { const QString devicePath = currentPrinterPath(); if (devicePath.isEmpty()) { @@ -10170,6 +13712,9 @@ void DeviceManager::startKeepalive(int intervalSec) { remoteCall(QStringLiteral("StartKeepalive"), {intervalSec}); return; } + if (firmwareExclusiveActive()) { + return; + } if (printerClassConnected_) { keepaliveTimer_->stop(); return; diff --git a/src/devicemanager.h b/src/devicemanager.h index 857d624..2467c05 100644 --- a/src/devicemanager.h +++ b/src/devicemanager.h @@ -1,6 +1,7 @@ #pragma once #include "printerprotocol.h" +#include "replacejournal.h" #include "runtimebridge.h" #include @@ -52,11 +53,22 @@ class PrinterMediaPreparer : public QObject { public slots: void analyzeSource(const QString &operationId, const QString &localPath, - quint64 generation); + quint64 generation, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}); void prepare(const QString &operationId, const QString &devicePath, const QString &localPath, const QString &expectedSourceSha256, - quint64 generation); + quint64 generation, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}); + void prepareRecovered(const QString &operationId, + const QString &devicePath, + const QString &localPath, + const QString &expectedSourceSha256, + quint64 generation, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}); void cancelStale(quint64 currentGeneration); void cancelOperation(const QString &operationId); void validateRetryCache(const QString &validationId, @@ -96,7 +108,10 @@ public slots: const QString &devicePath, const QString &localPath, const QString &expectedSourceSha256, - quint64 generation); + quint64 generation, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}, + bool recoveredVideo = false); void finishPreparation(int exitCode, bool normalExit); void finishMediaPreparation(int exitCode, bool normalExit); void finishThumbnailPreparation(int exitCode, bool normalExit); @@ -117,6 +132,8 @@ public slots: QString stagedThumbnailPath_; QString preparedSha256_; QString expectedSourceSha256_; + TryxRuntimeMediaTransform transform_; + bool recoveredVideo_ = false; quint64 generation_ = 0; QByteArray processOutput_; PreparationPhase phase_ = PreparationPhase::Idle; @@ -129,6 +146,8 @@ public slots: QString pendingDevicePath_; QString pendingLocalPath_; QString pendingExpectedSourceSha256_; + TryxRuntimeMediaTransform pendingTransform_; + bool pendingRecoveredVideo_ = false; quint64 pendingGeneration_ = 0; QSet deliveredPaths_; mutable QMutex retryValidationMutex_; @@ -187,6 +206,7 @@ public slots: void sendKeepalive(); void sendSysinfo(const QStringList &labels, const QStringList &values, const QStringList &units); + void sendLegacyMetrics(); void configurePrinterDevice(const QString &devicePath, const QString &deviceSerial, @@ -211,11 +231,27 @@ public slots: void refreshPrinterMediaList(const QString &devicePath, const QString &operationId, quint64 generation); + void stagePrinterMedia(const QString &devicePath, + const QString &mediaName, + qint64 expectedSize, + const QString &outputPath, + const QString &operationId, + quint64 generation); + void preflightReplacePrinterMedia( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, + const QString &operationId, + quint64 generation); void deletePrinterMedia(const QString &devicePath, const QStringList &fileNames, const QString &operationId, const QString &deleteIntentPath, bool reconcileOnly, + qint64 expectedSingleSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, quint64 generation); void applyPrinterMedia(const QString &devicePath, const QString &mediaFile, const TryxRuntimeApplyRequest &request, @@ -233,6 +269,10 @@ public slots: quint64 generation); void startPrinterDisplaySession(const QString &devicePath, quint64 generation); + void quiesceForFirmware(const QString &leaseId, + quint64 generation); + void releaseFirmwareQuiesceFence(const QString &leaseId, + quint64 generation); signals: void connected(const QString &productId, const QString &serial, @@ -279,6 +319,22 @@ public slots: void printerMediaListFailed(const QString &operationId, const QString &message, quint64 generation); + void printerMediaStaged( + const QString &operationId, const QString &mediaName, + const QString &outputPath, bool success, bool cancelled, + qint64 fileSize, qint64 chunkCount, + const QString &rawSha256, const QString &decodedSha256, + const QString &errorMessage, quint64 generation); + void printerReplacePreflightFinished( + const QString &operationId, const QString &mediaName, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, + const QStringList &references, + const QStringList &referencingSlots, + bool originalIdentityVerified, + bool replacementIdentityVerified, + bool success, + const QString &errorMessage, quint64 generation); void printerDeleteFinished( const QString &operationId, const QStringList &requestedNames, @@ -303,6 +359,10 @@ public slots: void printerSessionStarted(quint64 generation); void printerSessionStopped(quint64 generation); void printerSessionLost(quint64 generation); + void firmwareTransportQuiesced(const QString &leaseId, + quint64 generation); + void firmwareQuiesceReleaseFenceReached( + const QString &leaseId, quint64 generation); private slots: void sendPrinterKeepalive(); @@ -354,6 +414,7 @@ private slots: std::unique_ptr device_; std::unique_ptr printerProtocol_; + QTimer *legacyMetricsTimer_; QTimer *printerKeepaliveTimer_; QTimer *printerMetricsTimer_; QTimer *printerRecoveryTimer_; @@ -452,11 +513,15 @@ public slots: bool applyAfterUpload = false, const TryxRuntimeApplyRequest &applyRequest = {}, bool updateMetrics = false, - bool ensureExisting = false); + bool ensureExisting = false, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}); QString queueEnsureMediaAndApplyOperation( const QString &operationId, const QString &localPath, - const TryxRuntimeApplyRequest &applyRequest); + const TryxRuntimeApplyRequest &applyRequest, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}); QString queueDeleteMediaOperation(const QString &operationId, const QStringList &fileNames); QString queueApplyOperation(const QString &operationId, @@ -465,6 +530,31 @@ public slots: QString queueMetricsConfigOperation( const QString &operationId, const TryxRuntimeMetricsConfigRequest &request); + QString queueStageDeviceMediaOperation( + const QString &operationId, const QString &mediaId, + const QString &ownerUniqueName); + TryxRuntimeDeviceMediaArtifact claimDeviceMediaArtifact( + const QString &operationId, const QString &artifactId, + const QString &ownerUniqueName, + QString *errorMessage = nullptr); + bool renewDeviceMediaArtifactLease( + const QString &artifactId, const QString &leaseId, + const QString &ownerUniqueName, + QString *errorMessage = nullptr); + bool releaseDeviceMediaArtifact( + const QString &artifactId, const QString &leaseId, + const QString &ownerUniqueName, + QString *errorMessage = nullptr); + QString queueRecoveredMediaUploadOperation( + const QString &operationId, const QString &artifactId, + const QString &leaseId, const QString &ownerUniqueName, + const TryxRuntimeMediaTransform &transform); + QString queueReplaceDeviceMediaOperation( + const QString &operationId, const QString &artifactId, + const QString &leaseId, const QString &originalMediaId, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform, + const QString &ownerUniqueName); QString retryOperation(const QString &sourceOperationId, const QString &newOperationId); void cancelOperation(const QString &operationId); @@ -478,6 +568,18 @@ public slots: TryxRuntimeDisplayState displayState() const { return displayState_; } TryxRuntimeMediaCatalogSnapshot mediaCatalogSnapshot() const; QString mediaThumbnailPath(const QString &thumbnailKey) const; + bool acquireFirmwareExclusive(const QString &leaseId, + QString *errorMessage = nullptr); + void releaseFirmwareExclusive(const QString &leaseId, + bool resumeTransport = true); + void setFirmwareRecoveryInterlockActive(bool active); + void resumeConnectionAfterFirmwareRecoveryAcknowledgement(); + bool firmwareExclusiveActive() const { + return !firmwareExclusiveLeaseId_.isEmpty(); + } + bool firmwareRecoveryInterlockActive() const { + return firmwareRecoveryInterlockActive_; + } signals: void deviceConnected(const QString &productId, const QString &serial, @@ -506,6 +608,9 @@ public slots: const TryxRuntimeOperationsSnapshot &snapshot); void metricsStateUpdated(const TryxRuntimeMetricsState &state); void displayStateUpdated(const TryxRuntimeDisplayState &state); + void firmwareTransportQuiesced(const QString &leaseId, + bool success, + const QString &message); #ifdef TRYX_PROTOCOL_TESTING void printerWorkerDeviceInfoFailedForTesting(const QString &message, quint64 generation); @@ -551,12 +656,22 @@ public slots: quint64 generation); void requestAnalyzePrinterSource(const QString &operationId, const QString &localPath, - quint64 generation); + quint64 generation, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}); void requestPreparePrinterMedia(const QString &operationId, const QString &devicePath, const QString &localPath, const QString &expectedSourceSha256, - quint64 generation); + quint64 generation, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}); + void requestPrepareRecoveredPrinterMedia( + const QString &operationId, const QString &devicePath, + const QString &localPath, + const QString &expectedSourceSha256, quint64 generation, + const TryxRuntimeMediaTransform &transform = + TryxRuntimeMediaTransform{}); void requestCancelPrinterPreparation(quint64 currentGeneration); void requestCancelPrinterPreparationOperation(const QString &operationId); void requestReleasePrinterPreparation(const QString &uploadPath); @@ -572,11 +687,27 @@ public slots: void requestPrinterRefreshMedia(const QString &devicePath, const QString &operationId, quint64 generation); + void requestPrinterStageMedia(const QString &devicePath, + const QString &mediaName, + qint64 expectedSize, + const QString &outputPath, + const QString &operationId, + quint64 generation); + void requestPrinterReplacePreflight( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, + const QString &operationId, + quint64 generation); void requestPrinterDeleteMedia(const QString &devicePath, const QStringList &fileNames, const QString &operationId, const QString &deleteIntentPath, bool reconcileOnly, + qint64 expectedSingleSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, quint64 generation); void requestPrinterApplyMedia(const QString &devicePath, const QString &mediaFile, const TryxRuntimeApplyRequest &request, @@ -594,8 +725,14 @@ public slots: quint64 generation); void requestStartPrinterSession(const QString &devicePath, quint64 generation); + void requestFirmwareTransportQuiesce(const QString &leaseId, + quint64 generation); + void requestFirmwareQuiesceReleaseFence( + const QString &leaseId, quint64 generation); private: + struct OperationRecord; + #ifdef TRYX_PROTOCOL_TESTING friend class PrinterProtocolTests; #endif @@ -626,12 +763,45 @@ public slots: QString currentPrinterPath() const; QString printerUnavailableStatusText() const; QString printerMutationUnavailableStatusText() const; + QString firmwareExclusiveStatusText() const; void resumePrinterSessionAfterRetryCacheValidation(); bool completePrinterRecoveryAfterRemoval( const QString ¤tDeviceIdentity); void requirePrinterRecovery(const QString &message); QString normalizedOperationId(const QString &requestedId) const; bool operationIsTerminal(const QString &state) const; + QString mediaInboxDirectory() const; + QString mediaSpoolDirectory() const; + bool ensureMediaRuntimeDirectories( + QString *errorMessage = nullptr) const; + bool claimQuickStagedSource( + const QString &operationId, const QString &sourcePath, + QString *claimedPath, bool *owned, + QString *errorMessage = nullptr) const; + QString deviceMediaOutboxDirectory() const; + bool ensureDeviceMediaOutbox( + QString *errorMessage = nullptr) const; + void cleanupDeviceMediaOutbox(); + void sweepDeviceMediaArtifacts(); + void removeDeviceMediaArtifact(const QString &artifactId); + void releaseArtifactOperationHold(const QString &operationId); + bool validateArtifactRecord( + const QString &artifactId, const QString &ownerUniqueName, + const QString &leaseId, bool verifyHash, + QString *errorMessage = nullptr) const; + const TryxRuntimeMediaEntry *findMediaById( + const QString &mediaId) const; + static bool isValidDbusUniqueName(const QString &ownerUniqueName); + void watchArtifactOwner(const QString &ownerUniqueName); + void handleArtifactOwnerUnregistered(const QString &ownerUniqueName); + QString queueRecoveredOperation( + const QString &operationId, const QString &artifactId, + const QString &leaseId, const QString &ownerUniqueName, + const TryxRuntimeMediaTransform &transform, bool replace, + const QString &originalMediaId, + const TryxRuntimeApplyRequest &applyRequest); + void releaseOwnedSource(OperationRecord &record); + void cleanupMediaRuntimeStaging(); void publishOperation(const QString &operationId); void publishMetricsState(); void publishDisplayState(); @@ -694,6 +864,13 @@ public slots: void removePreparedFileForOperation(const QString &operationId); void preserveActivePreparedMediaForShutdown(); QString deleteIntentPath() const; + QString replaceIntentPath() const; + bool writeReplaceJournal(const QString &operationId, + const QString &stage, + QString *errorMessage = nullptr); + bool clearReplaceJournal(QString *errorMessage = nullptr); + void loadReplaceJournal(); + void resumePendingReplaceReconciliation(); bool writeDeleteIntent(const QString &operationId, const QString &stage, bool mayHaveStarted, @@ -765,6 +942,7 @@ private slots: QStringList deleteNames; QStringList deletedNames; TryxRuntimeApplyRequest applyRequest; + TryxRuntimeMediaTransform mediaTransform; TryxRuntimeMetricsConfigRequest metricsRequest; bool updateMetrics = false; bool ensureExisting = false; @@ -777,9 +955,34 @@ private slots: bool retryPreflight = false; bool requiresDeviceRecovery = false; bool retryMustUseNewRemoteName = false; + bool ownsSourcePath = false; QString uploadDeviceIdentity; quint64 uploadDeviceGeneration = 0; bool uploadFinalizationReconciliationPending = false; + QString artifactId; + QString originalMediaId; + QString originalRemoteNameForReplace; + QStringList replaceReferences; + QStringList replaceReferenceSlots; + QString artifactOwner; + QString artifactLeaseId; + QString requestedApplyFingerprint; + bool recoveredSource = false; + bool replaceOperation = false; + bool replaceJournalActive = false; + TryxReplaceJournalRecord replaceJournal; + }; + + struct DeviceMediaArtifactRecord { + TryxRuntimeDeviceMediaArtifact metadata; + QString ownerUniqueName; + QString canonicalPath; + QString leaseId; + QString inUseOperationId; + qint64 expiresUtcMs = 0; + quint64 deviceNumber = 0; + quint64 inodeNumber = 0; + bool claimed = false; }; QThread workerThread_; @@ -791,6 +994,8 @@ private slots: QDBusInterface *remoteInterface_ = nullptr; QDBusInterface *remoteOperationsInterface_ = nullptr; QDBusServiceWatcher *remoteServiceWatcher_ = nullptr; + QDBusServiceWatcher *artifactOwnerWatcher_ = nullptr; + QTimer *artifactSweepTimer_ = nullptr; PrinterProtocol::DiscoverySnapshot printerSnapshot_; QString printerDevicePath_; QString printerDeviceSerial_; @@ -817,6 +1022,7 @@ private slots: TryxRuntimeMetricsState metricsState_; TryxRuntimeDisplayState displayState_; QHash operations_; + QHash deviceMediaArtifacts_; QStringList operationOrder_; QString activeOperationId_; QString retryCacheOperationId_; @@ -826,6 +1032,10 @@ private slots: QJsonObject pendingRetryManifest_; QString pendingDeleteOperationId_; QJsonObject pendingDeleteIntent_; + QString pendingReplaceJournalOperationId_; + QString firmwareExclusiveLeaseId_; + QString firmwareReleasePendingLeaseId_; + quint64 firmwareQuiesceGeneration_ = 0; bool connected_ = false; bool printerClassConnected_ = false; bool printerDisplaySessionActive_ = false; @@ -835,6 +1045,10 @@ private slots: bool printerRecoveryRemovalObserved_ = false; bool printerSessionResumePending_ = false; bool autoConnectMode_ = false; + bool firmwareResumeAutoConnect_ = false; + bool firmwareReleaseResumeTransport_ = false; + bool firmwareRecoveryReconnectRequested_ = false; + bool firmwareRecoveryInterlockActive_ = false; bool automaticPrinterSessionStart_ = true; bool remoteMode_ = false; bool remoteApiCompatible_ = false; @@ -844,5 +1058,6 @@ private slots: QString retryCacheDirectoryOverride_; QString mediaCatalogDirectoryOverride_; QString paseMetricsConfigDirectoryOverride_; + QString mediaRuntimeRootOverride_; #endif }; diff --git a/src/displaypage.cpp b/src/displaypage.cpp deleted file mode 100644 index ad5b6d1..0000000 --- a/src/displaypage.cpp +++ /dev/null @@ -1,526 +0,0 @@ -#include "displaypage.h" -#include "devicemanager.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static const int TILE_WIDTH = 200; -static const int TILE_IMG_HEIGHT = 100; - -// --- MediaTile --- - -MediaTile::MediaTile(const MediaEntry &entry, QWidget *parent) - : QFrame(parent), entry_(entry) { - setFixedSize(TILE_WIDTH, TILE_IMG_HEIGHT + 50); - setCursor(Qt::PointingHandCursor); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(4, 4, 4, 4); - layout->setSpacing(2); - - imageLabel_ = new QLabel; - imageLabel_->setFixedSize(TILE_WIDTH - 8, TILE_IMG_HEIGHT); - imageLabel_->setAlignment(Qt::AlignCenter); - imageLabel_->setStyleSheet("background: #1a1a1a; border-radius: 4px;"); - imageLabel_->setText("..."); - layout->addWidget(imageLabel_); - - nameLabel_ = new QLabel(entry_.fileName); - nameLabel_->setAlignment(Qt::AlignCenter); - nameLabel_->setWordWrap(false); - QFont nameFont = nameLabel_->font(); - nameFont.setPointSize(8); - nameFont.setBold(true); - nameLabel_->setFont(nameFont); - nameLabel_->setMaximumWidth(TILE_WIDTH - 8); - layout->addWidget(nameLabel_); - - const double sizeMB = - static_cast(entry_.sizeBytes) / (1024.0 * 1024.0); - infoLabel_ = new QLabel(QString("%1 MB %2").arg(sizeMB, 0, 'f', 1).arg(entry_.format)); - infoLabel_->setAlignment(Qt::AlignCenter); - QFont infoFont = infoLabel_->font(); - infoFont.setPointSize(7); - infoLabel_->setFont(infoFont); - infoLabel_->setStyleSheet("color: #888;"); - layout->addWidget(infoLabel_); - - updateStyle(); -} - -void MediaTile::setSelected(bool sel) { - selected_ = sel; - updateStyle(); -} - -void MediaTile::setThumbnail(const QPixmap &pix) { - thumb_ = pix; - if (!pix.isNull()) { - imageLabel_->setPixmap(pix.scaled(imageLabel_->size(), - Qt::KeepAspectRatio, - Qt::SmoothTransformation)); - imageLabel_->setText({}); - } -} - -void MediaTile::setNeutralPlaceholder(const QString &text) { - thumb_ = {}; - imageLabel_->setPixmap({}); - imageLabel_->setText(text); - imageLabel_->setWordWrap(true); - imageLabel_->setStyleSheet( - "background: #20202c;" - "border: 1px solid #3d3d4d;" - "border-radius: 4px;" - "color: #9a9aaa;" - "padding: 8px;"); -} - -void MediaTile::mousePressEvent(QMouseEvent *event) { - if (event->button() == Qt::LeftButton) { - emit clicked(this); - } - QFrame::mousePressEvent(event); -} - -void MediaTile::updateStyle() { - if (selected_) { - setStyleSheet( - "MediaTile {" - " border: 2px solid #4CAF50;" - " border-radius: 6px;" - " background: rgba(76, 175, 80, 40);" - "}"); - } else { - setStyleSheet( - "MediaTile {" - " border: 2px solid transparent;" - " border-radius: 6px;" - " background: #2a2a2a;" - "}" - "MediaTile:hover {" - " border: 2px solid #555;" - " background: #333;" - "}"); - } -} - -// --- DisplayPage --- - -DisplayPage::DisplayPage(DeviceManager *deviceMgr, QWidget *parent) - : QWidget(parent), deviceMgr_(deviceMgr) { - setupUi(); - - connect(deviceMgr_, &DeviceManager::mediaListUpdated, this, &DisplayPage::onMediaListUpdated); - connect(deviceMgr_, &DeviceManager::mediaUploaded, this, &DisplayPage::onMediaUploaded); - connect(deviceMgr_, &DeviceManager::mediaDeleted, this, &DisplayPage::onMediaDeleted); - connect(deviceMgr_, &DeviceManager::uploadStatus, this, &DisplayPage::onUploadStatus); - connect(deviceMgr_, &DeviceManager::operationChanged, this, - &DisplayPage::onOperationChanged); - connect(deviceMgr_, &DeviceManager::operationSnapshotUpdated, this, - [this](const TryxRuntimeOperationsSnapshot &snapshot) { - if (snapshot.activeOperationId.isEmpty()) { - return; - } - activeOperationId_ = snapshot.activeOperationId; - onOperationChanged( - deviceMgr_->operationInfo(activeOperationId_), - snapshot.revision); - }); - connect(deviceMgr_, &DeviceManager::deviceError, this, - [this](const QString &message) { - refreshBtn_->setEnabled(!uploadBusy_); - emit statusMessage(message); - }); - connect(deviceMgr_, &DeviceManager::brightnessChanged, this, - [this](int val) { - const QSignalBlocker blocker(brightnessSlider_); - brightnessSlider_->setValue(val); - brightnessLabel_->setText(QString::number(val)); - }); -} - -void DisplayPage::setupUi() { - setAcceptDrops(true); - - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setSpacing(12); - - // Drop zone - dropZone_ = new QLabel(tr("Drag a file here\n(MP4, GIF, JPG, PNG)")); - dropZone_->setAlignment(Qt::AlignCenter); - dropZone_->setMinimumHeight(80); - dropZone_->setStyleSheet( - "QLabel {" - " border: 2px dashed #888;" - " border-radius: 8px;" - " padding: 20px;" - " color: #aaa;" - " font-size: 14px;" - "}"); - mainLayout->addWidget(dropZone_); - - // Upload button - auto *uploadLayout = new QHBoxLayout; - uploadBtn_ = new QPushButton(tr("Upload file...")); - uploadLayout->addWidget(uploadBtn_); - progressBar_ = new QProgressBar; - progressBar_->setRange(0, 0); - progressBar_->setVisible(false); - progressBar_->setMaximumHeight(20); - uploadLayout->addWidget(progressBar_); - retryBtn_ = new QPushButton(tr("Retry transfer")); - retryBtn_->setVisible(false); - uploadLayout->addWidget(retryBtn_); - cancelBtn_ = new QPushButton(tr("Cancel")); - cancelBtn_->setVisible(false); - uploadLayout->addWidget(cancelBtn_); - mainLayout->addLayout(uploadLayout); - - connect(uploadBtn_, &QPushButton::clicked, this, &DisplayPage::onUploadClicked); - connect(retryBtn_, &QPushButton::clicked, this, - &DisplayPage::onRetryClicked); - connect(cancelBtn_, &QPushButton::clicked, this, - &DisplayPage::onCancelClicked); - - // File list - auto *fileGroup = new QGroupBox(tr("Files on device")); - auto *fileLayout = new QVBoxLayout(fileGroup); - fileList_ = new QListWidget; - fileList_->setSelectionMode(QAbstractItemView::ExtendedSelection); - fileLayout->addWidget(fileList_); - - auto *fileBtnLayout = new QHBoxLayout; - setDisplayBtn_ = new QPushButton(tr("Set on display")); - deleteBtn_ = new QPushButton(tr("Delete")); - refreshBtn_ = new QPushButton(tr("Refresh")); - fileBtnLayout->addWidget(setDisplayBtn_); - fileBtnLayout->addWidget(deleteBtn_); - fileBtnLayout->addWidget(refreshBtn_); - fileLayout->addLayout(fileBtnLayout); - - mainLayout->addWidget(fileGroup); - - connect(setDisplayBtn_, &QPushButton::clicked, this, &DisplayPage::onSetDisplayClicked); - connect(deleteBtn_, &QPushButton::clicked, this, &DisplayPage::onDeleteClicked); - connect(refreshBtn_, &QPushButton::clicked, this, &DisplayPage::onRefreshClicked); - - // Brightness - auto *brightnessGroup = new QGroupBox(tr("Brightness")); - auto *brightnessLayout = new QHBoxLayout(brightnessGroup); - brightnessSlider_ = new QSlider(Qt::Horizontal); - brightnessSlider_->setRange(0, 100); - brightnessSlider_->setValue(0); - brightnessLabel_ = new QLabel(QStringLiteral("--")); - brightnessLabel_->setMinimumWidth(30); - brightnessLayout->addWidget(brightnessSlider_); - brightnessLayout->addWidget(brightnessLabel_); - mainLayout->addWidget(brightnessGroup); - - connect(brightnessSlider_, &QSlider::valueChanged, this, - [this](int val) { brightnessLabel_->setText(QString::number(val)); }); - connect(brightnessSlider_, &QSlider::sliderReleased, this, - [this]() { onBrightnessChanged(brightnessSlider_->value()); }); - - // Ratio - auto *optionsLayout = new QHBoxLayout; - optionsLayout->addWidget(new QLabel(tr("Ratio:"))); - ratioCombo_ = new QComboBox; - ratioCombo_->addItems({"2:1", "1:1"}); - optionsLayout->addWidget(ratioCombo_); - optionsLayout->addStretch(); - mainLayout->addLayout(optionsLayout); - - mainLayout->addStretch(); -} - -void DisplayPage::onUploadClicked() { - QString path = QFileDialog::getOpenFileName( - this, tr("Select media file"), QString(), - "Media (*.mp4 *.webm *.mkv *.avi *.mov *.gif *.jpg *.jpeg *.png *.bmp *.webp)"); - - if (!path.isEmpty()) { - setUploadBusy(true); - if (deviceMgr_->isPrinterClassDevicePresent()) { - activeOperationId_ = - deviceMgr_->queueUploadOperation(QString(), path, false); - } else { - deviceMgr_->uploadMedia(path); - } - } -} - -void DisplayPage::onSetDisplayClicked() { - auto selected = fileList_->selectedItems(); - if (selected.isEmpty()) { - emit statusMessage(tr("Select files to display")); - return; - } - if (deviceMgr_->isPrinterClassDevicePresent() && selected.size() > 1) { - emit statusMessage(tr("Only one media file can be applied on printer-class firmware yet.")); - return; - } - - QStringList media; - for (auto *item : selected) { - media << item->text(); - } - - if (deviceMgr_->isPrinterClassDevicePresent()) { - TryxRuntimeApplyRequest request; - request.media = media; - request.ratio = ratioCombo_->currentText(); - request.screenMode = QStringLiteral("Full Screen"); - request.playMode = QStringLiteral("Single"); - activeOperationId_ = - deviceMgr_->queueApplyOperation(QString(), request); - setUploadBusy(true); - } else { - deviceMgr_->setScreenConfig(media, ratioCombo_->currentText()); - emit statusMessage(tr("Display configuration set")); - } -} - -void DisplayPage::onDeleteClicked() { - auto selected = fileList_->selectedItems(); - if (selected.isEmpty()) { - emit statusMessage(tr("Select files to delete")); - return; - } - - QStringList files; - for (auto *item : selected) { - files << item->text(); - } - - if (deviceMgr_->isPrinterClassDevicePresent()) { - if (files.size() != 1) { - emit statusMessage( - tr("Select exactly one PASE media file to delete")); - return; - } - TryxRuntimeMediaEntry matched; - bool found = false; - for (const TryxRuntimeMediaEntry &entry : - deviceMgr_->mediaCatalogSnapshot().entries) { - if (entry.name == files.constFirst()) { - matched = entry; - found = true; - break; - } - } - if (!found || !matched.deleteAllowed) { - emit statusMessage( - matched.deleteBlockReason.isEmpty() - ? tr("This PASE media file cannot be deleted") - : tr("This PASE media file cannot be deleted: %1") - .arg(matched.deleteBlockReason)); - return; - } - const auto reply = QMessageBox::question( - this, tr("Delete"), - tr("Delete this file from PASE?\n\nName: %1\nSize: %2 bytes\n\nThe operation cannot be undone.") - .arg(matched.name) - .arg(matched.size), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (reply == QMessageBox::Yes) { - activeOperationId_ = deviceMgr_->queueDeleteMediaOperation( - QString(), QStringList{matched.name}); - setUploadBusy(true); - } - return; - } - - const auto reply = QMessageBox::question( - this, tr("Delete"), tr("Delete %1 file(s)?").arg(files.size())); - if (reply == QMessageBox::Yes) { - deviceMgr_->deleteMedia(files); - } -} - -void DisplayPage::onRefreshClicked() { - if (!refreshBtn_->isEnabled()) { - return; - } - refreshBtn_->setEnabled(false); - deviceMgr_->refreshMediaList(); -} - -void DisplayPage::onBrightnessChanged(int value) { - deviceMgr_->setBrightness(value); -} - -void DisplayPage::onMediaListUpdated(const QStringList &files) { - refreshBtn_->setEnabled(!uploadBusy_); - fileList_->clear(); - for (const auto &f : files) { - fileList_->addItem(f); - } - emit statusMessage(tr("Files on device: %1").arg(files.size())); -} - -void DisplayPage::onMediaUploaded(const QString &filename) { - if (deviceMgr_->isPrinterClassDevicePresent()) { - return; - } - setUploadBusy(false); - emit statusMessage(tr("Uploaded: %1").arg(filename)); - deviceMgr_->refreshMediaList(); -} - -void DisplayPage::onMediaDeleted() { - emit statusMessage(tr("Files deleted")); - deviceMgr_->refreshMediaList(); -} - -void DisplayPage::onUploadStatus(const QString &status) { - emit statusMessage(status); -} - -void DisplayPage::onOperationChanged( - const TryxRuntimeOperationInfo &info, quint64 revision) { - Q_UNUSED(revision); - const bool terminal = info.state == QStringLiteral("Succeeded") || - info.state == QStringLiteral("Failed") || - info.state == QStringLiteral("Cancelled") || - info.state == QStringLiteral("RetryAvailable"); - if (info.id != activeOperationId_ && - info.state != QStringLiteral("RetryAvailable")) { - return; - } - - if (!terminal) { - setUploadBusy(true); - retryBtn_->setVisible(false); - cancelBtn_->setVisible(true); - if (info.total > 0) { - progressBar_->setRange(0, 100); - const int percent = static_cast(qBound( - qint64(0), (info.completed * 100) / info.total, - qint64(100))); - progressBar_->setValue(percent); - } else { - progressBar_->setRange(0, 0); - } - if (!info.message.isEmpty()) { - emit statusMessage(info.message); - } - return; - } - - if (info.state == QStringLiteral("RetryAvailable")) { - const bool preparedRetry = - info.retryMode == QStringLiteral("PreparedMedia"); - retryOperationId_ = preparedRetry ? info.id : QString(); - retryBtn_->setVisible(preparedRetry); - cancelBtn_->setVisible(false); - setUploadBusy(false); - retryBtn_->setVisible(preparedRetry); - QString retryStatus = info.message.isEmpty() - ? tr("Prepared media is available for manual retry") - : info.message; - if (!info.primaryErrorMessage.trimmed().isEmpty()) { - retryStatus += QLatin1Char('\n') + - tr("Initial transfer error: %1") - .arg(info.primaryErrorMessage.trimmed()); - } - if (info.confirmedBytes > 0 && info.total > 0) { - retryStatus += QLatin1Char('\n') + - tr("Confirmed in the previous attempt: %1 of %2 bytes") - .arg(info.confirmedBytes) - .arg(info.total); - } - emit statusMessage(retryStatus); - } else { - setUploadBusy(false); - cancelBtn_->setVisible(false); - if (info.state == QStringLiteral("Succeeded")) { - emit statusMessage( - info.kind == QStringLiteral("DeleteMedia") - ? tr("Media file deleted and verified") - : info.kind.contains(QStringLiteral("Apply")) - ? tr("Display media applied") - : tr("Media upload completed and verified")); - } else if (!info.message.isEmpty()) { - emit statusMessage(info.message); - } - } - if (info.id == activeOperationId_) { - activeOperationId_.clear(); - } -} - -void DisplayPage::onRetryClicked() { - if (retryOperationId_.isEmpty()) { - return; - } - activeOperationId_ = - deviceMgr_->retryOperation(retryOperationId_, QString()); - retryBtn_->setVisible(false); - setUploadBusy(true); -} - -void DisplayPage::onCancelClicked() { - if (!activeOperationId_.isEmpty()) { - deviceMgr_->cancelOperation(activeOperationId_); - } -} - -void DisplayPage::setUploadBusy(bool busy) { - uploadBusy_ = busy; - progressBar_->setVisible(busy); - uploadBtn_->setEnabled(!busy); - refreshBtn_->setEnabled(!busy); - setDisplayBtn_->setEnabled(!busy); - if (!busy) { - progressBar_->setRange(0, 0); - } -} - -void DisplayPage::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasUrls()) { - event->acceptProposedAction(); - dropZone_->setStyleSheet( - "QLabel {" - " border: 2px dashed #4CAF50;" - " border-radius: 8px;" - " padding: 20px;" - " color: #4CAF50;" - " font-size: 14px;" - " background: rgba(76, 175, 80, 30);" - "}"); - } -} - -void DisplayPage::dropEvent(QDropEvent *event) { - dropZone_->setStyleSheet( - "QLabel {" - " border: 2px dashed #888;" - " border-radius: 8px;" - " padding: 20px;" - " color: #aaa;" - " font-size: 14px;" - "}"); - - for (const auto &url : event->mimeData()->urls()) { - if (url.isLocalFile()) { - setUploadBusy(true); - if (deviceMgr_->isPrinterClassDevicePresent()) { - activeOperationId_ = deviceMgr_->queueUploadOperation( - QString(), url.toLocalFile(), false); - } else { - deviceMgr_->uploadMedia(url.toLocalFile()); - } - break; - } - } -} diff --git a/src/displaypage.h b/src/displaypage.h deleted file mode 100644 index b69ebf6..0000000 --- a/src/displaypage.h +++ /dev/null @@ -1,110 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -class DeviceManager; -struct TryxRuntimeOperationInfo; - -struct MediaEntry { - enum class Origin { - UserFile, - DevicePreset, - }; - - QString filePath; - QString fileName; - QString remoteId; - QString format; - quint64 sizeBytes = 0; - Origin origin = Origin::UserFile; -}; - -class MediaTile : public QFrame { - Q_OBJECT -public: - explicit MediaTile(const MediaEntry &entry, QWidget *parent = nullptr); - - QString filePath() const { return entry_.filePath; } - QString remoteId() const { return entry_.remoteId; } - MediaEntry::Origin origin() const { return entry_.origin; } - bool isSelected() const { return selected_; } - void setSelected(bool sel); - void setThumbnail(const QPixmap &pix); - void setNeutralPlaceholder(const QString &text); - QPixmap thumbnail() const { return thumb_; } - -signals: - void clicked(MediaTile *tile); - -protected: - void mousePressEvent(QMouseEvent *event) override; - -private: - void updateStyle(); - - MediaEntry entry_; - QPixmap thumb_; - bool selected_ = false; - QLabel *imageLabel_; - QLabel *nameLabel_; - QLabel *infoLabel_; -}; - -class DisplayPage : public QWidget { - Q_OBJECT -public: - explicit DisplayPage(DeviceManager *deviceMgr, QWidget *parent = nullptr); - -signals: - void statusMessage(const QString &msg); - -private slots: - void onUploadClicked(); - void onSetDisplayClicked(); - void onDeleteClicked(); - void onRefreshClicked(); - void onBrightnessChanged(int value); - void onMediaListUpdated(const QStringList &files); - void onMediaUploaded(const QString &filename); - void onMediaDeleted(); - void onUploadStatus(const QString &status); - void onOperationChanged(const TryxRuntimeOperationInfo &info, - quint64 revision); - void onRetryClicked(); - void onCancelClicked(); - -private: - void setupUi(); - void setUploadBusy(bool busy); - - DeviceManager *deviceMgr_; - QListWidget *fileList_; - QSlider *brightnessSlider_; - QLabel *brightnessLabel_; - QComboBox *ratioCombo_; - QPushButton *uploadBtn_; - QPushButton *setDisplayBtn_; - QPushButton *deleteBtn_; - QPushButton *refreshBtn_; - QPushButton *retryBtn_; - QPushButton *cancelBtn_; - QLabel *dropZone_; - QProgressBar *progressBar_; - bool uploadBusy_ = false; - QString activeOperationId_; - QString retryOperationId_; - -protected: - void dragEnterEvent(QDragEnterEvent *event) override; - void dropEvent(QDropEvent *event) override; -}; diff --git a/src/firmwarebridge.cpp b/src/firmwarebridge.cpp new file mode 100644 index 0000000..732ab11 --- /dev/null +++ b/src/firmwarebridge.cpp @@ -0,0 +1,1851 @@ +#include "firmwarebridge.h" + +#include "devicemanager.h" +#include "firmwareupdater.h" +#include "runtimebridge.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_UNIX +#include +#endif + +namespace { + +constexpr qint64 kApprovalLifetimeMs = 10LL * 60LL * 1000LL; +constexpr qint64 kHashChunkBytes = 1024LL * 1024LL; +constexpr int kFirmwareQuiesceDeadlineMs = 30 * 1000; + +QString packageKindName(FirmwareUpdater::PackageKind kind) { + switch (kind) { + case FirmwareUpdater::PackageKind::LegacyAndroidOta: + return QStringLiteral("LegacyAndroidOta"); + case FirmwareUpdater::PackageKind::RockchipBundle: + return QStringLiteral("RockchipBundle"); + case FirmwareUpdater::PackageKind::Unknown: + break; + } + return QStringLiteral("Unknown"); +} + +bool hashFile(const QString &path, QString *sha256, + QString *errorMessage) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (errorMessage) { + *errorMessage = FirmwareBridge::tr( + "Cannot read firmware package: %1") + .arg(file.errorString()); + } + return false; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + while (!file.atEnd()) { + const QByteArray chunk = file.read(kHashChunkBytes); + if (chunk.isEmpty() && file.error() != QFileDevice::NoError) { + if (errorMessage) { + *errorMessage = FirmwareBridge::tr( + "Failed while hashing firmware package: %1") + .arg(file.errorString()); + } + return false; + } + hash.addData(chunk); + } + if (sha256) { + *sha256 = + QString::fromLatin1(hash.result().toHex()); + } + return true; +} + +QVariantMap validatePackageIdentity(FirmwareUpdater *updater, + const QString &requestedPath) { + QVariantMap result; + result.insert(QStringLiteral("valid"), false); + + if (!updater) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr( + "The daemon firmware worker is unavailable")); + return result; + } + + const QFileInfo requestedInfo(requestedPath); + if (requestedPath.trimmed().isEmpty()) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr("Select a local firmware ZIP")); + return result; + } + if (!requestedInfo.exists() || !requestedInfo.isFile() || + !requestedInfo.isReadable()) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr( + "Firmware package is not a readable local file")); + return result; + } + if (requestedInfo.suffix().compare( + QStringLiteral("zip"), Qt::CaseInsensitive) != 0) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr( + "Firmware package must be a local .zip file")); + return result; + } + + const QString canonicalPath = + requestedInfo.canonicalFilePath(); + if (canonicalPath.isEmpty()) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr( + "Firmware package path cannot be resolved")); + return result; + } + + const QFileInfo initialInfo(canonicalPath); + const qint64 initialSize = initialInfo.size(); + const qint64 initialMtime = + initialInfo.lastModified().toUTC().toMSecsSinceEpoch(); + if (initialSize <= 0) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr("Firmware package is empty")); + return result; + } + + QString initialSha256; + QString hashError; + if (!hashFile(canonicalPath, &initialSha256, &hashError)) { + result.insert(QStringLiteral("error"), hashError); + return result; + } + + const QFileInfo beforeValidationInfo(canonicalPath); + if (!beforeValidationInfo.exists() || + !beforeValidationInfo.isFile() || + beforeValidationInfo.canonicalFilePath() != canonicalPath || + beforeValidationInfo.size() != initialSize || + beforeValidationInfo.lastModified() + .toUTC() + .toMSecsSinceEpoch() != initialMtime) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr( + "Firmware package changed while computing its identity")); + return result; + } + + const FirmwareUpdater::PackageInfo package = + updater->validatePackage(canonicalPath); + if (!package.valid) { + result.insert(QStringLiteral("error"), package.error); + return result; + } + + QString finalSha256; + if (!hashFile(canonicalPath, &finalSha256, &hashError)) { + result.insert(QStringLiteral("error"), hashError); + return result; + } + + const QFileInfo finalInfo(canonicalPath); + const QString finalCanonicalPath = + finalInfo.canonicalFilePath(); + const qint64 finalMtime = + finalInfo.lastModified().toUTC().toMSecsSinceEpoch(); + if (!finalInfo.exists() || !finalInfo.isFile() || + finalCanonicalPath != canonicalPath || + finalInfo.size() != initialSize || + finalMtime != initialMtime || + finalSha256 != initialSha256) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr( + "Firmware package changed during validation")); + return result; + } + + const QString kind = packageKindName(package.kind); + if (kind == QStringLiteral("Unknown")) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr( + "Firmware package type is unsupported")); + return result; + } + if (package.kind == + FirmwareUpdater::PackageKind::RockchipBundle && + package.productCode != QStringLiteral("PASE")) { + result.insert( + QStringLiteral("error"), + FirmwareBridge::tr( + "Rockchip firmware product %1 is unsupported") + .arg(package.productCode)); + return result; + } + + const FirmwareUpdater::DependencyStatus dependencies = + updater->dependencyStatus(); + const bool flashSupported = + package.kind == + FirmwareUpdater::PackageKind::LegacyAndroidOta + ? dependencies.canFlashLegacy() + : dependencies.canFlashRockchip(); + + result.insert(QStringLiteral("valid"), true); + result.insert(QStringLiteral("canonicalPath"), + canonicalPath); + result.insert(QStringLiteral("size"), initialSize); + result.insert(QStringLiteral("mtimeUtcMs"), + initialMtime); + result.insert(QStringLiteral("sha256"), finalSha256); + result.insert(QStringLiteral("kind"), kind); + result.insert(QStringLiteral("flashSupported"), + flashSupported); + result.insert(QStringLiteral("preDevice"), + package.preDevice); + result.insert(QStringLiteral("postBuild"), + package.postBuild); + result.insert(QStringLiteral("postBuildIncremental"), + package.postBuildIncremental); + result.insert(QStringLiteral("productCode"), + package.productCode); + result.insert(QStringLiteral("appVersion"), + package.appVersion); + result.insert(QStringLiteral("firmwareVersion"), + package.firmwareVersion); + result.insert(QStringLiteral("machineModel"), + package.machineModel); + result.insert(QStringLiteral("partitions"), + package.partitions); + if (!flashSupported) { + result.insert( + QStringLiteral("dependencyError"), + package.kind == + FirmwareUpdater::PackageKind::LegacyAndroidOta + ? FirmwareBridge::tr( + "Legacy flashing requires adb and unzip") + : FirmwareBridge::tr( + "Rockchip flashing requires unzip, debugfs and upgrade_tool")); + } + return result; +} + +} // namespace + +bool FirmwareBridge::stageApprovedPackageCopy( + const QString &sourcePath, + qint64 expectedSize, + const QString &expectedSha256, + std::shared_ptr *directory, + QString *stagedPath, + QString *errorMessage) { + if (directory) { + directory->reset(); + } + if (stagedPath) { + stagedPath->clear(); + } + const auto fail = [errorMessage](const QString &message) { + if (errorMessage) { + *errorMessage = message; + } + return false; + }; + + const QString normalizedSha = + expectedSha256.trimmed().toLower(); + if (expectedSize <= 0 || + normalizedSha.size() != 64) { + return fail(tr( + "The approved firmware identity is incomplete")); + } + + QFile source(sourcePath); + if (!source.open(QIODevice::ReadOnly)) { + return fail( + tr("Cannot read the approved firmware package: %1") + .arg(source.errorString())); + } + if (source.size() != expectedSize) { + return fail(tr( + "Approved firmware size changed before the private copy was created")); + } + + auto privateDirectory = + std::make_shared( + QDir::tempPath() + + QStringLiteral( + "/tryx-approved-firmware-XXXXXX")); + if (!privateDirectory->isValid() || + !QFile::setPermissions( + privateDirectory->path(), + QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)) { + return fail(tr( + "Failed to create a private firmware working directory")); + } + + const QString destinationPath = + QDir(privateDirectory->path()).filePath( + QStringLiteral("approved-firmware.zip")); + QFile destination(destinationPath); + if (!destination.open( + QIODevice::WriteOnly | + QIODevice::NewOnly)) { + return fail( + tr("Failed to create the private firmware copy: %1") + .arg(destination.errorString())); + } + + QCryptographicHash hash( + QCryptographicHash::Sha256); + qint64 copied = 0; + while (!source.atEnd()) { + const QByteArray chunk = + source.read(kHashChunkBytes); + if (chunk.isEmpty()) { + if (source.error() != + QFileDevice::NoError) { + destination.close(); + return fail(tr( + "Failed while reading the approved firmware package: %1") + .arg(source.errorString())); + } + break; + } + copied += chunk.size(); + if (copied > expectedSize) { + destination.close(); + return fail(tr( + "Approved firmware size changed while the private copy was created")); + } + hash.addData(chunk); + qint64 written = 0; + while (written < chunk.size()) { + const qint64 count = + destination.write( + chunk.constData() + written, + chunk.size() - written); + if (count <= 0) { + const QString writeError = + destination.errorString(); + destination.close(); + return fail( + tr("Failed while writing the private firmware copy: %1") + .arg(writeError)); + } + written += count; + } + } + if (source.error() != QFileDevice::NoError || + copied != expectedSize || + QString::fromLatin1( + hash.result().toHex()) != normalizedSha) { + destination.close(); + return fail(tr( + "Approved firmware identity changed while the private copy was created")); + } + if (!destination.flush()) { + const QString flushError = + destination.errorString(); + destination.close(); + return fail( + tr("Failed to flush the private firmware copy: %1") + .arg(flushError)); + } +#ifdef Q_OS_UNIX + if (destination.handle() < 0 || + ::fsync(destination.handle()) != 0) { + destination.close(); + return fail(tr( + "Failed to synchronize the private firmware copy")); + } +#endif + destination.close(); + source.close(); + + if (!QFile::setPermissions( + destinationPath, + QFileDevice::ReadOwner)) { + return fail(tr( + "Failed to protect the private firmware copy")); + } + + QString stagedSha; + QString hashError; + if (!hashFile( + destinationPath, &stagedSha, + &hashError) || + QFileInfo(destinationPath).size() != + expectedSize || + stagedSha != normalizedSha) { + return fail( + hashError.isEmpty() + ? tr("The private firmware copy failed identity verification") + : hashError); + } + + if (directory) { + *directory = privateDirectory; + } + if (stagedPath) { + *stagedPath = destinationPath; + } + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +FirmwareBridge::FirmwareBridge(DeviceManager *deviceManager, + QObject *parent, + const QString &recoveryJournalPath) + : QObject(parent), + deviceManager_(deviceManager), + firmwareThreadContext_(new QObject), + recoveryJournal_(recoveryJournalPath) { + loadRecoveryJournal(); + if (deviceManager_) { + deviceManager_ + ->setFirmwareRecoveryInterlockActive( + recoveryRequired_); + connect( + deviceManager_, + &DeviceManager::firmwareTransportQuiesced, + this, + &FirmwareBridge::handleFirmwareTransportQuiesced); + } + quiesceDeadlineTimer_.setSingleShot(true); + quiesceDeadlineTimer_.setInterval( + kFirmwareQuiesceDeadlineMs); + connect( + &quiesceDeadlineTimer_, &QTimer::timeout, + this, [this]() { + if (!flashBusy_ || updaterStarted_ || + firmwareGateLeaseId_.isEmpty()) { + return; + } + failPendingFlash(tr( + "Timed out waiting for the device transport to stop; the firmware updater was not started")); + }); + firmwareThreadContext_->moveToThread(&firmwareThread_); + connect(&firmwareThread_, &QThread::finished, + firmwareThreadContext_, &QObject::deleteLater); + firmwareThread_.setObjectName( + QStringLiteral("tryx-firmware-worker")); + firmwareThread_.start(); + + QPointer guard(this); + QMetaObject::invokeMethod( + firmwareThreadContext_, + [this, guard]() { + if (!guard) { + return; + } + updater_ = + new FirmwareUpdater(firmwareThreadContext_); + connect( + updater_, &FirmwareUpdater::statusChanged, + this, &FirmwareBridge::handleUpdaterStatus, + Qt::QueuedConnection); + connect( + updater_, &FirmwareUpdater::progressChanged, + this, &FirmwareBridge::handleUpdaterProgress, + Qt::QueuedConnection); + connect( + updater_, + &FirmwareUpdater::irreversibleStarted, + this, + [this]() { + if (flashBusy_ && + updaterStarted_) { + updaterIrreversibleStarted_ = + true; + QString journalError; + if (!updateRecoveryJournalPhase( + QStringLiteral( + "Irreversible"), + &journalError)) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = + journalError; + status_ = tr( + "Firmware flashing entered an irreversible stage, but its recovery journal could not be updated: %1") + .arg( + journalError); + publishState(); + } + } + }, + Qt::QueuedConnection); + connect( + updater_, &FirmwareUpdater::finished, + this, &FirmwareBridge::handleUpdaterFinished, + Qt::QueuedConnection); + QMetaObject::invokeMethod( + this, + [this, guard]() { + if (!guard) { + return; + } + workerReady_ = true; + if (recoveryRequired_) { + phase_ = QStringLiteral( + "RecoveryRequired"); + status_ = + recoveryStatusText(); + } else { + phase_ = + QStringLiteral("Idle"); + status_ = tr( + "Select and validate a local firmware ZIP"); + } + publishState(); + }, + Qt::QueuedConnection); + }, + Qt::QueuedConnection); +} + +FirmwareBridge::~FirmwareBridge() { + stopQuiesceDeadline(); + releaseFirmwareGate(false); + firmwareThread_.quit(); + firmwareThread_.wait(); + clearStagedPackage(); +} + +QVariantMap FirmwareBridge::stateForCaller( + const QString &callerUniqueName) const { + QVariantMap state = publicState(); + const bool callerHasApproval = + approval_ && + callerOwns(approval_->ownerUniqueName, + callerUniqueName); + if (callerHasApproval && + !approvalExpired(*approval_)) { + state.insert(QStringLiteral("approvalAvailable"), + true); + state.insert(QStringLiteral("approvalToken"), + approval_->token); + state.insert(QStringLiteral("approvalExpiresUtcMs"), + approval_->expiresUtcMs); + } else { + state.insert(QStringLiteral("approvalAvailable"), + false); + state.remove(QStringLiteral("approvalToken")); + state.remove(QStringLiteral("approvalExpiresUtcMs")); + if (callerHasApproval && + approvalExpired(*approval_)) { + state.insert(QStringLiteral("phase"), + QStringLiteral("Expired")); + state.insert( + QStringLiteral("status"), + tr("Firmware approval expired; validate the package again")); + } + } + return state; +} + +bool FirmwareBridge::requestValidation( + const QString &packagePath, + const QString &callerUniqueName) { + if (shutdownRequested_) { + setFailure(tr( + "The runtime is shutting down; firmware actions are no longer accepted")); + return false; + } + if (!workerReady_) { + setFailure(tr("The daemon firmware worker is not ready")); + return false; + } + if (validationBusy_ || flashBusy_) { + setFailure(tr("Another firmware action is already active")); + return false; + } + if (callerUniqueName.trimmed().isEmpty()) { + setFailure(tr("Firmware validation caller identity is unavailable")); + return false; + } + + approval_.reset(); + pendingFlash_.reset(); + clearStagedPackage(); + attemptInheritedRecovery_ = false; + currentAttemptJournalCreated_ = false; + package_.clear(); + validationBusy_ = true; + progress_ = 0; + phase_ = QStringLiteral("Validating"); + status_ = tr("Validating firmware package and computing SHA-256..."); + validationRequestId_ = + QUuid::createUuid().toString(QUuid::WithoutBraces); + const QString requestId = validationRequestId_; + publishState(); + + QPointer guard(this); + QMetaObject::invokeMethod( + firmwareThreadContext_, + [this, guard, requestId, callerUniqueName, + packagePath]() { + if (!guard || !updater_) { + return; + } + const QVariantMap result = + validatePackageIdentity(updater_, packagePath); + if (!guard) { + return; + } + QMetaObject::invokeMethod( + this, + [this, guard, requestId, callerUniqueName, + result]() { + if (guard) { + handleValidationResult( + requestId, callerUniqueName, + result); + } + }, + Qt::QueuedConnection); + }, + Qt::QueuedConnection); + return true; +} + +bool FirmwareBridge::requestFlash( + const QString &approvalToken, + const QString &callerUniqueName) { + if (shutdownRequested_) { + setFailure(tr( + "The runtime is shutting down; firmware actions are no longer accepted")); + return false; + } + if (!workerReady_) { + setFailure(tr("The daemon firmware worker is not ready")); + return false; + } + if (validationBusy_ || flashBusy_) { + setFailure(tr("Another firmware action is already active")); + return false; + } + if (recoveryJournalInvalid_) { + setFailure(tr( + "The firmware recovery journal is invalid or unsafe. Explicitly acknowledge recovery before starting another flash attempt.")); + return false; + } + + QString activeOperationId; + if (hasActiveDeviceOperation(&activeOperationId)) { + setFailure( + tr("Firmware flashing is blocked while device operation %1 is active") + .arg(activeOperationId)); + return false; + } + + if (!approval_ || + approvalToken.trimmed().isEmpty() || + approval_->token != approvalToken || + !callerOwns(approval_->ownerUniqueName, + callerUniqueName)) { + setFailure(tr("Firmware approval token is invalid or belongs to another client")); + return false; + } + if (approvalExpired(*approval_)) { + approval_.reset(); + setFailure(tr("Firmware approval has expired; validate the package again")); + return false; + } + + // Consume before any asynchronous work. A failed identity check requires a + // fresh validation and can never replay this flash request automatically. + pendingFlash_ = *approval_; + approval_.reset(); + clearStagedPackage(); + updaterIrreversibleStarted_ = false; + attemptInheritedRecovery_ = + recoveryRequired_; + currentAttemptJournalCreated_ = false; + flashOwnerUniqueName_ = callerUniqueName; + flashBusy_ = true; + progress_ = 0; + phase_ = QStringLiteral("Revalidating"); + status_ = tr("Rechecking the approved firmware package before flashing..."); + flashRevalidationRequestId_ = + QUuid::createUuid().toString(QUuid::WithoutBraces); + const QString requestId = flashRevalidationRequestId_; + const QString canonicalPath = + pendingFlash_->canonicalPath; + const Approval approval = *pendingFlash_; + publishState(); + + QPointer guard(this); + QMetaObject::invokeMethod( + firmwareThreadContext_, + [this, guard, requestId, canonicalPath, + approval]() { + if (!guard || !updater_) { + return; + } + const QVariantMap result = + validatePackageIdentity(updater_, canonicalPath); + QVariantMap stagedResult; + std::shared_ptr + stagedDirectory; + QString stagedPath; + QString stagingError; + if (identityMatchesApproval( + approval, result) && + result.value( + QStringLiteral( + "flashSupported")).toBool() && + stageApprovedPackageCopy( + canonicalPath, approval.size, + approval.sha256, + &stagedDirectory, &stagedPath, + &stagingError)) { + stagedResult = + validatePackageIdentity( + updater_, stagedPath); + } + if (!guard) { + return; + } + QMetaObject::invokeMethod( + this, + [this, guard, requestId, result, + stagedResult, stagedDirectory, + stagedPath, stagingError]() { + if (guard) { + handleFlashRevalidationResult( + requestId, result, + stagedResult, + stagedDirectory, + stagedPath, + stagingError); + } + }, + Qt::QueuedConnection); + }, + Qt::QueuedConnection); + return true; +} + +void FirmwareBridge::requestCancel( + const QString &callerUniqueName) { + if (!flashBusy_ || !updater_) { + return; + } + if (!callerOwns(flashOwnerUniqueName_, + callerUniqueName)) { + status_ = tr( + "Only the client that started flashing may request cancellation"); + publishState(); + return; + } + + if (!updaterStarted_) { + QString journalError; + if (currentAttemptJournalCreated_ && + !attemptInheritedRecovery_) { + clearCurrentAttemptRecoveryJournal( + &journalError); + } + const bool retainRecovery = + recoveryRequired_; + stopQuiesceDeadline(); + flashRevalidationRequestId_.clear(); + postQuiesceValidationRequestId_.clear(); + pendingFlash_.reset(); + clearStagedPackage(); + flashOwnerUniqueName_.clear(); + flashBusy_ = false; + updaterIrreversibleStarted_ = false; + pendingRecoveryRecord_.reset(); + attemptInheritedRecovery_ = false; + releaseFirmwareGate( + !shutdownRequested_ && + !retainRecovery); + progress_ = 0; + phase_ = retainRecovery + ? QStringLiteral("RecoveryRequired") + : QStringLiteral("Cancelled"); + status_ = retainRecovery + ? tr("Firmware flashing was cancelled before the updater started. %1") + .arg( + journalError.isEmpty() + ? recoveryStatusText() + : journalError) + : tr("Firmware flashing was cancelled before the updater started"); + publishState(); + emit finished(false, status_); + return; + } + + // FirmwareUpdater owns the irreversible-step lock. Its cancel() method + // rejects cancellation after recovery reboot or Rockchip writes begin. + // This request intentionally has no success return and no optimistic state. + QMetaObject::invokeMethod( + firmwareThreadContext_, + [this]() { + if (updater_) { + updater_->cancel(); + } + }, + Qt::QueuedConnection); +} + +bool FirmwareBridge:: + requestRecoveryAcknowledgement( + const QString &callerUniqueName) { + if (callerUniqueName.trimmed().isEmpty()) { + status_ = tr( + "Firmware recovery acknowledgement caller identity is unavailable"); + publishState(); + return false; + } + if (shutdownRequested_) { + status_ = tr( + "The runtime is shutting down; firmware recovery cannot be acknowledged"); + publishState(); + return false; + } + if (validationBusy_ || flashBusy_) { + status_ = tr( + "Wait for the active firmware action to finish before acknowledging recovery"); + publishState(); + return false; + } + if (!recoveryRequired_) { + status_ = tr( + "No firmware recovery acknowledgement is required"); + publishState(); + return false; + } + if (!deviceManager_) { + phase_ = QStringLiteral( + "RecoveryRequired"); + status_ = tr( + "The device runtime is unavailable; firmware recovery was not acknowledged"); + publishState(); + return false; + } + + QString clearError; + if (!recoveryJournal_.acknowledgeAndClear( + &clearError)) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = clearError; + phase_ = QStringLiteral( + "RecoveryRequired"); + status_ = tr( + "Firmware recovery acknowledgement failed: %1") + .arg(clearError); + publishState(); + return false; + } + const auto after = recoveryJournal_.load(); + if (after.status != + TryxFirmwareRecoveryJournalLoadStatus::Missing) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = + after.error.isEmpty() + ? tr("The firmware recovery journal remained after acknowledgement") + : after.error; + phase_ = QStringLiteral( + "RecoveryRequired"); + status_ = recoveryStatusText(); + publishState(); + return false; + } + + recoveryRequired_ = false; + recoveryJournalInvalid_ = false; + attemptInheritedRecovery_ = false; + currentAttemptJournalCreated_ = false; + recoveryRecord_ = {}; + pendingRecoveryRecord_.reset(); + recoveryJournalError_.clear(); + phase_ = QStringLiteral("Idle"); + progress_ = 0; + status_ = tr( + "Firmware recovery acknowledged. Resuming the device connection."); + publishState(); + deviceManager_ + ->resumeConnectionAfterFirmwareRecoveryAcknowledgement(); + return true; +} + +void FirmwareBridge::prepareForShutdown() { + if (shutdownRequested_) { + return; + } + shutdownRequested_ = true; + approval_.reset(); + if (validationBusy_) { + validationRequestId_.clear(); + validationBusy_ = false; + progress_ = 0; + phase_ = QStringLiteral("ShuttingDown"); + status_ = tr( + "Firmware validation was stopped because the runtime is shutting down"); + publishState(); + return; + } + if (flashBusy_ && !updaterStarted_) { + QString journalError; + if (currentAttemptJournalCreated_ && + !attemptInheritedRecovery_) { + clearCurrentAttemptRecoveryJournal( + &journalError); + } + stopQuiesceDeadline(); + flashRevalidationRequestId_.clear(); + postQuiesceValidationRequestId_.clear(); + pendingFlash_.reset(); + clearStagedPackage(); + flashOwnerUniqueName_.clear(); + flashBusy_ = false; + updaterIrreversibleStarted_ = false; + pendingRecoveryRecord_.reset(); + attemptInheritedRecovery_ = false; + releaseFirmwareGate(false); + progress_ = 0; + phase_ = QStringLiteral("ShuttingDown"); + status_ = journalError.isEmpty() + ? tr( + "Firmware flashing was stopped before the updater started because the runtime is shutting down") + : tr( + "Firmware flashing was stopped before the updater started, but its recovery journal could not be cleared: %1") + .arg(journalError); + publishState(); + emit finished(false, status_); + return; + } + publishState(); +} + +void FirmwareBridge::handleValidationResult( + const QString &requestId, + const QString &callerUniqueName, + const QVariantMap &result) { + if (!validationBusy_ || + requestId != validationRequestId_) { + return; + } + validationBusy_ = false; + validationRequestId_.clear(); + if (!result.value(QStringLiteral("valid")).toBool()) { + package_.clear(); + setFailure( + result.value(QStringLiteral("error")).toString()); + return; + } + + package_ = result; + Approval approval; + approval.token = + QUuid::createUuid().toString(QUuid::WithoutBraces); + approval.ownerUniqueName = callerUniqueName; + approval.canonicalPath = + result.value(QStringLiteral("canonicalPath")).toString(); + approval.size = + result.value(QStringLiteral("size")).toLongLong(); + approval.mtimeUtcMs = + result.value(QStringLiteral("mtimeUtcMs")).toLongLong(); + approval.sha256 = + result.value(QStringLiteral("sha256")).toString(); + approval.kind = + result.value(QStringLiteral("kind")).toString(); + approval.expiresUtcMs = + QDateTime::currentDateTimeUtc().toMSecsSinceEpoch() + + kApprovalLifetimeMs; + approval_ = approval; + progress_ = 0; + phase_ = QStringLiteral("Approved"); + status_ = result.value( + QStringLiteral("flashSupported")).toBool() + ? tr("Firmware package validated. Explicit confirmation is required before flashing.") + : result.value( + QStringLiteral("dependencyError")).toString(); + publishState(); +} + +void FirmwareBridge::handleFlashRevalidationResult( + const QString &requestId, + const QVariantMap &result, + const QVariantMap &stagedResult, + const std::shared_ptr &directory, + const QString &stagedPath, + const QString &stagingError) { + if (!flashBusy_ || + requestId != flashRevalidationRequestId_ || + !pendingFlash_) { + return; + } + flashRevalidationRequestId_.clear(); + + const Approval approval = *pendingFlash_; + if (!identityMatchesApproval(approval, result)) { + failPendingFlash(tr( + "Approved firmware identity changed; validate the package again")); + return; + } + if (!result.value( + QStringLiteral("flashSupported")).toBool()) { + failPendingFlash( + result.value( + QStringLiteral("dependencyError")).toString()); + return; + } + if (!directory || !directory->isValid() || + stagedPath.isEmpty()) { + failPendingFlash( + stagingError.isEmpty() + ? tr("Failed to create the private approved firmware copy") + : stagingError); + return; + } + if (!stagedIdentityMatchesApproval( + approval, stagedResult)) { + failPendingFlash(tr( + "The private firmware copy does not match the approved package")); + return; + } + if (!stagedResult.value( + QStringLiteral("flashSupported")).toBool()) { + failPendingFlash( + stagedResult.value( + QStringLiteral( + "dependencyError")).toString()); + return; + } + + if (!deviceManager_) { + failPendingFlash( + tr("The device runtime is unavailable for firmware flashing")); + return; + } + + package_ = result; + stagedPackageDirectory_ = directory; + stagedPackagePath_ = stagedPath; + firmwareGateLeaseId_ = + QUuid::createUuid().toString( + QUuid::WithoutBraces); + QString gateError; + if (!deviceManager_->acquireFirmwareExclusive( + firmwareGateLeaseId_, &gateError)) { + firmwareGateLeaseId_.clear(); + failPendingFlash( + gateError.isEmpty() + ? tr("The device transport could not be reserved for firmware flashing") + : gateError); + return; + } + phase_ = QStringLiteral("Quiescing"); + status_ = tr( + "Stopping display, media and metrics transports before flashing..."); + quiesceDeadlineTimer_.start(); + publishState(); +} + +void FirmwareBridge::handleFirmwareTransportQuiesced( + const QString &leaseId, bool success, + const QString &message) { + if (!flashBusy_ || !pendingFlash_ || + leaseId != firmwareGateLeaseId_) { + return; + } + stopQuiesceDeadline(); + if (!success) { + failPendingFlash( + message.isEmpty() + ? tr("The device transport could not be stopped safely") + : message); + return; + } + + phase_ = QStringLiteral("Revalidating"); + status_ = tr( + "Device transport is closed. Rechecking the approved firmware identity..."); + postQuiesceValidationRequestId_ = + QUuid::createUuid().toString( + QUuid::WithoutBraces); + const QString requestId = + postQuiesceValidationRequestId_; + const QString stagedPath = + stagedPackagePath_; + const std::shared_ptr + stagedDirectory = + stagedPackageDirectory_; + if (!stagedDirectory || + !stagedDirectory->isValid() || + stagedPath.isEmpty()) { + failPendingFlash(tr( + "The private approved firmware copy is unavailable")); + return; + } + publishState(); + + QPointer guard(this); + QMetaObject::invokeMethod( + firmwareThreadContext_, + [this, guard, requestId, stagedPath, + stagedDirectory]() { + if (!guard || !updater_) { + return; + } + const QVariantMap result = + validatePackageIdentity( + updater_, stagedPath); + if (!guard) { + return; + } + QMetaObject::invokeMethod( + this, + [this, guard, requestId, result]() { + if (guard) { + handlePostQuiesceValidationResult( + requestId, result); + } + }, + Qt::QueuedConnection); + }, + Qt::QueuedConnection); +} + +void FirmwareBridge::handlePostQuiesceValidationResult( + const QString &requestId, + const QVariantMap &result) { + if (!flashBusy_ || !pendingFlash_ || + requestId != postQuiesceValidationRequestId_ || + firmwareGateLeaseId_.isEmpty()) { + return; + } + postQuiesceValidationRequestId_.clear(); + const Approval approval = *pendingFlash_; + if (!stagedIdentityMatchesApproval( + approval, result)) { + failPendingFlash(tr( + "The private approved firmware copy changed after the device transport was stopped")); + return; + } + if (!result.value( + QStringLiteral("flashSupported")).toBool()) { + failPendingFlash( + result.value( + QStringLiteral("dependencyError")).toString()); + return; + } + startApprovedFlash(approval); +} + +void FirmwareBridge::startApprovedFlash( + const Approval &approval) { + if (firmwareGateLeaseId_.isEmpty()) { + failPendingFlash( + tr("The firmware transport lease was lost before flashing")); + return; + } + const QString path = stagedPackagePath_; + const QString kind = approval.kind; + const qint64 expectedSize = + approval.size; + const QString expectedSha256 = + approval.sha256; + const std::shared_ptr + stagedDirectory = + stagedPackageDirectory_; + if (!stagedDirectory || + !stagedDirectory->isValid() || + path.isEmpty()) { + updaterStarted_ = false; + setShutdownInhibited(false); + failPendingFlash(tr( + "The private approved firmware copy is unavailable")); + return; + } + + QString journalError; + if (!armRecoveryJournal( + approval, &journalError)) { + failPendingFlash( + tr("Firmware updater was not started because its recovery journal could not be armed: %1") + .arg(journalError)); + return; + } + + updaterStarted_ = true; + updaterIrreversibleStarted_ = false; + setShutdownInhibited(true); + phase_ = QStringLiteral("Flashing"); + status_ = tr( + "Firmware flashing started. Do not disconnect USB or power."); + progress_ = 0; + publishState(); + + const bool queued = QMetaObject::invokeMethod( + firmwareThreadContext_, + [this, path, kind, expectedSize, + expectedSha256, stagedDirectory]() { + if (!updater_) { + return; + } + if (kind == QStringLiteral("LegacyAndroidOta")) { + updater_->startLegacyAdbOta( + path, expectedSize, + expectedSha256); + } else if (kind == + QStringLiteral("RockchipBundle")) { + updater_->startRockchipLoaderUpdate( + path, expectedSize, + expectedSha256); + } + }, + Qt::QueuedConnection); + if (!queued) { + updaterStarted_ = false; + setShutdownInhibited(false); + failPendingFlash(tr( + "Firmware updater could not be queued after the recovery journal was armed")); + } +} + +void FirmwareBridge::handleUpdaterStatus( + const QString &message) { + if (!flashBusy_) { + return; + } + status_ = message; + publishState(); + emit progressChanged(progress_, status_); +} + +void FirmwareBridge::handleUpdaterProgress(int progress) { + if (!flashBusy_) { + return; + } + progress_ = qBound(0, progress, 100); + publishState(); + emit progressChanged(progress_, status_); +} + +void FirmwareBridge::handleUpdaterFinished( + bool success, const QString &message) { + if (!flashBusy_) { + return; + } + const bool irreversibleStarted = + updaterIrreversibleStarted_ || + (updater_ && + updater_ + ->hasStartedIrreversibleOperation()); + QString journalError; + if (success) { + updateRecoveryJournalPhase( + QStringLiteral( + "AwaitingDeviceVerification"), + &journalError); + } else if (irreversibleStarted && + recoveryRecord_.phase != + QStringLiteral( + "Irreversible")) { + updateRecoveryJournalPhase( + QStringLiteral("Irreversible"), + &journalError); + } else if (!attemptInheritedRecovery_ && + !irreversibleStarted) { + clearCurrentAttemptRecoveryJournal( + &journalError); + } + + const bool retainRecovery = + success || + attemptInheritedRecovery_ || + irreversibleStarted || + recoveryRequired_; + const bool resumeTransport = + !shutdownRequested_ && + !retainRecovery; + updaterStarted_ = false; + updaterIrreversibleStarted_ = false; + stopQuiesceDeadline(); + flashBusy_ = false; + pendingFlash_.reset(); + clearStagedPackage(); + flashRevalidationRequestId_.clear(); + postQuiesceValidationRequestId_.clear(); + flashOwnerUniqueName_.clear(); + progress_ = success ? 100 : progress_; + if (retainRecovery) { + recoveryRequired_ = true; + phase_ = success + ? QStringLiteral( + "AwaitingDeviceVerification") + : QStringLiteral("RecoveryRequired"); + const QString recoveryText = + recoveryStatusText(); + status_ = message.trimmed().isEmpty() + ? recoveryText + : message + QStringLiteral(" ") + + recoveryText; + if (!journalError.isEmpty()) { + status_ += tr( + " Recovery journal error: %1") + .arg(journalError); + } + } else { + phase_ = QStringLiteral("Failed"); + status_ = message.isEmpty() + ? tr("Firmware operation failed") + : message; + } + releaseFirmwareGate(resumeTransport); + pendingRecoveryRecord_.reset(); + attemptInheritedRecovery_ = false; + setShutdownInhibited(false); + publishState(); + emit finished(success, status_); +} + +void FirmwareBridge::failPendingFlash( + const QString &message) { + QString journalError; + if (currentAttemptJournalCreated_ && + !attemptInheritedRecovery_) { + clearCurrentAttemptRecoveryJournal( + &journalError); + } + const bool retainRecovery = + recoveryRequired_; + stopQuiesceDeadline(); + flashRevalidationRequestId_.clear(); + postQuiesceValidationRequestId_.clear(); + pendingFlash_.reset(); + clearStagedPackage(); + flashOwnerUniqueName_.clear(); + updaterStarted_ = false; + updaterIrreversibleStarted_ = false; + flashBusy_ = false; + pendingRecoveryRecord_.reset(); + attemptInheritedRecovery_ = false; + releaseFirmwareGate( + !shutdownRequested_ && + !retainRecovery); + setShutdownInhibited(false); + setFailure( + journalError.isEmpty() + ? message + : message + tr( + " Recovery journal error: %1") + .arg(journalError)); +} + +void FirmwareBridge::stopQuiesceDeadline() { + if (quiesceDeadlineTimer_.isActive()) { + quiesceDeadlineTimer_.stop(); + } +} + +void FirmwareBridge::releaseFirmwareGate( + bool resumeTransport) { + if (firmwareGateLeaseId_.isEmpty()) { + return; + } + const QString leaseId = firmwareGateLeaseId_; + firmwareGateLeaseId_.clear(); + if (deviceManager_) { + deviceManager_->releaseFirmwareExclusive( + leaseId, resumeTransport); + } +} + +void FirmwareBridge::setShutdownInhibited( + bool inhibited) { + if (shutdownInhibited_ == inhibited) { + return; + } + shutdownInhibited_ = inhibited; + emit shutdownInhibitionChanged(inhibited); +} + +bool FirmwareBridge::identityMatchesApproval( + const Approval &approval, + const QVariantMap &result) { + return result.value(QStringLiteral("valid")).toBool() && + result.value( + QStringLiteral("canonicalPath")).toString() == + approval.canonicalPath && + result.value(QStringLiteral("size")).toLongLong() == + approval.size && + result.value( + QStringLiteral("mtimeUtcMs")).toLongLong() == + approval.mtimeUtcMs && + result.value(QStringLiteral("sha256")).toString() == + approval.sha256 && + result.value(QStringLiteral("kind")).toString() == + approval.kind; +} + +bool FirmwareBridge::stagedIdentityMatchesApproval( + const Approval &approval, + const QVariantMap &result) { + return result.value(QStringLiteral("valid")).toBool() && + result.value(QStringLiteral("size")).toLongLong() == + approval.size && + result.value(QStringLiteral("sha256")).toString() == + approval.sha256 && + result.value(QStringLiteral("kind")).toString() == + approval.kind; +} + +void FirmwareBridge::clearStagedPackage() { + stagedPackagePath_.clear(); + stagedPackageDirectory_.reset(); +} + +void FirmwareBridge::loadRecoveryJournal() { + const TryxFirmwareRecoveryJournalLoadResult loaded = + recoveryJournal_.load(); + recoveryRecord_ = {}; + pendingRecoveryRecord_.reset(); + recoveryJournalError_.clear(); + recoveryRequired_ = + loaded.status != + TryxFirmwareRecoveryJournalLoadStatus::Missing; + recoveryJournalInvalid_ = + loaded.status == + TryxFirmwareRecoveryJournalLoadStatus::Invalid; + if (loaded.status == + TryxFirmwareRecoveryJournalLoadStatus::Loaded) { + recoveryRecord_ = loaded.record; + } else if (recoveryJournalInvalid_) { + recoveryJournalError_ = loaded.error; + } +} + +bool FirmwareBridge::armRecoveryJournal( + const Approval &approval, + QString *errorMessage) { + if (recoveryJournalInvalid_) { + if (errorMessage) { + *errorMessage = + recoveryJournalError_.isEmpty() + ? tr("The firmware recovery journal is invalid or unsafe") + : recoveryJournalError_; + } + return false; + } + + if (attemptInheritedRecovery_) { + const auto loaded = recoveryJournal_.load(); + if (loaded.status != + TryxFirmwareRecoveryJournalLoadStatus::Loaded) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = + loaded.error.isEmpty() + ? tr("The inherited firmware recovery journal is missing") + : loaded.error; + if (errorMessage) { + *errorMessage = recoveryJournalError_; + } + return false; + } + recoveryRecord_ = loaded.record; + const qint64 now = + QDateTime::currentDateTimeUtc() + .toMSecsSinceEpoch(); + TryxFirmwareRecoveryRecord pending; + pending.attemptId = + QUuid::createUuid().toString( + QUuid::WithoutBraces); + pending.phase = QStringLiteral("Armed"); + pending.packageKind = approval.kind; + pending.packageSha256 = + approval.sha256.trimmed().toLower(); + pending.createdUtcMs = now; + pending.updatedUtcMs = now; + pendingRecoveryRecord_ = pending; + if (errorMessage) { + errorMessage->clear(); + } + return true; + } + + const qint64 now = + QDateTime::currentDateTimeUtc() + .toMSecsSinceEpoch(); + TryxFirmwareRecoveryRecord record; + record.attemptId = + QUuid::createUuid().toString( + QUuid::WithoutBraces); + record.phase = QStringLiteral("Armed"); + record.packageKind = approval.kind; + record.packageSha256 = + approval.sha256.trimmed().toLower(); + record.createdUtcMs = now; + record.updatedUtcMs = now; + pendingRecoveryRecord_.reset(); + + QString writeError; + if (!recoveryJournal_.write( + record, &writeError)) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = writeError; + if (errorMessage) { + *errorMessage = writeError; + } + return false; + } + + currentAttemptJournalCreated_ = true; + recoveryRequired_ = true; + recoveryRecord_ = record; + const auto loaded = recoveryJournal_.load(); + if (loaded.status != + TryxFirmwareRecoveryJournalLoadStatus::Loaded || + loaded.record.attemptId != record.attemptId || + loaded.record.phase != record.phase || + loaded.record.packageKind != record.packageKind || + loaded.record.packageSha256 != + record.packageSha256) { + recoveryJournalInvalid_ = true; + recoveryJournalError_ = + loaded.error.isEmpty() + ? tr("The firmware recovery journal could not be verified after writing") + : loaded.error; + if (errorMessage) { + *errorMessage = recoveryJournalError_; + } + return false; + } + recoveryRecord_ = loaded.record; + recoveryJournalInvalid_ = false; + recoveryJournalError_.clear(); + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +bool FirmwareBridge::updateRecoveryJournalPhase( + const QString &phase, + QString *errorMessage) { + const auto loaded = recoveryJournal_.load(); + if (loaded.status != + TryxFirmwareRecoveryJournalLoadStatus::Loaded) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = + loaded.error.isEmpty() + ? tr("The firmware recovery journal is missing") + : loaded.error; + if (errorMessage) { + *errorMessage = recoveryJournalError_; + } + return false; + } + if (!recoveryRecord_.attemptId.isEmpty() && + loaded.record.attemptId != + recoveryRecord_.attemptId) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = tr( + "The firmware recovery journal was replaced during the update"); + if (errorMessage) { + *errorMessage = recoveryJournalError_; + } + return false; + } + + TryxFirmwareRecoveryRecord updated; + if (attemptInheritedRecovery_ && + pendingRecoveryRecord_ && + (phase == QStringLiteral("Irreversible") || + phase == QStringLiteral( + "AwaitingDeviceVerification"))) { + updated = *pendingRecoveryRecord_; + } else { + updated = loaded.record; + } + updated.phase = phase; + updated.updatedUtcMs = qMax( + updated.createdUtcMs, + QDateTime::currentDateTimeUtc() + .toMSecsSinceEpoch()); + QString writeError; + if (!recoveryJournal_.write( + updated, &writeError)) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = writeError; + if (errorMessage) { + *errorMessage = writeError; + } + return false; + } + recoveryRequired_ = true; + recoveryJournalInvalid_ = false; + recoveryRecord_ = updated; + pendingRecoveryRecord_.reset(); + recoveryJournalError_.clear(); + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +bool FirmwareBridge:: + clearCurrentAttemptRecoveryJournal( + QString *errorMessage) { + if (!currentAttemptJournalCreated_ || + attemptInheritedRecovery_) { + if (errorMessage) { + errorMessage->clear(); + } + return true; + } + + const auto loaded = recoveryJournal_.load(); + if (loaded.status != + TryxFirmwareRecoveryJournalLoadStatus::Loaded || + recoveryRecord_.attemptId.isEmpty() || + loaded.record.attemptId != + recoveryRecord_.attemptId) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = + loaded.error.isEmpty() + ? tr("The current firmware recovery journal is missing or was replaced") + : loaded.error; + if (errorMessage) { + *errorMessage = recoveryJournalError_; + } + return false; + } + + QString clearError; + if (!recoveryJournal_.clear( + &clearError)) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = clearError; + if (errorMessage) { + *errorMessage = clearError; + } + return false; + } + const auto after = recoveryJournal_.load(); + if (after.status != + TryxFirmwareRecoveryJournalLoadStatus::Missing) { + recoveryRequired_ = true; + recoveryJournalInvalid_ = true; + recoveryJournalError_ = + after.error.isEmpty() + ? tr("The firmware recovery journal remained after clearing") + : after.error; + if (errorMessage) { + *errorMessage = recoveryJournalError_; + } + return false; + } + + recoveryRequired_ = false; + recoveryJournalInvalid_ = false; + currentAttemptJournalCreated_ = false; + recoveryRecord_ = {}; + pendingRecoveryRecord_.reset(); + recoveryJournalError_.clear(); + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +QString FirmwareBridge::recoveryStatusText() const { + if (recoveryJournalInvalid_) { + return recoveryJournalError_.isEmpty() + ? tr("Device connection is blocked because the firmware recovery journal is invalid or unsafe. Inspect the display, then explicitly acknowledge recovery.") + : tr("Device connection is blocked because the firmware recovery journal is invalid or unsafe: %1. Inspect the display, then explicitly acknowledge recovery.") + .arg(recoveryJournalError_); + } + if (recoveryRecord_.phase == + QStringLiteral( + "AwaitingDeviceVerification")) { + return tr( + "Firmware flashing completed. Inspect the physical display, then explicitly acknowledge recovery to resume the device connection."); + } + if (recoveryRecord_.phase == + QStringLiteral("Irreversible")) { + return tr( + "A firmware operation reached an irreversible stage without verified device recovery. Reflash in loader mode if needed, or inspect the display and explicitly acknowledge recovery."); + } + return tr( + "A previous firmware attempt did not reach verified device recovery. Reflash in loader mode if needed, or inspect the display and explicitly acknowledge recovery."); +} + +void FirmwareBridge::setFailure(const QString &message) { + if (validationBusy_ || flashBusy_) { + return; + } + phase_ = recoveryRequired_ + ? QStringLiteral("RecoveryRequired") + : QStringLiteral("Failed"); + const QString failure = + message.isEmpty() + ? tr("Firmware operation failed") + : message; + status_ = recoveryRequired_ + ? failure + QStringLiteral(" ") + + recoveryStatusText() + : failure; + publishState(); +} + +void FirmwareBridge::publishState() { + if (deviceManager_) { + deviceManager_ + ->setFirmwareRecoveryInterlockActive( + recoveryRequired_); + } + emit stateChanged(publicState()); +} + +QVariantMap FirmwareBridge::publicState() const { + QVariantMap state = package_; + state.remove(QStringLiteral("approvalToken")); + state.insert(QStringLiteral("apiVersion"), + InterfaceVersion); + state.insert(QStringLiteral("ready"), workerReady_); + state.insert(QStringLiteral("busy"), + validationBusy_ || flashBusy_); + state.insert(QStringLiteral("validationBusy"), + validationBusy_); + state.insert(QStringLiteral("flashBusy"), + flashBusy_); + state.insert(QStringLiteral("shutdownInhibited"), + shutdownInhibited_); + state.insert(QStringLiteral("recoveryRequired"), + recoveryRequired_); + state.insert(QStringLiteral("phase"), phase_); + state.insert(QStringLiteral("progress"), progress_); + state.insert(QStringLiteral("status"), status_); + state.insert(QStringLiteral("approvalAvailable"), + false); + return state; +} + +bool FirmwareBridge::hasActiveDeviceOperation( + QString *operationId) const { + if (!deviceManager_) { + if (operationId) { + operationId->clear(); + } + return false; + } + const TryxRuntimeOperationInfo active = + deviceManager_->activeOperationInfo(); + if (operationId) { + *operationId = active.id; + } + return !active.id.trimmed().isEmpty(); +} + +bool FirmwareBridge::callerOwns( + const QString &expectedOwner, + const QString &callerUniqueName) const { + return !expectedOwner.trimmed().isEmpty() && + expectedOwner == callerUniqueName; +} + +bool FirmwareBridge::approvalExpired( + const Approval &approval) const { + return approval.expiresUtcMs <= + QDateTime::currentDateTimeUtc().toMSecsSinceEpoch(); +} + +FirmwareAdaptor::FirmwareAdaptor( + TryxRuntimeExportedObject *exportedObject, + FirmwareBridge *bridge) + : QDBusAbstractAdaptor(exportedObject), + exportedObject_(exportedObject), + bridge_(bridge) { + setAutoRelaySignals(false); + if (!bridge_) { + return; + } + connect(bridge_, &FirmwareBridge::stateChanged, + this, &FirmwareAdaptor::StateChanged); + connect(bridge_, &FirmwareBridge::progressChanged, + this, &FirmwareAdaptor::ProgressChanged); + connect(bridge_, &FirmwareBridge::finished, + this, &FirmwareAdaptor::Finished); +} + +quint32 FirmwareAdaptor::GetFirmwareApiVersion() const { + return FirmwareBridge::InterfaceVersion; +} + +QVariantMap FirmwareAdaptor::GetFirmwareState() const { + return bridge_ + ? bridge_->stateForCaller(callerUniqueName()) + : QVariantMap{}; +} + +bool FirmwareAdaptor::ValidateFirmware( + const QString &packagePath) { + return bridge_ && + bridge_->requestValidation( + packagePath, callerUniqueName()); +} + +bool FirmwareAdaptor::StartFirmwareFlash( + const QString &approvalToken) { + return bridge_ && + bridge_->requestFlash( + approvalToken, callerUniqueName()); +} + +void FirmwareAdaptor::CancelFirmware() { + if (bridge_) { + bridge_->requestCancel(callerUniqueName()); + } +} + +bool FirmwareAdaptor:: + AcknowledgeFirmwareRecovery() { + return bridge_ && + bridge_ + ->requestRecoveryAcknowledgement( + callerUniqueName()); +} + +QString FirmwareAdaptor::callerUniqueName() const { + return exportedObject_ + ? exportedObject_->callerUniqueName() + : QString(); +} + +QString tryxFirmwareInterfaceName() { + return QStringLiteral("org.tryx.Panorama.Firmware1"); +} diff --git a/src/firmwarebridge.h b/src/firmwarebridge.h new file mode 100644 index 0000000..2e2e990 --- /dev/null +++ b/src/firmwarebridge.h @@ -0,0 +1,184 @@ +#pragma once + +#include "firmwarerecoveryjournal.h" + +#include +#include +#include +#include +#include + +#include +#include + +class DeviceManager; +class FirmwareUpdater; +class QTemporaryDir; +class PrinterProtocolTests; +class TryxRuntimeExportedObject; + +class FirmwareBridge final : public QObject { + Q_OBJECT + +public: + static constexpr quint32 InterfaceVersion = 2; + + explicit FirmwareBridge(DeviceManager *deviceManager, + QObject *parent = nullptr, + const QString &recoveryJournalPath = {}); + ~FirmwareBridge() override; + + QVariantMap stateForCaller(const QString &callerUniqueName) const; + bool requestValidation(const QString &packagePath, + const QString &callerUniqueName); + bool requestFlash(const QString &approvalToken, + const QString &callerUniqueName); + void requestCancel(const QString &callerUniqueName); + bool requestRecoveryAcknowledgement( + const QString &callerUniqueName); + bool shutdownInhibited() const { return shutdownInhibited_; } + bool recoveryRequired() const { + return recoveryRequired_; + } + void prepareForShutdown(); + +signals: + void stateChanged(const QVariantMap &state); + void progressChanged(int progress, const QString &message); + void finished(bool success, const QString &message); + void shutdownInhibitionChanged(bool inhibited); + +private: + struct Approval { + QString token; + QString ownerUniqueName; + QString canonicalPath; + QString sha256; + QString kind; + qint64 size = 0; + qint64 mtimeUtcMs = 0; + qint64 expiresUtcMs = 0; + }; + + void handleValidationResult(const QString &requestId, + const QString &callerUniqueName, + const QVariantMap &result); + void handleFlashRevalidationResult(const QString &requestId, + const QVariantMap &result, + const QVariantMap &stagedResult, + const std::shared_ptr &directory, + const QString &stagedPath, + const QString &stagingError); + void handleFirmwareTransportQuiesced( + const QString &leaseId, bool success, + const QString &message); + void handlePostQuiesceValidationResult( + const QString &requestId, + const QVariantMap &result); + void startApprovedFlash(const Approval &approval); + void handleUpdaterStatus(const QString &message); + void handleUpdaterProgress(int progress); + void handleUpdaterFinished(bool success, const QString &message); + void failPendingFlash(const QString &message); + void stopQuiesceDeadline(); + void releaseFirmwareGate(bool resumeTransport); + void setShutdownInhibited(bool inhibited); + static bool identityMatchesApproval( + const Approval &approval, + const QVariantMap &result); + void setFailure(const QString &message); + void publishState(); + QVariantMap publicState() const; + bool hasActiveDeviceOperation(QString *operationId = nullptr) const; + bool callerOwns(const QString &expectedOwner, + const QString &callerUniqueName) const; + bool approvalExpired(const Approval &approval) const; + static bool stageApprovedPackageCopy( + const QString &sourcePath, + qint64 expectedSize, + const QString &expectedSha256, + std::shared_ptr *directory, + QString *stagedPath, + QString *errorMessage); + static bool stagedIdentityMatchesApproval( + const Approval &approval, + const QVariantMap &result); + void clearStagedPackage(); + void loadRecoveryJournal(); + bool armRecoveryJournal( + const Approval &approval, + QString *errorMessage); + bool updateRecoveryJournalPhase( + const QString &phase, + QString *errorMessage); + bool clearCurrentAttemptRecoveryJournal( + QString *errorMessage); + QString recoveryStatusText() const; + + friend class PrinterProtocolTests; + + DeviceManager *deviceManager_ = nullptr; + QThread firmwareThread_; + QTimer quiesceDeadlineTimer_; + QObject *firmwareThreadContext_ = nullptr; + FirmwareUpdater *updater_ = nullptr; + TryxFirmwareRecoveryJournal recoveryJournal_; + bool workerReady_ = false; + bool validationBusy_ = false; + bool flashBusy_ = false; + int progress_ = 0; + QString phase_ = QStringLiteral("Initializing"); + QString status_; + QString validationRequestId_; + QString flashRevalidationRequestId_; + QString postQuiesceValidationRequestId_; + QString firmwareGateLeaseId_; + QString flashOwnerUniqueName_; + QString stagedPackagePath_; + QVariantMap package_; + std::optional approval_; + std::optional pendingFlash_; + std::shared_ptr stagedPackageDirectory_; + bool updaterStarted_ = false; + bool updaterIrreversibleStarted_ = false; + bool recoveryRequired_ = false; + bool recoveryJournalInvalid_ = false; + bool attemptInheritedRecovery_ = false; + bool currentAttemptJournalCreated_ = false; + TryxFirmwareRecoveryRecord recoveryRecord_; + std::optional + pendingRecoveryRecord_; + QString recoveryJournalError_; + bool shutdownInhibited_ = false; + bool shutdownRequested_ = false; +}; + +class FirmwareAdaptor final : public QDBusAbstractAdaptor { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.tryx.Panorama.Firmware1") + +public: + FirmwareAdaptor(TryxRuntimeExportedObject *exportedObject, + FirmwareBridge *bridge); + +public slots: + quint32 GetFirmwareApiVersion() const; + QVariantMap GetFirmwareState() const; + bool ValidateFirmware(const QString &packagePath); + bool StartFirmwareFlash(const QString &approvalToken); + void CancelFirmware(); + bool AcknowledgeFirmwareRecovery(); + +signals: + void StateChanged(const QVariantMap &state); + void ProgressChanged(int progress, const QString &message); + void Finished(bool success, const QString &message); + +private: + QString callerUniqueName() const; + + TryxRuntimeExportedObject *exportedObject_ = nullptr; + FirmwareBridge *bridge_ = nullptr; +}; + +QString tryxFirmwareInterfaceName(); diff --git a/src/firmwarerecoveryjournal.cpp b/src/firmwarerecoveryjournal.cpp new file mode 100644 index 0000000..08ba3c9 --- /dev/null +++ b/src/firmwarerecoveryjournal.cpp @@ -0,0 +1,852 @@ +#include "firmwarerecoveryjournal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +const QSet &exactJsonKeys() { + static const QSet keys{ + QStringLiteral("version"), + QStringLiteral("attemptId"), + QStringLiteral("phase"), + QStringLiteral("packageKind"), + QStringLiteral("packageSha256"), + QStringLiteral("createdUtcMs"), + QStringLiteral("updatedUtcMs"), + }; + return keys; +} + +const QSet &allowedPhases() { + static const QSet phases{ + QStringLiteral("Armed"), + QStringLiteral("Irreversible"), + QStringLiteral( + "AwaitingDeviceVerification"), + }; + return phases; +} + +const QSet &allowedPackageKinds() { + static const QSet kinds{ + QStringLiteral("LegacyAndroidOta"), + QStringLiteral("RockchipBundle"), + }; + return kinds; +} + +bool setError(QString *errorMessage, + const QString &message) { + if (errorMessage) { + *errorMessage = message; + } + return false; +} + +QString systemError(int errorNumber = errno) { + return QString::fromLocal8Bit( + std::strerror(errorNumber)); +} + +bool isCanonicalUuid(const QString &value) { + const QUuid parsed(value); + return !parsed.isNull() && + parsed.toString( + QUuid::WithoutBraces) == value; +} + +bool isSha256(const QString &value) { + if (value.size() != 64) { + return false; + } + for (const QChar character : value) { + const bool decimal = + character >= QLatin1Char('0') && + character <= QLatin1Char('9'); + const bool hexadecimal = + character >= QLatin1Char('a') && + character <= QLatin1Char('f'); + if (!decimal && !hexadecimal) { + return false; + } + } + return true; +} + +bool parsePositiveInteger( + const QJsonValue &value, qint64 *result) { + if (!result || !value.isString()) { + return false; + } + const QString encoded = value.toString(); + if (encoded.isEmpty() || + (encoded.size() > 1 && + encoded.startsWith(QLatin1Char('0')))) { + return false; + } + for (const QChar character : encoded) { + if (character < QLatin1Char('0') || + character > QLatin1Char('9')) { + return false; + } + } + bool ok = false; + const qint64 parsed = + encoded.toLongLong(&ok); + if (!ok || parsed <= 0 || + QString::number(parsed) != encoded) { + return false; + } + *result = parsed; + return true; +} + +bool directoryStatusIsSafe( + const struct stat &status) { + return S_ISDIR(status.st_mode) && + status.st_uid == ::geteuid() && + (status.st_mode & 07777) == + (S_IRUSR | S_IWUSR | S_IXUSR); +} + +bool journalFileStatusIsSafe( + const struct stat &status, + bool requireBoundedContents) { + return S_ISREG(status.st_mode) && + status.st_uid == ::geteuid() && + (status.st_mode & 07777) == + (S_IRUSR | S_IWUSR) && + status.st_nlink == 1 && + (!requireBoundedContents || + (status.st_size > 0 && + status.st_size <= + TryxFirmwareRecoveryJournal:: + MaximumBytes)); +} + +bool openVerifiedDirectory( + const QString &directoryPath, + bool create, + int *directoryFd, + bool *missing, + QString *errorMessage) { + if (!directoryFd || !missing) { + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery directory validation is unavailable")); + } + *directoryFd = -1; + *missing = false; + const QByteArray encodedDirectory = + QFile::encodeName(directoryPath); + struct stat before {}; + if (::lstat( + encodedDirectory.constData(), + &before) != 0) { + if (errno != ENOENT) { + return setError( + errorMessage, + QStringLiteral( + "Cannot inspect firmware recovery directory: %1") + .arg(systemError())); + } + if (!create) { + *missing = true; + return true; + } + + const QString parentPath = + QFileInfo(directoryPath) + .absolutePath(); + if (!QDir().mkpath(parentPath)) { + return setError( + errorMessage, + QStringLiteral( + "Cannot create firmware recovery parent directory")); + } + if (::mkdir( + encodedDirectory.constData(), + S_IRUSR | S_IWUSR | + S_IXUSR) != 0 && + errno != EEXIST) { + return setError( + errorMessage, + QStringLiteral( + "Cannot create firmware recovery directory: %1") + .arg(systemError())); + } + if (::lstat( + encodedDirectory.constData(), + &before) != 0) { + return setError( + errorMessage, + QStringLiteral( + "Cannot inspect created firmware recovery directory: %1") + .arg(systemError())); + } + } + + if (!directoryStatusIsSafe(before)) { + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery directory must be one owner-only 0700 directory")); + } + + const int descriptor = ::open( + encodedDirectory.constData(), + O_RDONLY | O_DIRECTORY | + O_CLOEXEC | O_NOFOLLOW); + if (descriptor < 0) { + return setError( + errorMessage, + QStringLiteral( + "Cannot open firmware recovery directory: %1") + .arg(systemError())); + } + struct stat after {}; + if (::fstat(descriptor, &after) != 0 || + !directoryStatusIsSafe(after) || + before.st_dev != after.st_dev || + before.st_ino != after.st_ino) { + const int savedError = errno; + ::close(descriptor); + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery directory changed during validation: %1") + .arg(systemError(savedError))); + } + + *directoryFd = descriptor; + return true; +} + +bool inspectJournalEntry( + int directoryFd, const QByteArray &fileName, + struct stat *status, bool *missing, + bool requireBoundedContents, + QString *errorMessage) { + if (!status || !missing) { + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery entry validation is unavailable")); + } + *missing = false; + if (::fstatat( + directoryFd, fileName.constData(), + status, AT_SYMLINK_NOFOLLOW) != 0) { + if (errno == ENOENT) { + *missing = true; + return true; + } + return setError( + errorMessage, + QStringLiteral( + "Cannot inspect firmware recovery journal: %1") + .arg(systemError())); + } + if (!journalFileStatusIsSafe( + *status, requireBoundedContents)) { + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery journal must be one owner-only 0600 regular file")); + } + return true; +} + +QJsonObject recordToJson( + const TryxFirmwareRecoveryRecord &record) { + QJsonObject object; + object.insert( + QStringLiteral("version"), + TryxFirmwareRecoveryJournal:: + FormatVersion); + object.insert( + QStringLiteral("attemptId"), + record.attemptId); + object.insert( + QStringLiteral("phase"), + record.phase); + object.insert( + QStringLiteral("packageKind"), + record.packageKind); + object.insert( + QStringLiteral("packageSha256"), + record.packageSha256); + object.insert( + QStringLiteral("createdUtcMs"), + QString::number(record.createdUtcMs)); + object.insert( + QStringLiteral("updatedUtcMs"), + QString::number(record.updatedUtcMs)); + return object; +} + +bool jsonToRecord( + const QJsonObject &object, + TryxFirmwareRecoveryRecord *record, + QString *errorMessage) { + const QStringList keys = object.keys(); + const QSet actualKeys( + keys.cbegin(), keys.cend()); + if (!record || + actualKeys != exactJsonKeys() || + !object.value( + QStringLiteral("version")) + .isDouble() || + object.value( + QStringLiteral("version")) + .toDouble(-1.0) != + static_cast( + TryxFirmwareRecoveryJournal:: + FormatVersion)) { + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery journal has an unsupported JSON shape")); + } + + static const QStringList stringFields{ + QStringLiteral("attemptId"), + QStringLiteral("phase"), + QStringLiteral("packageKind"), + QStringLiteral("packageSha256"), + QStringLiteral("createdUtcMs"), + QStringLiteral("updatedUtcMs"), + }; + for (const QString &field : stringFields) { + if (!object.value(field).isString()) { + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery journal field %1 has an invalid type") + .arg(field)); + } + } + + TryxFirmwareRecoveryRecord parsed; + parsed.attemptId = + object.value( + QStringLiteral("attemptId")) + .toString(); + parsed.phase = + object.value(QStringLiteral("phase")) + .toString(); + parsed.packageKind = + object.value( + QStringLiteral("packageKind")) + .toString(); + parsed.packageSha256 = + object.value( + QStringLiteral("packageSha256")) + .toString(); + if (!parsePositiveInteger( + object.value( + QStringLiteral("createdUtcMs")), + &parsed.createdUtcMs) || + !parsePositiveInteger( + object.value( + QStringLiteral("updatedUtcMs")), + &parsed.updatedUtcMs) || + !TryxFirmwareRecoveryJournal:: + validateRecord( + parsed, errorMessage)) { + return false; + } + *record = parsed; + return true; +} + +bool writeAll( + int descriptor, const QByteArray &data, + QString *errorMessage) { + qsizetype offset = 0; + while (offset < data.size()) { + const ssize_t written = ::write( + descriptor, + data.constData() + offset, + static_cast( + data.size() - offset)); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + return setError( + errorMessage, + QStringLiteral( + "Cannot write firmware recovery journal: %1") + .arg(systemError())); + } + offset += + static_cast(written); + } + return true; +} + +bool syncDirectory( + int directoryFd, + QString *errorMessage) { + if (::fsync(directoryFd) == 0) { + return true; + } + return setError( + errorMessage, + QStringLiteral( + "Cannot sync firmware recovery directory: %1") + .arg(systemError())); +} + +} // namespace + +TryxFirmwareRecoveryJournal:: + TryxFirmwareRecoveryJournal(QString path) + : path_( + QFileInfo( + path.trimmed().isEmpty() + ? defaultPath() + : path) + .absoluteFilePath()) {} + +QString TryxFirmwareRecoveryJournal::path() const { + return path_; +} + +QString TryxFirmwareRecoveryJournal:: + defaultPath() { + return QDir( + QStandardPaths::writableLocation( + QStandardPaths:: + AppLocalDataLocation)) + .filePath( + QStringLiteral( + "firmware-recovery/interlock.json")); +} + +bool TryxFirmwareRecoveryJournal:: + validateRecord( + const TryxFirmwareRecoveryRecord &record, + QString *errorMessage) { + if (!isCanonicalUuid(record.attemptId) || + !allowedPhases().contains( + record.phase) || + !allowedPackageKinds().contains( + record.packageKind) || + !isSha256(record.packageSha256) || + record.createdUtcMs <= 0 || + record.updatedUtcMs < + record.createdUtcMs) { + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery journal record is invalid")); + } + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +TryxFirmwareRecoveryJournalLoadResult +TryxFirmwareRecoveryJournal::load() const { + TryxFirmwareRecoveryJournalLoadResult result; + const QFileInfo pathInfo(path_); + const QString directoryPath = + pathInfo.absolutePath(); + const QByteArray fileName = + QFile::encodeName(pathInfo.fileName()); + if (fileName.isEmpty() || + fileName == QByteArrayLiteral(".") || + fileName == QByteArrayLiteral("..")) { + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid; + result.error = QStringLiteral( + "Firmware recovery journal path is invalid"); + return result; + } + + int directoryFd = -1; + bool directoryMissing = false; + if (!openVerifiedDirectory( + directoryPath, false, + &directoryFd, &directoryMissing, + &result.error)) { + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid; + return result; + } + if (directoryMissing) { + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Missing; + return result; + } + + struct stat before {}; + bool missing = false; + if (!inspectJournalEntry( + directoryFd, fileName, &before, + &missing, true, &result.error)) { + ::close(directoryFd); + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid; + return result; + } + if (missing) { + ::close(directoryFd); + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Missing; + return result; + } + + const int descriptor = ::openat( + directoryFd, fileName.constData(), + O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (descriptor < 0) { + result.error = QStringLiteral( + "Cannot open firmware recovery journal: %1") + .arg(systemError()); + ::close(directoryFd); + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid; + return result; + } + struct stat opened {}; + if (::fstat(descriptor, &opened) != 0 || + !journalFileStatusIsSafe( + opened, true) || + opened.st_dev != before.st_dev || + opened.st_ino != before.st_ino) { + result.error = QStringLiteral( + "Firmware recovery journal changed during validation"); + ::close(descriptor); + ::close(directoryFd); + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid; + return result; + } + + QByteArray contents; + contents.reserve( + static_cast(opened.st_size)); + while (contents.size() < opened.st_size) { + char buffer[4096]; + const qint64 remaining = + opened.st_size - contents.size(); + const ssize_t bytesRead = ::read( + descriptor, buffer, + static_cast( + qMin( + remaining, + sizeof(buffer)))); + if (bytesRead < 0 && errno == EINTR) { + continue; + } + if (bytesRead <= 0) { + result.error = QStringLiteral( + "Cannot read complete firmware recovery journal"); + ::close(descriptor); + ::close(directoryFd); + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid; + return result; + } + contents.append( + buffer, + static_cast(bytesRead)); + } + struct stat after {}; + if (::fstat(descriptor, &after) != 0 || + after.st_dev != opened.st_dev || + after.st_ino != opened.st_ino || + after.st_size != opened.st_size || + !journalFileStatusIsSafe( + after, true)) { + result.error = QStringLiteral( + "Firmware recovery journal changed while reading"); + ::close(descriptor); + ::close(directoryFd); + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid; + return result; + } + ::close(descriptor); + ::close(directoryFd); + + QJsonParseError parseError; + const QJsonDocument document = + QJsonDocument::fromJson( + contents, &parseError); + if (parseError.error != + QJsonParseError::NoError || + !document.isObject() || + !jsonToRecord( + document.object(), + &result.record, + &result.error)) { + if (result.error.isEmpty()) { + result.error = QStringLiteral( + "Firmware recovery journal JSON is invalid: %1") + .arg( + parseError.errorString()); + } + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid; + return result; + } + + result.status = + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded; + return result; +} + +bool TryxFirmwareRecoveryJournal::write( + const TryxFirmwareRecoveryRecord &record, + QString *errorMessage) const { + if (!validateRecord( + record, errorMessage)) { + return false; + } + const auto existing = load(); + if (existing.status == + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid) { + return setError( + errorMessage, + existing.error); + } + + const QFileInfo pathInfo(path_); + int directoryFd = -1; + bool directoryMissing = false; + if (!openVerifiedDirectory( + pathInfo.absolutePath(), true, + &directoryFd, &directoryMissing, + errorMessage)) { + return false; + } + const QByteArray fileName = + QFile::encodeName(pathInfo.fileName()); + const QByteArray temporaryName = + QByteArrayLiteral(".") + fileName + + QByteArrayLiteral(".") + + QUuid::createUuid() + .toString(QUuid::WithoutBraces) + .toLatin1() + + QByteArrayLiteral(".tmp"); + const int descriptor = ::openat( + directoryFd, + temporaryName.constData(), + O_WRONLY | O_CREAT | O_EXCL | + O_CLOEXEC | O_NOFOLLOW, + S_IRUSR | S_IWUSR); + if (descriptor < 0) { + const QString error = + QStringLiteral( + "Cannot create firmware recovery journal: %1") + .arg(systemError()); + ::close(directoryFd); + return setError(errorMessage, error); + } + + bool success = true; + const QByteArray contents = + QJsonDocument(recordToJson(record)) + .toJson(QJsonDocument::Compact) + + '\n'; + if (contents.size() > + MaximumBytes || + ::fchmod( + descriptor, + S_IRUSR | S_IWUSR) != 0 || + !writeAll( + descriptor, contents, + errorMessage) || + ::fsync(descriptor) != 0) { + if (errorMessage && + errorMessage->isEmpty()) { + *errorMessage = QStringLiteral( + "Cannot sync firmware recovery journal: %1") + .arg(systemError()); + } + success = false; + } + const int closeResult = + ::close(descriptor); + if (closeResult != 0 && success) { + success = setError( + errorMessage, + QStringLiteral( + "Cannot close firmware recovery journal: %1") + .arg(systemError())); + } + if (success && + ::renameat( + directoryFd, + temporaryName.constData(), + directoryFd, + fileName.constData()) != 0) { + success = setError( + errorMessage, + QStringLiteral( + "Cannot publish firmware recovery journal: %1") + .arg(systemError())); + } + if (!success) { + ::unlinkat( + directoryFd, + temporaryName.constData(), 0); + ::close(directoryFd); + return false; + } + if (!syncDirectory( + directoryFd, errorMessage)) { + ::close(directoryFd); + return false; + } + ::close(directoryFd); + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +bool TryxFirmwareRecoveryJournal::clear( + QString *errorMessage) const { + return unlinkExactEntry( + true, errorMessage); +} + +bool TryxFirmwareRecoveryJournal:: + acknowledgeAndClear( + QString *errorMessage) const { + return unlinkExactEntry( + false, errorMessage); +} + +bool TryxFirmwareRecoveryJournal:: + unlinkExactEntry( + bool requireValidRecord, + QString *errorMessage) const { + if (requireValidRecord) { + const auto loaded = load(); + if (loaded.status == + TryxFirmwareRecoveryJournalLoadStatus:: + Invalid) { + return setError( + errorMessage, loaded.error); + } + if (loaded.status == + TryxFirmwareRecoveryJournalLoadStatus:: + Missing) { + if (errorMessage) { + errorMessage->clear(); + } + return true; + } + } + + const QFileInfo pathInfo(path_); + const QByteArray fileName = + QFile::encodeName(pathInfo.fileName()); + if (fileName.isEmpty() || + fileName == QByteArrayLiteral(".") || + fileName == QByteArrayLiteral("..")) { + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery journal path is invalid")); + } + int directoryFd = -1; + bool directoryMissing = false; + if (!openVerifiedDirectory( + pathInfo.absolutePath(), false, + &directoryFd, &directoryMissing, + errorMessage)) { + return false; + } + if (directoryMissing) { + if (errorMessage) { + errorMessage->clear(); + } + return true; + } + struct stat status {}; + bool missing = false; + if (requireValidRecord) { + if (!inspectJournalEntry( + directoryFd, fileName, &status, + &missing, false, errorMessage)) { + ::close(directoryFd); + return false; + } + } else if (::fstatat( + directoryFd, fileName.constData(), + &status, AT_SYMLINK_NOFOLLOW) != 0) { + if (errno == ENOENT) { + missing = true; + } else { + const QString error = + QStringLiteral( + "Cannot inspect firmware recovery journal: %1") + .arg(systemError()); + ::close(directoryFd); + return setError(errorMessage, error); + } + } else if (S_ISDIR(status.st_mode)) { + ::close(directoryFd); + return setError( + errorMessage, + QStringLiteral( + "Firmware recovery journal path names a directory")); + } + if (missing) { + ::close(directoryFd); + if (errorMessage) { + errorMessage->clear(); + } + return true; + } + if (::unlinkat( + directoryFd, fileName.constData(), + 0) != 0) { + const QString error = + QStringLiteral( + "Cannot remove firmware recovery journal: %1") + .arg(systemError()); + ::close(directoryFd); + return setError(errorMessage, error); + } + const bool synced = + syncDirectory( + directoryFd, errorMessage); + ::close(directoryFd); + return synced; +} diff --git a/src/firmwarerecoveryjournal.h b/src/firmwarerecoveryjournal.h new file mode 100644 index 0000000..7a8e75d --- /dev/null +++ b/src/firmwarerecoveryjournal.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +enum class TryxFirmwareRecoveryJournalLoadStatus { + Missing, + Loaded, + Invalid +}; + +struct TryxFirmwareRecoveryRecord { + QString attemptId; + QString phase = QStringLiteral("Armed"); + QString packageKind; + QString packageSha256; + qint64 createdUtcMs = 0; + qint64 updatedUtcMs = 0; +}; + +struct TryxFirmwareRecoveryJournalLoadResult { + TryxFirmwareRecoveryJournalLoadStatus status = + TryxFirmwareRecoveryJournalLoadStatus::Missing; + TryxFirmwareRecoveryRecord record; + QString error; +}; + +class TryxFirmwareRecoveryJournal final { +public: + static constexpr int FormatVersion = 1; + static constexpr qint64 MaximumBytes = + 64 * 1024; + + explicit TryxFirmwareRecoveryJournal( + QString path = {}); + + QString path() const; + TryxFirmwareRecoveryJournalLoadResult + load() const; + bool write( + const TryxFirmwareRecoveryRecord &record, + QString *errorMessage = nullptr) const; + bool clear( + QString *errorMessage = nullptr) const; + bool acknowledgeAndClear( + QString *errorMessage = nullptr) const; + + static QString defaultPath(); + static bool validateRecord( + const TryxFirmwareRecoveryRecord &record, + QString *errorMessage = nullptr); + +private: + bool unlinkExactEntry( + bool requireValidRecord, + QString *errorMessage) const; + + QString path_; +}; diff --git a/src/firmwareupdater.cpp b/src/firmwareupdater.cpp index 797fdc8..104a5be 100644 --- a/src/firmwareupdater.cpp +++ b/src/firmwareupdater.cpp @@ -1,6 +1,7 @@ #include "firmwareupdater.h" #include +#include #include #include #include @@ -379,6 +380,317 @@ bool FirmwareUpdater::writeRockchipMarker(const QString &path, quint32 state, return true; } +bool FirmwareUpdater::fileSha256( + const QString &path, QString *sha256, + QString *errorMessage) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (errorMessage) { + *errorMessage = + tr("Cannot read firmware package: %1") + .arg(file.errorString()); + } + return false; + } + + QCryptographicHash hash( + QCryptographicHash::Sha256); + while (!file.atEnd()) { + const QByteArray chunk = + file.read(1024 * 1024); + if (chunk.isEmpty() && + file.error() != + QFileDevice::NoError) { + if (errorMessage) { + *errorMessage = + tr("Failed while hashing firmware package: %1") + .arg(file.errorString()); + } + return false; + } + hash.addData(chunk); + } + if (sha256) { + *sha256 = QString::fromLatin1( + hash.result().toHex()); + } + return true; +} + +bool FirmwareUpdater:: + approvedPackageIdentityMatches( + const QString &path, + qint64 expectedSize, + const QString &expectedSha256, + QString *errorMessage) { + const QString normalizedSha = + expectedSha256.trimmed().toLower(); + if (expectedSize <= 0 || + normalizedSha.size() != 64 || + QFileInfo(path).size() != + expectedSize) { + if (errorMessage) { + *errorMessage = tr( + "The private firmware copy no longer matches the approved size"); + } + return false; + } + QString actualSha; + if (!fileSha256( + path, &actualSha, errorMessage)) { + return false; + } + if (actualSha != normalizedSha) { + if (errorMessage) { + *errorMessage = tr( + "The private firmware copy no longer matches the approved SHA-256"); + } + return false; + } + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +FirmwareUpdater::RockchipLoaderIdentity +FirmwareUpdater::parseRockchipLoaderIdentity( + const QString &output, + const QString &requiredSerial) { + RockchipLoaderIdentity result; + const QRegularExpression deviceLine( + QStringLiteral( + "^\\s*DevNo\\s*=\\s*(\\d+)\\s+" + "Vid\\s*=\\s*0x([0-9A-Fa-f]{4})\\s*,\\s*" + "Pid\\s*=\\s*0x([0-9A-Fa-f]{4})\\s*,\\s*" + "LocationID\\s*=\\s*(\\S+)\\s+" + "Mode\\s*=\\s*(\\S+)\\s+" + "SerialNo\\s*=\\s*(\\S*)\\s*$"), + QRegularExpression::CaseInsensitiveOption); + QList devices; + bool malformedDeviceLine = false; + for (const QString &line : + output.split('\n')) { + if (!line.contains( + QStringLiteral("DevNo"), + Qt::CaseInsensitive)) { + continue; + } + const QRegularExpressionMatch match = + deviceLine.match(line); + if (!match.hasMatch()) { + malformedDeviceLine = true; + continue; + } + bool deviceNumberOk = false; + bool vendorOk = false; + bool productOk = false; + RockchipLoaderIdentity identity; + identity.deviceNumber = + match.captured(1).toInt( + &deviceNumberOk); + identity.vendorId = + match.captured(2).toUShort( + &vendorOk, 16); + identity.productId = + match.captured(3).toUShort( + &productOk, 16); + identity.locationId = + match.captured(4).trimmed(); + identity.mode = + match.captured(5).trimmed(); + identity.serial = + match.captured(6).trimmed(); + if (!deviceNumberOk || !vendorOk || + !productOk) { + malformedDeviceLine = true; + continue; + } + devices.append(identity); + } + + const QRegularExpression countExpression( + QStringLiteral( + "connected\\s*\\(\\s*(\\d+)\\s*\\)"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch countMatch = + countExpression.match(output); + int reportedCount = -1; + if (countMatch.hasMatch()) { + bool ok = false; + reportedCount = + countMatch.captured(1).toInt(&ok); + if (!ok) { + reportedCount = -1; + } + } + + if (malformedDeviceLine) { + result.status = + RockchipProbeStatus::Unsafe; + result.error = tr( + "upgrade_tool returned a malformed Rockchip device identity"); + return result; + } + if (devices.isEmpty()) { + if (reportedCount > 0 || + output.contains( + QStringLiteral("Maskrom"), + Qt::CaseInsensitive)) { + result.status = + RockchipProbeStatus::Unsafe; + result.error = tr( + "upgrade_tool reported an unidentifiable Rockchip device"); + return result; + } + result.status = + RockchipProbeStatus::NoDevice; + result.error = tr( + "No Rockchip loader device was reported"); + return result; + } + if (devices.size() != 1 || + reportedCount != 1) { + result.status = + RockchipProbeStatus::Unsafe; + result.error = tr( + "upgrade_tool must explicitly report exactly one Rockchip loader device; disconnect all other Rockchip devices"); + return result; + } + + result = devices.first(); + result.status = RockchipProbeStatus::Unsafe; + if (result.vendorId != 0x2207 || + result.productId != 0x350a) { + result.error = tr( + "Rockchip device USB identity %1:%2 is not the supported 2207:350a loader") + .arg( + result.vendorId, 4, + 16, QLatin1Char('0')) + .arg( + result.productId, 4, + 16, QLatin1Char('0')); + return result; + } + if (result.mode.compare( + QStringLiteral("Loader"), + Qt::CaseInsensitive) != 0) { + result.error = + result.mode.compare( + QStringLiteral("Maskrom"), + Qt::CaseInsensitive) == 0 + ? tr( + "Rockchip Maskrom mode is not accepted for automatic flashing") + : tr( + "Rockchip device is not in Loader mode: %1") + .arg(result.mode); + return result; + } + if (result.locationId.isEmpty()) { + result.error = tr( + "Rockchip loader location identity is empty"); + return result; + } + if (result.serial.isEmpty() || + !result.serial.contains( + QStringLiteral("TRYX"), + Qt::CaseInsensitive)) { + result.error = tr( + "Rockchip loader serial does not identify a TRYX device"); + return result; + } + if (!requiredSerial.trimmed().isEmpty() && + result.serial != requiredSerial.trimmed()) { + result.error = tr( + "Rockchip loader serial %1 does not match the selected ADB device %2") + .arg( + result.serial, + requiredSerial.trimmed()); + return result; + } + result.status = RockchipProbeStatus::Valid; + result.error.clear(); + return result; +} + +bool FirmwareUpdater::rockchipChipInfoIsRk3568( + const QString &output, + QString *errorMessage) { + const QRegularExpression lineExpression( + QStringLiteral( + "^\\s*Chip\\s+Info\\s*:\\s*(.*)$"), + QRegularExpression::CaseInsensitiveOption); + for (const QString &line : + output.split('\n')) { + const QRegularExpressionMatch match = + lineExpression.match(line); + if (!match.hasMatch()) { + continue; + } + const QStringList tokens = + match.captured(1) + .split( + QRegularExpression( + QStringLiteral("\\s+")), + Qt::SkipEmptyParts); + if (tokens.size() < 4) { + break; + } + QByteArray identifier; + for (int index = 0; index < 4; + ++index) { + bool ok = false; + const int value = + tokens.at(index).toInt(&ok, 16); + if (!ok || + tokens.at(index).size() != 2 || + value < 0 || value > 0xff) { + identifier.clear(); + break; + } + identifier.append( + static_cast(value)); + } + std::reverse( + identifier.begin(), + identifier.end()); + if (identifier == + QByteArrayLiteral("3568")) { + if (errorMessage) { + errorMessage->clear(); + } + return true; + } + if (errorMessage) { + *errorMessage = tr( + "Rockchip chip identity is not RK3568"); + } + return false; + } + if (errorMessage) { + *errorMessage = tr( + "upgrade_tool RCI did not return a readable chip identity"); + } + return false; +} + +bool FirmwareUpdater::sameRockchipIdentity( + const RockchipLoaderIdentity &left, + const RockchipLoaderIdentity &right) { + return left.status == + RockchipProbeStatus::Valid && + right.status == + RockchipProbeStatus::Valid && + left.deviceNumber == + right.deviceNumber && + left.vendorId == right.vendorId && + left.productId == right.productId && + left.locationId == + right.locationId && + left.mode == right.mode && + left.serial == right.serial; +} + FirmwareUpdater::PackageInfo FirmwareUpdater::validatePackage(const QString &packagePath) const { PackageInfo info; info.path = packagePath; @@ -686,11 +998,26 @@ bool FirmwareUpdater::loadRockchipPartitionOffsets(QString *errorMessage) { return true; } -void FirmwareUpdater::startLegacyAdbOta(const QString &packagePath) { +void FirmwareUpdater::startLegacyAdbOta( + const QString &packagePath, + qint64 expectedSize, + const QString &expectedSha256) { if (isRunning()) { emit statusChanged(tr("Firmware update is already running")); return; } + irreversibleStarted_ = false; + rockchipWritesStarted_ = false; + packageExpectedSize_ = expectedSize; + packageSha256_ = + expectedSha256.trimmed().toLower(); + QString identityError; + if (!approvedPackageIdentityMatches( + packagePath, packageExpectedSize_, + packageSha256_, &identityError)) { + fail(identityError); + return; + } package_ = validatePackage(packagePath); if (!package_.valid) { @@ -701,16 +1028,25 @@ void FirmwareUpdater::startLegacyAdbOta(const QString &packagePath) { fail(tr("Selected package is not a legacy Android OTA package")); return; } + if (!approvedPackageIdentityMatches( + packagePath, packageExpectedSize_, + packageSha256_, &identityError)) { + fail(identityError); + return; + } adbPath_ = adbExecutable(); if (adbPath_.isEmpty()) { fail(tr("adb not found. Install android-tools to flash firmware.")); return; } - updateMode_ = UpdateMode::LegacyAdbOta; selectedSerial_.clear(); currentBuildIncremental_.clear(); + rockchipLoaderIdentity_ = {}; + rockchipNextAction_ = + RockchipNextAction::None; + rockchipWritesStarted_ = false; cancelRequested_ = false; tempDir_.reset(); @@ -719,11 +1055,26 @@ void FirmwareUpdater::startLegacyAdbOta(const QString &packagePath) { tr("Searching for TRYX device over ADB...")); } -void FirmwareUpdater::startRockchipLoaderUpdate(const QString &packagePath) { +void FirmwareUpdater::startRockchipLoaderUpdate( + const QString &packagePath, + qint64 expectedSize, + const QString &expectedSha256) { if (isRunning()) { emit statusChanged(tr("Firmware update is already running")); return; } + irreversibleStarted_ = false; + rockchipWritesStarted_ = false; + packageExpectedSize_ = expectedSize; + packageSha256_ = + expectedSha256.trimmed().toLower(); + QString identityError; + if (!approvedPackageIdentityMatches( + packagePath, packageExpectedSize_, + packageSha256_, &identityError)) { + fail(identityError); + return; + } package_ = validatePackage(packagePath); if (!package_.valid) { @@ -739,6 +1090,12 @@ void FirmwareUpdater::startRockchipLoaderUpdate(const QString &packagePath) { .arg(package_.productCode)); return; } + if (!approvedPackageIdentityMatches( + packagePath, packageExpectedSize_, + packageSha256_, &identityError)) { + fail(identityError); + return; + } adbPath_ = adbExecutable(); unzipPath_ = unzipExecutable(); @@ -782,6 +1139,10 @@ void FirmwareUpdater::startRockchipLoaderUpdate(const QString &packagePath) { lastLoaderOutput_.clear(); loaderPollAttempts_ = 0; rockchipPartitionTotal_ = 0; + rockchipLoaderIdentity_ = {}; + rockchipNextAction_ = + RockchipNextAction::None; + rockchipWritesStarted_ = false; cancelRequested_ = false; emit progressChanged(0); @@ -848,6 +1209,15 @@ void FirmwareUpdater::startProgramStep(Step step, const QString &program, .arg(stepProgramName(program), process_->errorString())); return; } + if (!irreversibleStarted_ && + (step == Step::RebootRecovery || + step == Step::WriteStartMarker)) { + irreversibleStarted_ = true; + if (step == Step::WriteStartMarker) { + rockchipWritesStarted_ = true; + } + emit irreversibleStarted(); + } stepTimer_.start(timeoutMs); } @@ -904,6 +1274,18 @@ qint64 FirmwareUpdater::parseRemoteSize(const QString &output) const { return ok ? value : -1; } +QString FirmwareUpdater::parseRemoteSha256( + const QString &output) const { + const QRegularExpression shaExpression( + QStringLiteral( + "(?:^|\\s)([0-9A-Fa-f]{64})(?:\\s|$)")); + const QRegularExpressionMatch match = + shaExpression.match(output); + return match.hasMatch() + ? match.captured(1).toLower() + : QString(); +} + void FirmwareUpdater::onProcessReadyRead() { if (!process_) { return; @@ -971,6 +1353,17 @@ void FirmwareUpdater::onProcessFinished(int exitCode, tr("Checking copied package size...")); return; } + if (finishedStep == + Step::VerifyRemoteSha256) { + startStep( + Step::VerifyRemoteSha256Fallback, + {"-s", selectedSerial_, "shell", + "toybox", "sha256sum", + kRemotePackagePath}, + 5 * 60 * 1000, + tr("Verifying copied package SHA-256 with toybox...")); + return; + } if (finishedStep == Step::RebootLoader && updateMode_ == UpdateMode::RockchipLoader) { emit statusChanged(tr("ADB reboot loader command returned an error; checking Rockchip loader anyway.")); scheduleLoaderPoll(output); @@ -1010,12 +1403,47 @@ void FirmwareUpdater::onStepTimedOut() { scheduleLoaderPoll(tr("upgrade_tool LD timed out")); return; } + const bool irreversibleCommandInFlight = + irreversibleStarted_ && process_ && + process_->state() != + QProcess::NotRunning && + (currentStep_ == + Step::RebootRecovery || + currentStep_ == + Step::WriteStartMarker || + currentStep_ == + Step::UpgradeLoader || + currentStep_ == + Step::WriteGpt || + currentStep_ == + Step::FlashPartition || + currentStep_ == + Step::WriteCompleteMarker || + currentStep_ == + Step::RebootRockchip); + if (irreversibleCommandInFlight) { + emit statusChanged(tr( + "%1 exceeded its expected duration. The command is still running and will not be interrupted because firmware state may already be changing.") + .arg( + stepProgramName( + currentProgram_))); + return; + } fail(tr("%1 command timed out").arg(stepProgramName(currentProgram_))); } void FirmwareUpdater::handleStepSuccess(const QString &output) { switch (currentStep_) { case Step::ExtractRockchipPackage: { + QString identityError; + if (!approvedPackageIdentityMatches( + package_.path, + packageExpectedSize_, + packageSha256_, + &identityError)) { + fail(identityError); + return; + } rockchipPartitions_.clear(); for (const QString &partition : kRockchipPartitionOrder) { if (QFileInfo::exists(rockchipFirmwareDir_ + "/" + partition + ".img")) { @@ -1142,6 +1570,38 @@ void FirmwareUpdater::handleStepSuccess(const QString &output) { .arg(package_.sizeBytes)); return; } + emit progressChanged(90); + startStep( + Step::VerifyRemoteSha256, + {"-s", selectedSerial_, "shell", + "sha256sum", kRemotePackagePath}, + 5 * 60 * 1000, + tr("Verifying copied package SHA-256...")); + return; + } + + case Step::VerifyRemoteSha256: + case Step::VerifyRemoteSha256Fallback: { + const QString remoteSha = + parseRemoteSha256(output); + if (remoteSha.isEmpty() && + currentStep_ == + Step::VerifyRemoteSha256) { + startStep( + Step::VerifyRemoteSha256Fallback, + {"-s", selectedSerial_, "shell", + "toybox", "sha256sum", + kRemotePackagePath}, + 5 * 60 * 1000, + tr("Verifying copied package SHA-256 with toybox...")); + return; + } + if (remoteSha.isEmpty() || + remoteSha != packageSha256_) { + fail(tr( + "Copied package SHA-256 mismatch; recovery reboot was not sent")); + return; + } emit progressChanged(95); startStep(Step::RebootRecovery, {"-s", selectedSerial_, "reboot", "recovery"}, @@ -1159,30 +1619,77 @@ void FirmwareUpdater::handleStepSuccess(const QString &output) { return; case Step::DetectLoader: - if (!rockchipLoaderDetected(output)) { + { + const RockchipLoaderIdentity identity = + parseRockchipLoaderIdentity( + output, selectedSerial_); + if (identity.status == + RockchipProbeStatus::NoDevice) { scheduleLoaderPoll(output); return; } + if (identity.status != + RockchipProbeStatus::Valid) { + fail(identity.error); + return; + } + rockchipLoaderIdentity_ = identity; emit progressChanged(20); - startProgramStep(Step::WriteStartMarker, - upgradeToolPath_, - {"WL", kRockchipMarkerAddress, rockchipStartMarkerPath_}, - 60000, - tr("Writing Rockchip update marker...")); + startProgramStep( + Step::ReadChipInfo, + upgradeToolPath_, {"RCI"}, + 10000, + tr("Confirming RK3568 chip identity...")); + return; + } + + case Step::ReadChipInfo: { + QString chipError; + if (!rockchipChipInfoIsRk3568( + output, &chipError)) { + fail(chipError); + return; + } + confirmRockchipLoader( + RockchipNextAction:: + WriteStartMarker, + tr("Rechecking the exact Rockchip loader before the first write...")); + return; + } + + case Step::ConfirmLoader: { + const RockchipLoaderIdentity identity = + parseRockchipLoaderIdentity( + output, + rockchipLoaderIdentity_.serial); + if (identity.status != + RockchipProbeStatus::Valid || + !sameRockchipIdentity( + rockchipLoaderIdentity_, + identity)) { + fail( + identity.error.isEmpty() + ? tr("The Rockchip loader identity changed before a write") + : identity.error); + return; + } + continueRockchipAction(); return; + } case Step::WriteStartMarker: emit progressChanged(25); - startProgramStep(Step::UpgradeLoader, - upgradeToolPath_, - {"UL", rockchipFirmwareDir_ + "/MiniLoaderAll.bin", "-noreset"}, - 120000, - tr("Uploading Rockchip loader...")); + confirmRockchipLoader( + RockchipNextAction:: + UpgradeLoader, + tr("Rechecking the exact Rockchip loader before uploading the loader...")); return; case Step::UpgradeLoader: emit progressChanged(34); - startRockchipGptWrite(); + confirmRockchipLoader( + RockchipNextAction::WriteGpt, + tr("Rechecking the exact Rockchip loader before writing GPT...")); return; case Step::WriteGpt: @@ -1199,11 +1706,10 @@ void FirmwareUpdater::handleStepSuccess(const QString &output) { case Step::WriteCompleteMarker: emit progressChanged(95); - startProgramStep(Step::RebootRockchip, - upgradeToolPath_, - {"RD"}, - 60000, - tr("Rebooting Rockchip device...")); + confirmRockchipLoader( + RockchipNextAction:: + RebootRockchip, + tr("Rechecking the exact Rockchip loader before rebooting...")); return; case Step::RebootRockchip: @@ -1229,6 +1735,81 @@ void FirmwareUpdater::scheduleLoaderPoll(const QString &lastOutput) { loaderPollTimer_.start(kLoaderPollIntervalMs); } +void FirmwareUpdater::confirmRockchipLoader( + RockchipNextAction nextAction, + const QString &status) { + if (rockchipLoaderIdentity_.status != + RockchipProbeStatus::Valid || + nextAction == RockchipNextAction::None) { + fail(tr( + "The confirmed Rockchip loader identity is unavailable")); + return; + } + rockchipNextAction_ = nextAction; + startProgramStep( + Step::ConfirmLoader, + upgradeToolPath_, {"LD"}, 10000, + status); +} + +void FirmwareUpdater::continueRockchipAction() { + const RockchipNextAction nextAction = + rockchipNextAction_; + rockchipNextAction_ = + RockchipNextAction::None; + switch (nextAction) { + case RockchipNextAction::WriteStartMarker: + // upgrade_tool has no per-device selector, so every write is + // preceded by an exact, unique LD identity check. The irreversible + // lock is published after QProcess confirms that this first WL + // command actually started. + startProgramStep( + Step::WriteStartMarker, + upgradeToolPath_, + {"WL", kRockchipMarkerAddress, + rockchipStartMarkerPath_}, + 60000, + tr("Writing Rockchip update marker...")); + return; + case RockchipNextAction::UpgradeLoader: + startProgramStep( + Step::UpgradeLoader, + upgradeToolPath_, + {"UL", + rockchipFirmwareDir_ + + "/MiniLoaderAll.bin", + "-noreset"}, + 120000, + tr("Uploading Rockchip loader...")); + return; + case RockchipNextAction::WriteGpt: + startRockchipGptWrite(); + return; + case RockchipNextAction::FlashPartition: + startCurrentRockchipPartitionWrite(); + return; + case RockchipNextAction::WriteCompleteMarker: + startProgramStep( + Step::WriteCompleteMarker, + upgradeToolPath_, + {"WL", kRockchipMarkerAddress, + rockchipCompleteMarkerPath_}, + 60000, + tr("Writing Rockchip completion marker...")); + return; + case RockchipNextAction::RebootRockchip: + startProgramStep( + Step::RebootRockchip, + upgradeToolPath_, {"RD"}, 60000, + tr("Rebooting Rockchip device...")); + return; + case RockchipNextAction::None: + fail(tr( + "Rockchip loader confirmation has no pending write")); + return; + } +} + void FirmwareUpdater::startRockchipGptWrite() { startProgramStep(Step::WriteGpt, upgradeToolPath_, @@ -1253,68 +1834,50 @@ void FirmwareUpdater::startNextRockchipFlashPartition() { return; } emit progressChanged(qBound(42, progress, 90)); - startProgramStep(Step::FlashPartition, - upgradeToolPath_, - {"WL", offset, imagePath}, - 30 * 60 * 1000, - tr("Flashing Rockchip partition %1...").arg(currentPartition_)); + confirmRockchipLoader( + RockchipNextAction::FlashPartition, + tr("Rechecking the exact Rockchip loader before flashing %1...") + .arg(currentPartition_)); return; } currentPartition_.clear(); emit progressChanged(92); - startProgramStep(Step::WriteCompleteMarker, - upgradeToolPath_, - {"WL", kRockchipMarkerAddress, rockchipCompleteMarkerPath_}, - 60000, - tr("Writing Rockchip completion marker...")); + confirmRockchipLoader( + RockchipNextAction:: + WriteCompleteMarker, + tr("Rechecking the exact Rockchip loader before the completion marker...")); } -bool FirmwareUpdater::rockchipLoaderDetected(const QString &output) const { - const QString probe = output.trimmed(); - if (probe.isEmpty()) { - return false; - } - if (probe.contains("not found", Qt::CaseInsensitive) || - probe.contains("no device", Qt::CaseInsensitive)) { - return false; +void FirmwareUpdater:: + startCurrentRockchipPartitionWrite() { + const QString imagePath = + rockchipFirmwareDir_ + "/" + + currentPartition_ + ".img"; + const QString offset = + rockchipPartitionOffsets_.value( + currentPartition_); + if (currentPartition_.isEmpty() || + offset.isEmpty() || + !QFileInfo::exists(imagePath)) { + fail(tr( + "Rockchip partition %1 is no longer ready for flashing") + .arg(currentPartition_)); + return; } - return probe.contains("DevNo", Qt::CaseInsensitive) || - probe.contains("Vid=", Qt::CaseInsensitive) || - probe.contains("Maskrom", Qt::CaseInsensitive) || - probe.contains("Loader", Qt::CaseInsensitive); + startProgramStep( + Step::FlashPartition, + upgradeToolPath_, + {"WL", offset, imagePath}, + 30 * 60 * 1000, + tr("Flashing Rockchip partition %1...") + .arg(currentPartition_)); } bool FirmwareUpdater::isRockchipCancelLocked() const { - if (updateMode_ != UpdateMode::RockchipLoader) { - return false; - } - - switch (currentStep_) { - case Step::WriteStartMarker: - case Step::UpgradeLoader: - case Step::WriteGpt: - case Step::WriteParameter: - case Step::FlashPartition: - case Step::WriteCompleteMarker: - case Step::RebootRockchip: - return true; - - case Step::Idle: - case Step::ExtractRockchipPackage: - case Step::ListDevices: - case Step::GetState: - case Step::GetProductDevice: - case Step::GetBuildIncremental: - case Step::PushPackage: - case Step::VerifyRemoteSize: - case Step::VerifyRemoteSizeFallback: - case Step::RebootRecovery: - case Step::RebootLoader: - case Step::DetectLoader: - return false; - } - return false; + return updateMode_ == + UpdateMode::RockchipLoader && + rockchipWritesStarted_; } void FirmwareUpdater::fail(const QString &message) { diff --git a/src/firmwareupdater.h b/src/firmwareupdater.h index 1a53ed6..1098765 100644 --- a/src/firmwareupdater.h +++ b/src/firmwareupdater.h @@ -8,9 +8,11 @@ #include #include #include +#include #include class QFileInfo; +class PrinterProtocolTests; class QTemporaryDir; class FirmwareUpdater : public QObject { @@ -77,15 +79,26 @@ class FirmwareUpdater : public QObject { QString rockchipFlashingUnavailableMessage() const; bool rockchipFlashingAvailable() const; bool isRunning() const; + bool hasStartedIrreversibleOperation() const { + return irreversibleStarted_.load( + std::memory_order_acquire); + } public slots: - void startLegacyAdbOta(const QString &packagePath); - void startRockchipLoaderUpdate(const QString &packagePath); + void startLegacyAdbOta( + const QString &packagePath, + qint64 expectedSize, + const QString &expectedSha256); + void startRockchipLoaderUpdate( + const QString &packagePath, + qint64 expectedSize, + const QString &expectedSha256); void cancel(); signals: void statusChanged(const QString &message); void progressChanged(int value); + void irreversibleStarted(); void finished(bool success, const QString &message); private slots: @@ -111,9 +124,13 @@ private slots: PushPackage, VerifyRemoteSize, VerifyRemoteSizeFallback, + VerifyRemoteSha256, + VerifyRemoteSha256Fallback, RebootRecovery, RebootLoader, DetectLoader, + ReadChipInfo, + ConfirmLoader, WriteStartMarker, UpgradeLoader, WriteGpt, @@ -123,6 +140,34 @@ private slots: RebootRockchip }; + enum class RockchipProbeStatus { + NoDevice, + Unsafe, + Valid + }; + + struct RockchipLoaderIdentity { + RockchipProbeStatus status = + RockchipProbeStatus::NoDevice; + QString error; + int deviceNumber = -1; + quint16 vendorId = 0; + quint16 productId = 0; + QString locationId; + QString mode; + QString serial; + }; + + enum class RockchipNextAction { + None, + WriteStartMarker, + UpgradeLoader, + WriteGpt, + FlashPartition, + WriteCompleteMarker, + RebootRockchip + }; + static QString adbExecutable(); static QString unzipExecutable(); static QString debugfsExecutable(); @@ -147,6 +192,22 @@ private slots: QString *errorMessage); static bool writeRockchipMarker(const QString &path, quint32 state, QString *errorMessage); + static bool fileSha256(const QString &path, QString *sha256, + QString *errorMessage); + static bool approvedPackageIdentityMatches( + const QString &path, + qint64 expectedSize, + const QString &expectedSha256, + QString *errorMessage); + static RockchipLoaderIdentity parseRockchipLoaderIdentity( + const QString &output, + const QString &requiredSerial = {}); + static bool rockchipChipInfoIsRk3568( + const QString &output, + QString *errorMessage); + static bool sameRockchipIdentity( + const RockchipLoaderIdentity &left, + const RockchipLoaderIdentity &right); PackageInfo validateLegacyAndroidOta(const QString &packagePath, const QFileInfo &fileInfo, @@ -165,11 +226,16 @@ private slots: int timeoutMs, const QString &status); bool selectAdbDevice(const QString &output, QString *errorMessage); qint64 parseRemoteSize(const QString &output) const; + QString parseRemoteSha256(const QString &output) const; void handleStepSuccess(const QString &output); void scheduleLoaderPoll(const QString &lastOutput = {}); + void confirmRockchipLoader( + RockchipNextAction nextAction, + const QString &status); + void continueRockchipAction(); void startRockchipGptWrite(); void startNextRockchipFlashPartition(); - bool rockchipLoaderDetected(const QString &output) const; + void startCurrentRockchipPartitionWrite(); bool isRockchipCancelLocked() const; void fail(const QString &message); void complete(const QString &message); @@ -186,6 +252,8 @@ private slots: QString upgradeToolPath_; QString selectedSerial_; QString currentBuildIncremental_; + QString packageSha256_; + qint64 packageExpectedSize_ = 0; QString rockchipFirmwareDir_; QString rockchipStartMarkerPath_; QString rockchipCompleteMarkerPath_; @@ -197,7 +265,15 @@ private slots: QByteArray processStdErr_; int loaderPollAttempts_ = 0; int rockchipPartitionTotal_ = 0; + RockchipLoaderIdentity rockchipLoaderIdentity_; + RockchipNextAction rockchipNextAction_ = + RockchipNextAction::None; std::unique_ptr tempDir_; PackageInfo package_; bool cancelRequested_ = false; + bool rockchipWritesStarted_ = false; + std::atomic_bool irreversibleStarted_{ + false}; + + friend class PrinterProtocolTests; }; diff --git a/src/homepage.cpp b/src/homepage.cpp deleted file mode 100644 index eff4b3f..0000000 --- a/src/homepage.cpp +++ /dev/null @@ -1,554 +0,0 @@ -#include "homepage.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -// ============================================================ -// GaugeWidget - semi-circular gauge with needle -// ============================================================ - -GaugeWidget::GaugeWidget(QWidget *parent) - : QWidget(parent) { - setMinimumSize(140, 100); - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); -} - -void GaugeWidget::setValue(double percent) { - value_ = qBound(0.0, percent, 100.0); - update(); -} - -void GaugeWidget::paintEvent(QPaintEvent *) { - QPainter p(this); - p.setRenderHint(QPainter::Antialiasing, true); - - const int w = width(); - const int h = height(); - const int margin = 10; - const int maxDiam = qMin(w - 40, 160); - const int diameter = qMin(maxDiam, (h - 20) * 2); - const int radius = diameter / 2; - const QPointF center(w / 2.0, margin + radius + 4); - - const QRectF arcRect(center.x() - radius, center.y() - radius, - diameter, diameter); - - // Arc angles: Qt uses 1/16th degrees, 0 = 3 o'clock, counter-clockwise positive - // We want arc from 200 deg to -20 deg (span of 220 degrees, a wide semi-circle) - const double startAngle = 200.0; // left side - const double spanAngle = -220.0; // sweep clockwise - - // Background arc - QPen bgPen(QColor(60, 60, 80), 8, Qt::SolidLine, Qt::RoundCap); - p.setPen(bgPen); - p.drawArc(arcRect, static_cast(startAngle * 16), - static_cast(spanAngle * 16)); - - // Value arc - double valueSpan = spanAngle * (value_ / 100.0); - QPen valPen(QColor(0xDE, 0xF7, 0x50), 8, Qt::SolidLine, Qt::RoundCap); - p.setPen(valPen); - if (qAbs(valueSpan) > 0.5) { - p.drawArc(arcRect, static_cast(startAngle * 16), - static_cast(valueSpan * 16)); - } - - // Needle - double needleAngle = startAngle + spanAngle * (value_ / 100.0); - double needleRad = qDegreesToRadians(needleAngle); - double needleLen = radius - 14; - QPointF needleTip(center.x() + needleLen * qCos(needleRad), - center.y() - needleLen * qSin(needleRad)); - QPen needlePen(QColor(255, 255, 255, 200), 2, Qt::SolidLine, Qt::RoundCap); - p.setPen(needlePen); - p.drawLine(center, needleTip); - - // Center dot - p.setPen(Qt::NoPen); - p.setBrush(QColor(255, 255, 255, 220)); - p.drawEllipse(center, 3.0, 3.0); -} - -// ============================================================ -// GraphWidget - rolling line chart -// ============================================================ - -GraphWidget::GraphWidget(const QColor &lineColor, QWidget *parent) - : QWidget(parent), lineColor_(lineColor) { - setMinimumSize(200, 60); - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - data_.fill(0.0, MAX_POINTS); -} - -void GraphWidget::addValue(double val) { - data_.append(val); - if (data_.size() > MAX_POINTS) { - data_.removeFirst(); - } - update(); -} - -void GraphWidget::paintEvent(QPaintEvent *) { - QPainter p(this); - p.setRenderHint(QPainter::Antialiasing, true); - - const int w = width(); - const int h = height(); - const int pad = 4; - const double drawW = w - 2 * pad; - const double drawH = h - 2 * pad; - - // Find max for scaling - double maxVal = 1.0; - for (double v : data_) { - if (v > maxVal) maxVal = v; - } - - // Draw grid lines (subtle) - p.setPen(QPen(QColor(60, 60, 80, 80), 1)); - for (int i = 1; i < 4; i++) { - double y = pad + drawH * i / 4.0; - p.drawLine(QPointF(pad, y), QPointF(w - pad, y)); - } - - // Build path - if (data_.size() < 2) return; - - QPainterPath path; - QPainterPath fillPath; - double step = drawW / (MAX_POINTS - 1); - - for (int i = 0; i < data_.size(); i++) { - double x = pad + i * step; - double y = pad + drawH - (data_[i] / maxVal) * drawH; - if (i == 0) { - path.moveTo(x, y); - fillPath.moveTo(x, h - pad); - fillPath.lineTo(x, y); - } else { - path.lineTo(x, y); - fillPath.lineTo(x, y); - } - } - - // Fill under the curve - fillPath.lineTo(pad + (data_.size() - 1) * step, h - pad); - fillPath.closeSubpath(); - QColor fillColor = lineColor_; - fillColor.setAlpha(30); - p.setPen(Qt::NoPen); - p.setBrush(fillColor); - p.drawPath(fillPath); - - // Draw line - QPen linePen(lineColor_, 2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); - p.setPen(linePen); - p.setBrush(Qt::NoBrush); - p.drawPath(path); -} - -// ============================================================ -// Homepage -// ============================================================ - -static const QColor BG_COLOR(0x1a, 0x1a, 0x2e); -static const QColor CARD_BG(0x2a, 0x2a, 0x3e); -static const QColor CARD_BORDER(0x3a, 0x3a, 0x4e); -static const QColor ACCENT(0xDE, 0xF7, 0x50); -static const QColor TEXT_WHITE(255, 255, 255); -static const QColor TEXT_GRAY(0xaa, 0xaa, 0xaa); -static const QColor GRAPH_GREEN(0x55, 0xef, 0xc4); -static const QColor GRAPH_BLUE(0x74, 0xb9, 0xff); - -static const QString CARD_STYLE = - "QFrame#DashCard {" - " background: #2a2a3e;" - " border: 1px solid #3a3a4e;" - " border-radius: 12px;" - " padding: 16px;" - "}"; - -Homepage::Homepage(QWidget *parent) - : QWidget(parent) { - monitor_ = new SystemMonitor(this); - updateTimer_ = new QTimer(this); - - setupUi(); - - connect(updateTimer_, &QTimer::timeout, monitor_, &SystemMonitor::update); - connect(monitor_, &SystemMonitor::metricsUpdated, this, &Homepage::onMetricsUpdated); - - updateTimer_->start(2000); - monitor_->update(); -} - -QFrame *Homepage::createCard() { - auto *card = new QFrame; - card->setObjectName("DashCard"); - card->setStyleSheet(CARD_STYLE); - return card; -} - -void Homepage::setCurrentLanguage(const QString &language) { - const QString normalized = (language == "en" || language == "ru") ? language : "system"; - const int index = languageCombo_->findData(normalized); - if (index >= 0) { - QSignalBlocker blocker(languageCombo_); - languageCombo_->setCurrentIndex(index); - } -} - -void Homepage::populateLanguageCombo() { - languageCombo_->clear(); - languageCombo_->addItem(tr("System"), "system"); - languageCombo_->addItem(tr("English"), "en"); - languageCombo_->addItem(tr("Russian"), "ru"); -} - -void Homepage::setupUi() { - auto *outerLayout = new QVBoxLayout(this); - outerLayout->setContentsMargins(0, 0, 0, 0); - - auto *scrollArea = new QScrollArea; - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setStyleSheet( - "QScrollArea { border: none; background: #1a1a2e; }" - "QScrollBar:vertical { background: #1a1a2e; width: 6px; }" - "QScrollBar::handle:vertical { background: #3a3a4e; border-radius: 3px; }" - "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"); - - auto *scrollWidget = new QWidget; - scrollWidget->setStyleSheet("background: #1a1a2e;"); - auto *mainLayout = new QVBoxLayout(scrollWidget); - mainLayout->setSpacing(16); - mainLayout->setContentsMargins(24, 24, 24, 24); - - auto *headerLayout = new QHBoxLayout; - headerLayout->setSpacing(12); - - auto *titleBox = new QVBoxLayout; - titleBox->setSpacing(2); - - auto *titleLabel = new QLabel(tr("PANORAMA")); - QFont titleFont = titleLabel->font(); - titleFont.setPointSize(22); - titleFont.setBold(true); - titleLabel->setFont(titleFont); - titleLabel->setStyleSheet("color: #fff; background: transparent;"); - titleBox->addWidget(titleLabel); - - auto *subtitleLabel = new QLabel(tr("System Monitoring Dashboard")); - subtitleLabel->setStyleSheet("color: #666; font-size: 12px; background: transparent; margin-bottom: 4px;"); - titleBox->addWidget(subtitleLabel); - - headerLayout->addLayout(titleBox); - headerLayout->addStretch(); - - auto *languageLabel = new QLabel(tr("Language:")); - languageLabel->setStyleSheet("color: #aaa; background: transparent; font-size: 12px;"); - headerLayout->addWidget(languageLabel); - - languageCombo_ = new QComboBox; - languageCombo_->setMinimumWidth(120); - populateLanguageCombo(); - connect(languageCombo_, &QComboBox::currentIndexChanged, this, [this](int) { - emit languageChanged(languageCombo_->currentData().toString()); - }); - headerLayout->addWidget(languageCombo_); - - mainLayout->addLayout(headerLayout); - - // Cards grid: 2 rows x 3 columns conceptually - // Network spans rows 0-1, col 0 - // CPU at (0,1), GPU at (0,2) - // Memory at (1,1), Disk at (1,2) - auto *grid = new QGridLayout; - grid->setSpacing(12); - grid->setColumnStretch(0, 3); - grid->setColumnStretch(1, 2); - grid->setColumnStretch(2, 2); - grid->setRowStretch(0, 1); - grid->setRowStretch(1, 1); - - // ========== Network Status Card (spans 2 rows, left) ========== - { - auto *card = createCard(); - auto *layout = new QVBoxLayout(card); - layout->setSpacing(8); - - auto *title = new QLabel(tr("Network Status")); - QFont tf = title->font(); - tf.setPointSize(12); - tf.setBold(true); - title->setFont(tf); - title->setStyleSheet("color: #fff; border: none; background: transparent;"); - layout->addWidget(title); - - layout->addSpacing(4); - - // Download section - downloadGraph_ = new GraphWidget(GRAPH_GREEN, this); - downloadGraph_->setMinimumHeight(80); - layout->addWidget(downloadGraph_); - - netDownloadLabel_ = new QLabel(tr("Download: 0 KB/s")); - netDownloadLabel_->setStyleSheet("color: #55efc4; border: none; background: transparent; font-size: 12px;"); - QFont dlFont = netDownloadLabel_->font(); - dlFont.setBold(true); - netDownloadLabel_->setFont(dlFont); - layout->addWidget(netDownloadLabel_); - - layout->addSpacing(8); - - // Upload section - uploadGraph_ = new GraphWidget(GRAPH_BLUE, this); - uploadGraph_->setMinimumHeight(80); - layout->addWidget(uploadGraph_); - - netUploadLabel_ = new QLabel(tr("Upload: 0 KB/s")); - netUploadLabel_->setStyleSheet("color: #74b9ff; border: none; background: transparent; font-size: 12px;"); - QFont ulFont = netUploadLabel_->font(); - ulFont.setBold(true); - netUploadLabel_->setFont(ulFont); - layout->addWidget(netUploadLabel_); - - layout->addStretch(); - - grid->addWidget(card, 0, 0, 2, 1); - } - - // ========== CPU Load Card (top middle) ========== - { - auto *card = createCard(); - auto *layout = new QVBoxLayout(card); - layout->setSpacing(4); - layout->setAlignment(Qt::AlignCenter); - - cpuUsageLabel_ = new QLabel("0%"); - QFont bigFont = cpuUsageLabel_->font(); - bigFont.setPointSize(24); - bigFont.setBold(true); - cpuUsageLabel_->setFont(bigFont); - cpuUsageLabel_->setStyleSheet("color: #fff; border: none; background: transparent;"); - cpuUsageLabel_->setAlignment(Qt::AlignCenter); - layout->addWidget(cpuUsageLabel_); - - auto *subtitle = new QLabel(tr("CPU Load")); - subtitle->setStyleSheet("color: #aaa; border: none; background: transparent; font-size: 10px;"); - subtitle->setAlignment(Qt::AlignCenter); - layout->addWidget(subtitle); - - layout->addSpacing(2); - - cpuGauge_ = new GaugeWidget(this); - cpuGauge_->setMinimumHeight(100); - layout->addWidget(cpuGauge_); - - cpuTempLabel_ = new QLabel(QString::fromUtf8("\xF0\x9F\x8C\xA1 0\xC2\xB0""C")); - cpuTempLabel_->setStyleSheet("color: #fff; border: none; background: transparent; font-size: 12px;"); - cpuTempLabel_->setAlignment(Qt::AlignCenter); - layout->addWidget(cpuTempLabel_); - - grid->addWidget(card, 0, 1); - } - - // ========== GPU Load Card (top right) ========== - { - auto *card = createCard(); - auto *layout = new QVBoxLayout(card); - layout->setSpacing(4); - layout->setAlignment(Qt::AlignCenter); - - gpuUsageLabel_ = new QLabel("0%"); - QFont bigFont = gpuUsageLabel_->font(); - bigFont.setPointSize(24); - bigFont.setBold(true); - gpuUsageLabel_->setFont(bigFont); - gpuUsageLabel_->setStyleSheet("color: #fff; border: none; background: transparent;"); - gpuUsageLabel_->setAlignment(Qt::AlignCenter); - layout->addWidget(gpuUsageLabel_); - - auto *subtitle = new QLabel(tr("GPU Load")); - subtitle->setStyleSheet("color: #aaa; border: none; background: transparent; font-size: 10px;"); - subtitle->setAlignment(Qt::AlignCenter); - layout->addWidget(subtitle); - - layout->addSpacing(2); - - gpuGauge_ = new GaugeWidget(this); - gpuGauge_->setMinimumHeight(100); - layout->addWidget(gpuGauge_); - - gpuTempLabel_ = new QLabel(QString::fromUtf8("\xF0\x9F\x8C\xA1 0\xC2\xB0""C")); - gpuTempLabel_->setStyleSheet("color: #fff; border: none; background: transparent; font-size: 12px;"); - gpuTempLabel_->setAlignment(Qt::AlignCenter); - layout->addWidget(gpuTempLabel_); - - grid->addWidget(card, 0, 2); - } - - // ========== Memory Load Card (bottom middle) ========== - { - auto *card = createCard(); - auto *layout = new QVBoxLayout(card); - layout->setSpacing(6); - layout->setAlignment(Qt::AlignCenter); - - memUsageLabel_ = new QLabel("0%"); - QFont bigFont = memUsageLabel_->font(); - bigFont.setPointSize(36); - bigFont.setBold(true); - memUsageLabel_->setFont(bigFont); - memUsageLabel_->setStyleSheet("color: #fff; border: none; background: transparent;"); - memUsageLabel_->setAlignment(Qt::AlignCenter); - layout->addWidget(memUsageLabel_); - - auto *subtitle = new QLabel(tr("Memory Load")); - subtitle->setStyleSheet("color: #aaa; border: none; background: transparent; font-size: 11px;"); - subtitle->setAlignment(Qt::AlignCenter); - layout->addWidget(subtitle); - - layout->addSpacing(8); - - // Progress bar container - memBar_ = new QFrame; - memBar_->setFixedHeight(12); - memBar_->setStyleSheet( - "QFrame { background: #1a1a2e; border-radius: 6px; border: none; }"); - - memBarFill_ = new QFrame(memBar_); - memBarFill_->setFixedHeight(12); - memBarFill_->setStyleSheet( - "QFrame { background: #DEF750; border-radius: 6px; border: none; }"); - memBarFill_->setGeometry(0, 0, 0, 12); - - layout->addWidget(memBar_); - - layout->addSpacing(4); - - memDetailLabel_ = new QLabel(QString::fromUtf8("\xF0\x9F\x92\xBE 0.0G / 0G")); - memDetailLabel_->setStyleSheet("color: #aaa; border: none; background: transparent; font-size: 12px;"); - memDetailLabel_->setAlignment(Qt::AlignCenter); - layout->addWidget(memDetailLabel_); - - layout->addStretch(); - - grid->addWidget(card, 1, 1); - } - - // ========== Hard Disk Load Card (bottom right) ========== - { - auto *card = createCard(); - auto *layout = new QVBoxLayout(card); - layout->setSpacing(6); - layout->setAlignment(Qt::AlignCenter); - - diskUsageLabel_ = new QLabel("0%"); - QFont bigFont = diskUsageLabel_->font(); - bigFont.setPointSize(36); - bigFont.setBold(true); - diskUsageLabel_->setFont(bigFont); - diskUsageLabel_->setStyleSheet("color: #fff; border: none; background: transparent;"); - diskUsageLabel_->setAlignment(Qt::AlignCenter); - layout->addWidget(diskUsageLabel_); - - auto *subtitle = new QLabel(tr("Hard disk Load")); - subtitle->setStyleSheet("color: #aaa; border: none; background: transparent; font-size: 11px;"); - subtitle->setAlignment(Qt::AlignCenter); - layout->addWidget(subtitle); - - layout->addSpacing(8); - - // Progress bar container - diskBar_ = new QFrame; - diskBar_->setFixedHeight(12); - diskBar_->setStyleSheet( - "QFrame { background: #1a1a2e; border-radius: 6px; border: none; }"); - - diskBarFill_ = new QFrame(diskBar_); - diskBarFill_->setFixedHeight(12); - diskBarFill_->setStyleSheet( - "QFrame { background: #DEF750; border-radius: 6px; border: none; }"); - diskBarFill_->setGeometry(0, 0, 0, 12); - - layout->addWidget(diskBar_); - - layout->addSpacing(4); - - diskDetailLabel_ = new QLabel(QString::fromUtf8("\xF0\x9F\x92\xBF 0G / 0G")); - diskDetailLabel_->setStyleSheet("color: #aaa; border: none; background: transparent; font-size: 12px;"); - diskDetailLabel_->setAlignment(Qt::AlignCenter); - layout->addWidget(diskDetailLabel_); - - layout->addStretch(); - - grid->addWidget(card, 1, 2); - } - - mainLayout->addLayout(grid); - mainLayout->addStretch(); - - scrollArea->setWidget(scrollWidget); - outerLayout->addWidget(scrollArea); -} - -void Homepage::onMetricsUpdated(const SystemMetrics &m) { - auto formatSpeed = [](double kbs) -> QString { - if (kbs >= 1024.0) { - return QString("%1 MB/s").arg(kbs / 1024.0, 0, 'f', 1); - } - return QString("%1 KB/s").arg(kbs, 0, 'f', 0); - }; - - // CPU - int cpuUsage = static_cast(m.cpu.usagePercent); - cpuUsageLabel_->setText(QString("%1%").arg(cpuUsage)); - cpuGauge_->setValue(m.cpu.usagePercent); - cpuTempLabel_->setText(QString::fromUtf8("\xF0\x9F\x8C\xA1 %1\xC2\xB0""C") - .arg(m.cpu.temperature, 0, 'f', 0)); - - // GPU - if (!m.gpus.isEmpty()) { - int gpuUsage = static_cast(m.gpus[0].usagePercent); - gpuUsageLabel_->setText(QString("%1%").arg(gpuUsage)); - gpuGauge_->setValue(m.gpus[0].usagePercent); - gpuTempLabel_->setText(QString::fromUtf8("\xF0\x9F\x8C\xA1 %1\xC2\xB0""C") - .arg(m.gpus[0].temperature, 0, 'f', 0)); - } - - // Memory - int memUsage = static_cast(m.ram.usagePercent); - memUsageLabel_->setText(QString("%1%").arg(memUsage)); - double usedGB = m.ram.usedMB / 1024.0; - double totalGB = m.ram.totalMB / 1024.0; - memDetailLabel_->setText(QString::fromUtf8("\xF0\x9F\x92\xBE %1G / %2G") - .arg(usedGB, 0, 'f', 1) - .arg(totalGB, 0, 'f', 0)); - // Update progress bar fill width - int barWidth = memBar_->width(); - int fillWidth = static_cast(barWidth * m.ram.usagePercent / 100.0); - memBarFill_->setGeometry(0, 0, fillWidth, 12); - - // Disk - int diskUsage = static_cast(m.disk.usagePercent); - diskUsageLabel_->setText(QString("%1%").arg(diskUsage)); - diskDetailLabel_->setText(QString::fromUtf8("\xF0\x9F\x92\xBF %1G / %2G") - .arg(m.disk.usedGB) - .arg(m.disk.totalGB)); - int diskBarWidth = diskBar_->width(); - int diskFillWidth = static_cast(diskBarWidth * m.disk.usagePercent / 100.0); - diskBarFill_->setGeometry(0, 0, diskFillWidth, 12); - - // Network - netDownloadLabel_->setText(tr("Download: %1").arg(formatSpeed(m.net.rxSpeedKBs))); - netUploadLabel_->setText(tr("Upload: %1").arg(formatSpeed(m.net.txSpeedKBs))); - downloadGraph_->addValue(m.net.rxSpeedKBs); - uploadGraph_->addValue(m.net.txSpeedKBs); -} diff --git a/src/homepage.h b/src/homepage.h deleted file mode 100644 index 9a5fc14..0000000 --- a/src/homepage.h +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "systemmonitor.h" - -// Custom widget: semi-circular gauge for CPU/GPU -class GaugeWidget : public QWidget { - Q_OBJECT -public: - explicit GaugeWidget(QWidget *parent = nullptr); - void setValue(double percent); - double value() const { return value_; } - -protected: - void paintEvent(QPaintEvent *event) override; - -private: - double value_ = 0.0; -}; - -// Custom widget: rolling line graph for network -class GraphWidget : public QWidget { - Q_OBJECT -public: - explicit GraphWidget(const QColor &lineColor, QWidget *parent = nullptr); - void addValue(double val); - -protected: - void paintEvent(QPaintEvent *event) override; - -private: - QVector data_; - QColor lineColor_; - static const int MAX_POINTS = 30; -}; - -class Homepage : public QWidget { - Q_OBJECT -public: - explicit Homepage(QWidget *parent = nullptr); - - void setCurrentLanguage(const QString &language); - -signals: - void languageChanged(const QString &language); - -private slots: - void onMetricsUpdated(const SystemMetrics &metrics); - -private: - void setupUi(); - QFrame *createCard(); - void populateLanguageCombo(); - - SystemMonitor *monitor_; - QTimer *updateTimer_; - QComboBox *languageCombo_; - - // CPU card - QLabel *cpuUsageLabel_; - QLabel *cpuTempLabel_; - GaugeWidget *cpuGauge_; - - // GPU card - QLabel *gpuUsageLabel_; - QLabel *gpuTempLabel_; - GaugeWidget *gpuGauge_; - - // Memory card - QLabel *memUsageLabel_; - QLabel *memDetailLabel_; - QFrame *memBar_; - QFrame *memBarFill_; - - // Disk card - QLabel *diskUsageLabel_; - QLabel *diskDetailLabel_; - QFrame *diskBar_; - QFrame *diskBarFill_; - - // Network card - QLabel *netDownloadLabel_; - QLabel *netUploadLabel_; - GraphWidget *downloadGraph_; - GraphWidget *uploadGraph_; -}; diff --git a/src/hudpage.cpp b/src/hudpage.cpp deleted file mode 100644 index 13857bc..0000000 --- a/src/hudpage.cpp +++ /dev/null @@ -1,293 +0,0 @@ -#include "hudpage.h" -#include "devicemanager.h" - -#include -#include -#include -#include -#include -#include - -HudPage::HudPage(DeviceManager *deviceMgr, QWidget *parent) - : QWidget(parent), deviceMgr_(deviceMgr) { - - monitor_ = new SystemMonitor(this); - metricsTimer_ = new QTimer(this); - - setupUi(); - - connect(metricsTimer_, &QTimer::timeout, this, &HudPage::onSendMetrics); -} - -void HudPage::setupUi() { - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setSpacing(12); - - // Metric selection (max 3) - auto *metricsGroup = new QGroupBox(tr("Metrics on display (max 3)")); - auto *metricsLayout = new QGridLayout(metricsGroup); - - struct MetricDef { - const char *displayName; - QString protocolLabel; - QString unit; - }; - - QList defs = { - {QT_TR_NOOP("CPU Temperature"), "CPU Temperature", "°C"}, - {QT_TR_NOOP("CPU Frequency"), "CPU Frequency", "MHz"}, - {QT_TR_NOOP("CPU Usage"), "CPU Usage", "%"}, - {QT_TR_NOOP("CPU Voltage"), "CPU Voltage", "V"}, - {QT_TR_NOOP("GPU Temperature"), "GPU Temperature", "°C"}, - {QT_TR_NOOP("GPU Frequency"), "GPU Frequency", "MHz"}, - {QT_TR_NOOP("GPU Voltage"), "GPU Voltage", "V"}, - {QT_TR_NOOP("Motherboard Temperature"), "Motherboard Temperature", "°C"}, - {QT_TR_NOOP("Memory Frequency"), "Memory Frequency", "MHz"}, - {QT_TR_NOOP("Memory Utilization"), "Memory Utilization", "%"}, - {QT_TR_NOOP("Date & Time"), "Date & Time", ""}, - }; - - int row = 0, col = 0; - for (const auto &def : defs) { - auto *cb = new QCheckBox(tr(def.displayName)); - metricsLayout->addWidget(cb, row, col); - - MetricOption opt; - opt.checkbox = cb; - opt.label = def.protocolLabel; - opt.unit = def.unit; - metricOptions_.append(opt); - - connect(cb, &QCheckBox::toggled, this, &HudPage::onMetricToggled); - - col++; - if (col >= 3) { col = 0; row++; } - } - - selectionCountLabel_ = new QLabel(tr("Selected: 0 / 3")); - metricsLayout->addWidget(selectionCountLabel_, row + 1, 0, 1, 3); - - mainLayout->addWidget(metricsGroup); - - // Display settings - auto *settingsGroup = new QGroupBox(tr("Display settings")); - auto *settingsLayout = new QGridLayout(settingsGroup); - - settingsLayout->addWidget(new QLabel(tr("Position:")), 0, 0); - positionCombo_ = new QComboBox; - positionCombo_->addItem(tr("Top"), "Top"); - positionCombo_->addItem(tr("Center"), "Center"); - positionCombo_->addItem(tr("Bottom"), "Bottom"); - settingsLayout->addWidget(positionCombo_, 0, 1); - - settingsLayout->addWidget(new QLabel(tr("Alignment:")), 1, 0); - alignCombo_ = new QComboBox; - alignCombo_->addItem(tr("Left"), "Left"); - alignCombo_->addItem(tr("Center"), "Center"); - alignCombo_->addItem(tr("Right"), "Right"); - settingsLayout->addWidget(alignCombo_, 1, 1); - - textColorBtn_ = new QPushButton(tr("Text color")); - textColorBtn_->setStyleSheet("background-color: #FFFFFF;"); - settingsLayout->addWidget(textColorBtn_, 2, 0, 1, 2); - - connect(textColorBtn_, &QPushButton::clicked, this, &HudPage::onChooseTextColor); - - // Badges - cbCpuBadge_ = new QCheckBox(tr("CPU Badge")); - cbGpuBadge_ = new QCheckBox(tr("GPU Badge")); - settingsLayout->addWidget(cbCpuBadge_, 3, 0); - settingsLayout->addWidget(cbGpuBadge_, 3, 1); - - mainLayout->addWidget(settingsGroup); - - // Apply config button - applyConfigBtn_ = new QPushButton(tr("Apply configuration")); - applyConfigBtn_->setMinimumHeight(36); - mainLayout->addWidget(applyConfigBtn_); - connect(applyConfigBtn_, &QPushButton::clicked, this, &HudPage::onApplyConfig); - - // Interval + start/stop metrics sending - auto *controlGroup = new QGroupBox(tr("Send metrics")); - auto *controlLayout = new QHBoxLayout(controlGroup); - - controlLayout->addWidget(new QLabel(tr("Interval (sec):"))); - intervalSpin_ = new QSpinBox; - intervalSpin_->setRange(1, 60); - intervalSpin_->setValue(5); - controlLayout->addWidget(intervalSpin_); - - startStopBtn_ = new QPushButton(tr("Start")); - startStopBtn_->setMinimumHeight(36); - controlLayout->addWidget(startStopBtn_); - - mainLayout->addWidget(controlGroup); - - connect(startStopBtn_, &QPushButton::clicked, this, &HudPage::onStartStopClicked); - - // Status - statusLabel_ = new QLabel(tr("Metrics not being sent")); - statusLabel_->setStyleSheet("color: #888; padding: 8px;"); - mainLayout->addWidget(statusLabel_); - - mainLayout->addStretch(); -} - -void HudPage::onMetricToggled() { - int count = 0; - for (const auto &opt : metricOptions_) { - if (opt.checkbox->isChecked()) count++; - } - - selectionCountLabel_->setText(tr("Selected: %1 / 3").arg(count)); - - // Disable unchecked if already 3 selected - for (auto &opt : metricOptions_) { - if (!opt.checkbox->isChecked()) { - opt.checkbox->setEnabled(count < 3); - } - } -} - -void HudPage::onChooseTextColor() { - QColor color = QColorDialog::getColor(textColor_, this, tr("Text color")); - if (color.isValid()) { - textColor_ = color; - textColorBtn_->setStyleSheet( - QString("background-color: %1;").arg(color.name())); - } -} - -void HudPage::onApplyConfig() { - if (deviceMgr_->isPrinterClassDevicePresent()) { - emit statusMessage(tr("Screen configuration is disabled on printer-class firmware until the new protocol is verified.")); - return; - } - - applyScreenConfig(); - emit statusMessage(tr("Metrics configuration applied")); -} - -void HudPage::applyScreenConfig() { - // Collect selected labels - QStringList labels; - for (const auto &opt : metricOptions_) { - if (opt.checkbox->isChecked()) { - labels << opt.label; - } - } - - // Collect badges - QStringList badges; - if (cbCpuBadge_->isChecked()) badges << "CPU Badge"; - if (cbGpuBadge_->isChecked()) badges << "GPU Badge"; - - // Get current media from device (we don't change it, just re-apply config) - // The screen config is sent with current display settings - deviceMgr_->setScreenConfig( - {}, // media - empty means keep current - "2:1", // ratio - "Full Screen", // screenMode - "Single", // playMode - labels, // sysinfoDisplay - positionCombo_->currentData().toString(), // position - textColor_.name(), // color - alignCombo_->currentData().toString(), // align - badges, // badges - 0 // filter opacity - ); -} - -void HudPage::onStartStopClicked() { - if (hudRunning_) { - stopHud(); - } else { - startHud(); - } -} - -void HudPage::startHud() { - if (hudRunning_) return; - - // Check that at least one metric is selected - QStringList labels; - for (const auto &opt : metricOptions_) { - if (opt.checkbox->isChecked()) { - labels << opt.label; - } - } - if (labels.isEmpty()) { - emit statusMessage(tr("Select at least one metric")); - return; - } - - // Apply screen config first - applyScreenConfig(); - - hudRunning_ = true; - startStopBtn_->setText(tr("Stop")); - metricsTimer_->start(intervalSpin_->value() * 1000); - emit hudRunningChanged(true); - statusLabel_->setText(tr("Sending metrics...")); - statusLabel_->setStyleSheet("color: #4CAF50; padding: 8px;"); - - // Send first batch immediately - onSendMetrics(); -} - -void HudPage::stopHud() { - if (!hudRunning_) return; - - hudRunning_ = false; - metricsTimer_->stop(); - startStopBtn_->setText(tr("Start")); - emit hudRunningChanged(false); - statusLabel_->setText(tr("Metrics not being sent")); - statusLabel_->setStyleSheet("color: #888; padding: 8px;"); -} - -void HudPage::onSendMetrics() { - monitor_->update(); - auto metrics = monitor_->currentMetrics(); - - QStringList labels, values, units; - - for (const auto &opt : metricOptions_) { - if (!opt.checkbox->isChecked()) continue; - - QString value; - if (opt.label == "CPU Temperature") { - value = QString::number(metrics.cpu.temperature, 'f', 0); - } else if (opt.label == "CPU Frequency") { - value = QString::number(metrics.cpu.frequencyMHz, 'f', 0); - } else if (opt.label == "CPU Usage") { - value = QString::number(metrics.cpu.usagePercent, 'f', 1); - } else if (opt.label == "CPU Voltage") { - value = "0"; // TODO: read CPU voltage from sysfs - } else if (opt.label == "GPU Temperature") { - value = !metrics.gpus.isEmpty() - ? QString::number(metrics.gpus[0].temperature, 'f', 0) - : "0"; - } else if (opt.label == "GPU Frequency") { - value = "0"; // TODO: read GPU clock from sysfs - } else if (opt.label == "GPU Voltage") { - value = "0"; // TODO: read GPU voltage from sysfs - } else if (opt.label == "Motherboard Temperature") { - value = "0"; // TODO: read MB temp from sysfs - } else if (opt.label == "Memory Frequency") { - value = "0"; // TODO: read RAM frequency - } else if (opt.label == "Memory Utilization") { - value = QString::number(metrics.ram.usagePercent, 'f', 1); - } else if (opt.label == "Date & Time") { - value = QDateTime::currentDateTime().toString("hh:mm:ss"); - } - - labels << opt.label; - values << value; - units << opt.unit; - } - - deviceMgr_->sendSysinfo(labels, values, units); - - statusLabel_->setText(tr("Sent: %1 metrics").arg(labels.size())); -} diff --git a/src/hudpage.h b/src/hudpage.h deleted file mode 100644 index be83798..0000000 --- a/src/hudpage.h +++ /dev/null @@ -1,68 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "systemmonitor.h" - -class DeviceManager; - -class HudPage : public QWidget { - Q_OBJECT -public: - explicit HudPage(DeviceManager *deviceMgr, QWidget *parent = nullptr); - -signals: - void statusMessage(const QString &msg); - void hudRunningChanged(bool running); - -public slots: - void startHud(); - void stopHud(); - bool isHudRunning() const { return hudRunning_; } - -private slots: - void onStartStopClicked(); - void onSendMetrics(); - void onMetricToggled(); - void onChooseTextColor(); - void onApplyConfig(); - -private: - void setupUi(); - void applyScreenConfig(); - - DeviceManager *deviceMgr_; - SystemMonitor *monitor_; - QTimer *metricsTimer_; - bool hudRunning_ = false; - - // Metric checkboxes (max 3 can be selected) - struct MetricOption { - QCheckBox *checkbox; - QString label; // protocol label: "CPU Temperature" etc. - QString unit; // "C", "%", "MHz", "MB" etc. - }; - QList metricOptions_; - QLabel *selectionCountLabel_; - - // Display settings - QComboBox *positionCombo_; - QComboBox *alignCombo_; - QPushButton *textColorBtn_; - QColor textColor_ = QColor("#FFFFFF"); - QCheckBox *cbCpuBadge_; - QCheckBox *cbGpuBadge_; - QSpinBox *intervalSpin_; - - QPushButton *startStopBtn_; - QPushButton *applyConfigBtn_; - QLabel *statusLabel_; -}; diff --git a/src/hudrenderer.cpp b/src/hudrenderer.cpp deleted file mode 100644 index 2f30682..0000000 --- a/src/hudrenderer.cpp +++ /dev/null @@ -1,206 +0,0 @@ -#include "hudrenderer.h" -#include -#include -#include -#include -#include - -HudRenderer::HudRenderer(QObject *parent) - : QObject(parent) {} - -void HudRenderer::setConfig(const HudConfig &config) { - config_ = config; -} - -QImage HudRenderer::renderOverlay(const SystemMetrics &metrics) { - QImage image(DISPLAY_WIDTH, DISPLAY_HEIGHT, QImage::Format_ARGB32); - image.fill(Qt::transparent); - - QPainter painter(&image); - painter.setRenderHint(QPainter::Antialiasing); - painter.setRenderHint(QPainter::TextAntialiasing); - - QStringList lines = buildLines(metrics); - drawText(painter, lines, image.rect()); - - return image; -} - -QImage HudRenderer::renderPreview(const SystemMetrics &metrics, - int previewWidth, int previewHeight) { - QImage preview(DISPLAY_WIDTH, DISPLAY_HEIGHT, QImage::Format_ARGB32); - - // Draw video frame as background (or dark fallback) - if (!config_.sourceVideoPath.isEmpty()) { - QImage frame = extractVideoFrame(config_.sourceVideoPath); - if (!frame.isNull()) { - QPainter bgPainter(&preview); - bgPainter.drawImage(preview.rect(), - frame.scaled(DISPLAY_WIDTH, DISPLAY_HEIGHT, - Qt::IgnoreAspectRatio, - Qt::SmoothTransformation)); - } else { - preview.fill(QColor(20, 20, 20)); - } - } else { - preview.fill(QColor(20, 20, 20)); - } - - // Draw overlay on top - QImage overlay = renderOverlay(metrics); - QPainter painter(&preview); - painter.drawImage(0, 0, overlay); - - return preview.scaled(previewWidth, previewHeight, - Qt::KeepAspectRatio, Qt::SmoothTransformation); -} - -bool HudRenderer::saveOverlay(const QImage &image, const QString &path) { - QDir().mkpath(QFileInfo(path).absolutePath()); - return image.save(path, "PNG"); -} - -bool HudRenderer::compositeVideo(const QString &sourceVideo, - const QString &overlayPng, - const QString &outputVideo) { - QProcess proc; - proc.setProcessChannelMode(QProcess::MergedChannels); - - QStringList args; - args << "-y" - << "-i" << sourceVideo - << "-i" << overlayPng - << "-filter_complex" << "overlay=0:0:format=auto" - << "-c:v" << "libx264" - << "-preset" << "ultrafast" - << "-crf" << "23" - << "-pix_fmt" << "yuv420p" - << "-movflags" << "+faststart" - << "-an" - << outputVideo; - - proc.start("ffmpeg", args); - if (!proc.waitForFinished(60000)) { - emit compositeError("ffmpeg timeout"); - return false; - } - - if (proc.exitCode() != 0) { - emit compositeError(QString::fromUtf8(proc.readAll())); - return false; - } - - emit compositeFinished(true, outputVideo); - return true; -} - -QImage HudRenderer::extractVideoFrame(const QString &videoPath) { - QString tmpFrame = "/tmp/tryx-panorama/preview_frame.png"; - QDir().mkpath("/tmp/tryx-panorama"); - - QProcess proc; - proc.start("ffmpeg", {"-y", "-i", videoPath, - "-vf", "select=eq(n\\,0)", - "-frames:v", "1", - tmpFrame}); - proc.waitForFinished(5000); - - if (proc.exitCode() == 0) { - return QImage(tmpFrame); - } - return {}; -} - -QStringList HudRenderer::buildLines(const SystemMetrics &metrics) { - QStringList lines; - - if (config_.showCpuTemp) { - lines << QString("CPU %1 C").arg(metrics.cpu.temperature, 0, 'f', 0); - } - if (config_.showCpuUsage) { - lines << QString("CPU %1%").arg(metrics.cpu.usagePercent, 0, 'f', 1); - } - if (config_.showCpuFreq) { - lines << QString("CPU %1 MHz").arg(metrics.cpu.frequencyMHz, 0, 'f', 0); - } - - for (int i = 0; i < metrics.gpus.size(); ++i) { - const auto &gpu = metrics.gpus[i]; - QString prefix = metrics.gpus.size() > 1 - ? QString("GPU%1 ").arg(i + 1) - : QString("GPU "); - - if (config_.showGpuTemp) { - lines << prefix + QString("%1 C").arg(gpu.temperature, 0, 'f', 0); - } - if (config_.showGpuUsage) { - lines << prefix + QString("%1%").arg(gpu.usagePercent, 0, 'f', 0); - } - if (config_.showGpuVram) { - lines << prefix + QString("%1 / %2 MB").arg(gpu.vramUsedMB).arg(gpu.vramTotalMB); - } - } - - if (config_.showRam) { - lines << QString("RAM %1 / %2 MB (%3%)") - .arg(metrics.ram.usedMB) - .arg(metrics.ram.totalMB) - .arg(metrics.ram.usagePercent, 0, 'f', 1); - } - - if (config_.showNet) { - lines << QString("NET D:%1 KB/s U:%2 KB/s") - .arg(metrics.net.rxSpeedKBs, 0, 'f', 1) - .arg(metrics.net.txSpeedKBs, 0, 'f', 1); - } - - return lines; -} - -void HudRenderer::drawText(QPainter &painter, const QStringList &lines, - const QRect &rect) { - painter.setFont(config_.font); - QFontMetrics fm(config_.font); - - int lineHeight = fm.height() + config_.lineSpacing; - int totalHeight = lines.size() * lineHeight - config_.lineSpacing; - - int startY = config_.paddingY; - switch (config_.position) { - case HudConfig::Top: - startY = config_.paddingY; - break; - case HudConfig::Center: - startY = (rect.height() - totalHeight) / 2; - break; - case HudConfig::Bottom: - startY = rect.height() - totalHeight - config_.paddingY; - break; - } - - for (int i = 0; i < lines.size(); ++i) { - int y = startY + i * lineHeight + fm.ascent(); - int textWidth = fm.horizontalAdvance(lines[i]); - int x = config_.paddingX; - - switch (config_.alignment) { - case HudConfig::Left: - x = config_.paddingX; - break; - case HudConfig::HCenter: - x = (rect.width() - textWidth) / 2; - break; - case HudConfig::Right: - x = rect.width() - textWidth - config_.paddingX; - break; - } - - // Text shadow for readability on video - painter.setPen(config_.shadowColor); - painter.drawText(x + 2, y + 2, lines[i]); - - // Main text - painter.setPen(config_.textColor); - painter.drawText(x, y, lines[i]); - } -} diff --git a/src/hudrenderer.h b/src/hudrenderer.h deleted file mode 100644 index 2ef5df9..0000000 --- a/src/hudrenderer.h +++ /dev/null @@ -1,73 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "systemmonitor.h" - -struct HudConfig { - bool showCpuTemp = true; - bool showCpuUsage = true; - bool showCpuFreq = true; - bool showGpuTemp = true; - bool showGpuUsage = true; - bool showGpuVram = true; - bool showRam = true; - bool showNet = true; - - QFont font = QFont("Monospace", 28); - QColor textColor = QColor(255, 255, 255); - QColor shadowColor = QColor(0, 0, 0, 180); - - enum Position { Top, Center, Bottom }; - enum Alignment { Left, HCenter, Right }; - Position position = Top; - Alignment alignment = Left; - - int paddingX = 40; - int paddingY = 40; - int lineSpacing = 8; - - QString sourceVideoPath; -}; - -class HudRenderer : public QObject { - Q_OBJECT -public: - static constexpr int DISPLAY_WIDTH = 2240; - static constexpr int DISPLAY_HEIGHT = 1080; - - explicit HudRenderer(QObject *parent = nullptr); - - void setConfig(const HudConfig &config); - HudConfig config() const { return config_; } - - // Render transparent overlay PNG (for ffmpeg compositing) - QImage renderOverlay(const SystemMetrics &metrics); - - // Render preview with video frame background + overlay - QImage renderPreview(const SystemMetrics &metrics, int previewWidth, int previewHeight); - - bool saveOverlay(const QImage &image, const QString &path); - - // ffmpeg: composite overlay onto source video -> output video - bool compositeVideo(const QString &sourceVideo, const QString &overlayPng, - const QString &outputVideo); - - // Extract single frame from video for preview - QImage extractVideoFrame(const QString &videoPath); - -signals: - void compositeFinished(bool success, const QString &outputPath); - void compositeError(const QString &message); - -private: - QStringList buildLines(const SystemMetrics &metrics); - void drawText(QPainter &painter, const QStringList &lines, const QRect &rect); - - HudConfig config_; -}; diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index cde61ef..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,475 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "devicemanager.h" -#include "mainwindow.h" -#include "runtimebridge.h" -#include "panorama/config.hpp" - -namespace { - -constexpr int kRuntimeDbusCallTimeoutMs = 2000; - -QString instanceSocketPath() { - const QString runtimeDir = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); - const QString baseDir = runtimeDir.isEmpty() ? QDir::tempPath() : runtimeDir; - return QDir(baseDir).filePath("tryx-panorama-manager.instance"); -} - -bool daemonRequested(int argc, char *argv[]) { - for (int index = 1; index < argc; ++index) { - if (QString::fromLocal8Bit(argv[index]) == QStringLiteral("--daemon")) { - return true; - } - } - return false; -} - -bool versionRequested(int argc, char *argv[]) { - for (int index = 1; index < argc; ++index) { - if (QString::fromLocal8Bit(argv[index]) == QStringLiteral("--version")) { - return true; - } - } - return false; -} - -void configureApplicationIdentity(QCoreApplication &app) { - app.setApplicationName(QStringLiteral("TRYX Panorama Manager")); - app.setApplicationVersion(QStringLiteral(TRYX_APP_VERSION)); - app.setOrganizationName(QStringLiteral("DXVSI")); -} - -bool runtimeServiceIsRegistered() { - QDBusConnection bus = QDBusConnection::sessionBus(); - return bus.isConnected() && bus.interface() && - bus.interface()->isServiceRegistered(tryxRuntimeServiceName()); -} - -bool runtimeServiceApiCompatible(QString *errorMessage) { - QDBusInterface runtime( - tryxRuntimeServiceName(), tryxRuntimeObjectPath(), - tryxRuntimeOperationsInterfaceName(), QDBusConnection::sessionBus()); - runtime.setTimeout(kRuntimeDbusCallTimeoutMs); - const QDBusReply reply = - runtime.call(QStringLiteral("GetRuntimeApiVersion")); - if (!reply.isValid()) { - if (errorMessage) { - *errorMessage = reply.error().message(); - } - return false; - } - if (reply.value() != tryxRuntimeApiVersion()) { - if (errorMessage) { - *errorMessage = QObject::tr( - "The running TRYX runtime uses API %1, but this GUI requires API %2") - .arg(reply.value()) - .arg(tryxRuntimeApiVersion()); - } - return false; - } - return true; -} - -bool runtimeHasActiveOperation(bool *active, QString *errorMessage) { - if (active) { - *active = false; - } - QDBusInterface runtime( - tryxRuntimeServiceName(), tryxRuntimeObjectPath(), - tryxRuntimeOperationsInterfaceName(), QDBusConnection::sessionBus()); - runtime.setTimeout(kRuntimeDbusCallTimeoutMs); - const QDBusReply reply = - runtime.call(QStringLiteral("GetActiveOperation")); - if (!reply.isValid()) { - if (errorMessage) { - *errorMessage = reply.error().message(); - } - return false; - } - if (active) { - *active = !reply.value().id.isEmpty(); - } - return true; -} - -bool waitForRuntimeService(int timeoutMs, QString *errorMessage) { - if (runtimeServiceIsRegistered()) { - return true; - } - - const QDBusConnection bus = QDBusConnection::sessionBus(); - if (!bus.isConnected()) { - if (errorMessage) { - *errorMessage = QObject::tr("The user D-Bus session is unavailable"); - } - return false; - } - - QEventLoop loop; - QTimer deadline; - deadline.setSingleShot(true); - QDBusServiceWatcher watcher( - tryxRuntimeServiceName(), bus, - QDBusServiceWatcher::WatchForRegistration); - QObject::connect(&watcher, &QDBusServiceWatcher::serviceRegistered, - &loop, &QEventLoop::quit); - QObject::connect(&deadline, &QTimer::timeout, - &loop, &QEventLoop::quit); - deadline.start(qMax(1, timeoutMs)); - loop.exec(); - - if (runtimeServiceIsRegistered()) { - return true; - } - if (errorMessage) { - *errorMessage = QObject::tr( - "TRYX background runtime did not acquire its D-Bus name before the startup deadline"); - } - return false; -} - -bool controlRuntimeThroughSystemd(const QString &action, bool *unitMissing, - QString *errorMessage) { - if (unitMissing) { - *unitMissing = false; - } - - QProcess process; - process.setProcessChannelMode(QProcess::MergedChannels); - process.start(QStringLiteral("systemctl"), - {QStringLiteral("--user"), action, - QStringLiteral("tryx-panorama.service")}); - if (!process.waitForStarted(2000)) { - if (unitMissing) { - *unitMissing = true; - } - if (errorMessage) { - *errorMessage = QObject::tr("Failed to start systemctl: %1") - .arg(process.errorString()); - } - return false; - } - if (!process.waitForFinished(8000)) { - process.kill(); - process.waitForFinished(1000); - if (errorMessage) { - *errorMessage = QObject::tr( - "systemctl did not finish the TRYX runtime action before the deadline"); - } - return false; - } - - const QString output = QString::fromLocal8Bit(process.readAll()).trimmed(); - if (process.exitStatus() == QProcess::NormalExit && - process.exitCode() == 0) { - return true; - } - const bool missing = output.contains(QStringLiteral("not found"), - Qt::CaseInsensitive) || - output.contains(QStringLiteral("not be found"), - Qt::CaseInsensitive) || - output.contains(QStringLiteral("not loaded"), - Qt::CaseInsensitive); - if (unitMissing) { - *unitMissing = missing; - } - if (errorMessage) { - *errorMessage = output.isEmpty() - ? QObject::tr("systemctl failed with exit code %1") - .arg(process.exitCode()) - : output; - } - return false; -} - -bool ensureRuntimeService(QString *errorMessage) { - if (runtimeServiceIsRegistered()) { - QString compatibilityError; - if (runtimeServiceApiCompatible(&compatibilityError)) { - return true; - } - - bool hasActiveOperation = false; - QString operationError; - if (!runtimeHasActiveOperation(&hasActiveOperation, - &operationError)) { - if (errorMessage) { - *errorMessage = QObject::tr( - "An incompatible TRYX runtime is already running and its operation state could not be verified: %1") - .arg(operationError); - } - return false; - } - if (hasActiveOperation) { - if (errorMessage) { - *errorMessage = QObject::tr( - "The installed TRYX runtime must be restarted, but a media operation is still active. Finish or cancel it before reopening the GUI"); - } - return false; - } - - bool unitMissing = false; - QString restartError; - if (!controlRuntimeThroughSystemd(QStringLiteral("restart"), - &unitMissing, &restartError)) { - if (errorMessage) { - *errorMessage = unitMissing - ? QObject::tr( - "The running TRYX runtime is incompatible and the systemd user unit is not installed") - : restartError; - } - return false; - } - QString waitError; - if (!waitForRuntimeService(8000, &waitError)) { - if (errorMessage) { - *errorMessage = waitError; - } - return false; - } - if (!runtimeServiceApiCompatible(&compatibilityError)) { - if (errorMessage) { - *errorMessage = QObject::tr( - "The TRYX runtime remained incompatible after restart: %1") - .arg(compatibilityError); - } - return false; - } - return true; - } - - bool unitMissing = false; - QString systemdError; - const bool systemdStarted = - controlRuntimeThroughSystemd(QStringLiteral("start"), - &unitMissing, &systemdError); - if (!systemdStarted && !unitMissing) { - if (errorMessage) { - *errorMessage = systemdError; - } - return false; - } - - if (!systemdStarted) { - const bool launched = QProcess::startDetached( - QCoreApplication::applicationFilePath(), - {QStringLiteral("--daemon")}); - if (!launched) { - if (errorMessage) { - *errorMessage = QObject::tr( - "The systemd unit is not installed and the development runtime could not be started"); - } - return false; - } - } - - QString waitError; - if (!waitForRuntimeService(8000, &waitError)) { - if (errorMessage) { - *errorMessage = waitError; - } - return false; - } - - QString compatibilityError; - if (!runtimeServiceApiCompatible(&compatibilityError)) { - if (errorMessage) { - *errorMessage = QObject::tr( - "The TRYX runtime started, but its API is incompatible: %1") - .arg(compatibilityError); - } - return false; - } - return true; -} - -int runDaemon(QCoreApplication &app) { - registerTryxRuntimeMetaTypes(); - QDBusConnection bus = QDBusConnection::sessionBus(); - if (!bus.isConnected()) { - qCritical() << "The user D-Bus session is unavailable"; - return 2; - } - - DeviceManager manager; - QObject exportedObject; - TryxRuntimeManagerAdaptor adaptor(&exportedObject, &manager); - TryxRuntimeOperationsAdaptor operationsAdaptor( - &exportedObject, &manager, &adaptor); - if (!bus.registerObject(tryxRuntimeObjectPath(), &exportedObject, - QDBusConnection::ExportAdaptors)) { - qCritical() << "Failed to register TRYX D-Bus object:" - << bus.lastError().message(); - return 3; - } - if (!bus.registerService(tryxRuntimeServiceName())) { - qCritical() << "Failed to acquire TRYX D-Bus service name:" - << bus.lastError().message(); - bus.unregisterObject(tryxRuntimeObjectPath()); - return 4; - } - - QObject::connect(&manager, &DeviceManager::deviceError, - &app, [](const QString &message) { - qWarning().noquote() << message; - }); - QObject::connect(&manager, &DeviceManager::uploadStatus, - &app, [](const QString &message) { - qInfo().noquote() << message; - }); - QObject::connect(&manager, - &DeviceManager::printerDisplaySessionChanged, - &app, [](bool active) { - qInfo() << "PASE display session active:" << active; - }); - - manager.connectDevice(); - qInfo() << "TRYX background runtime acquired" - << tryxRuntimeServiceName(); - return app.exec(); -} - -bool notifyRunningInstance(const QString &path) { - QLocalSocket probe; - probe.connectToServer(path); - if (!probe.waitForConnected(300)) { - return false; - } - - probe.write("show"); - probe.flush(); - probe.waitForBytesWritten(300); - return true; -} - -QString configuredLanguage() { - auto config = panorama::ConfigManager::load_config(); - if (!config) { - return "system"; - } - return QString::fromStdString(config->language); -} - -void saveLanguage(const QString &language) { - panorama::Config config = panorama::ConfigManager::load_config().value_or(panorama::Config{}); - config.language = (language == "en" || language == "ru") ? language.toStdString() : "system"; - panorama::ConfigManager::save_config(config); -} - -void applyLanguage(QCoreApplication &app, QTranslator &translator, - const QString &language) { - app.removeTranslator(&translator); - - if (language == "en") { - return; - } - - const QLocale locale = language == "system" ? QLocale::system() : QLocale(language); - if (locale.language() == QLocale::English) { - return; - } - - if (translator.load(locale, "tryx-panorama", "_", ":/i18n")) { - app.installTranslator(&translator); - } -} - -} // namespace - -int main(int argc, char *argv[]) { - if (versionRequested(argc, argv)) { - std::fputs("tryx-panorama-manager " TRYX_APP_VERSION "\n", stdout); - return 0; - } - - // Suppress GStreamer device enumeration spam - setenv("GST_DEBUG", "0", 0); - setenv("PIPEWIRE_LOG_LEVEL", "0", 0); - - QLoggingCategory::setFilterRules( - "qt.multimedia.*=false\n" - "qt.core.qfuture.*=false\n"); - - if (daemonRequested(argc, argv)) { - QCoreApplication app(argc, argv); - configureApplicationIdentity(app); - QTranslator translator; - applyLanguage(app, translator, configuredLanguage()); - return runDaemon(app); - } - - QApplication app(argc, argv); - configureApplicationIdentity(app); - app.setWindowIcon(QIcon(":/tryx-panorama.png")); - app.setDesktopFileName("tryx-panorama-manager"); - - QTranslator translator; - QString activeLanguage = configuredLanguage(); - applyLanguage(app, translator, activeLanguage); - - const QString socketPath = instanceSocketPath(); - if (notifyRunningInstance(socketPath)) { - return 0; - } - - QLocalServer::removeServer(socketPath); - QLocalServer instanceServer; - if (!instanceServer.listen(socketPath)) { - qWarning() << "Failed to start single-instance server:" - << instanceServer.errorString(); - } - - auto languageHandler = [&](const QString &language) { - activeLanguage = (language == "en" || language == "ru") ? language : "system"; - saveLanguage(activeLanguage); - applyLanguage(app, translator, activeLanguage); - }; - - QString runtimeError; - registerTryxRuntimeMetaTypes(); - if (!ensureRuntimeService(&runtimeError)) { - QMessageBox::critical( - nullptr, QObject::tr("TRYX background runtime"), - QObject::tr("Failed to start the background runtime: %1") - .arg(runtimeError)); - return 1; - } - - auto *deviceManager = DeviceManager::createRemote(); - MainWindow window(activeLanguage, languageHandler, deviceManager); - window.show(); - - QObject::connect(&instanceServer, &QLocalServer::newConnection, &window, [&]() { - QLocalSocket *connection = instanceServer.nextPendingConnection(); - if (connection) { - connection->deleteLater(); - } - - window.show(); - window.setWindowState((window.windowState() & ~Qt::WindowMinimized) | Qt::WindowActive); - window.raise(); - window.activateWindow(); - }); - - return app.exec(); -} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp deleted file mode 100644 index 64a6a3a..0000000 --- a/src/mainwindow.cpp +++ /dev/null @@ -1,279 +0,0 @@ -#include "mainwindow.h" -#include "devicemanager.h" -#include "homepage.h" -#include "panoramapage.h" -#include "settingspage.h" -#include "traymanager.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -MainWindow::MainWindow(const QString ¤tLanguage, - std::function languageHandler, - DeviceManager *deviceManager, - QWidget *parent) - : QMainWindow(parent), - languageHandler_(std::move(languageHandler)), - currentLanguage_((currentLanguage == "en" || currentLanguage == "ru") - ? currentLanguage - : "system") { - - deviceMgr_ = deviceManager ? deviceManager : new DeviceManager; - if (!deviceMgr_->parent()) { - deviceMgr_->setParent(this); - } - trayMgr_ = new TrayManager(this); - - setupUi(); - setupConnections(); - - setWindowTitle(tr("TRYX Panorama Manager")); - setMinimumSize(640, 480); - resize(1100, 750); - - trayMgr_->show(); - - // Auto-connect on startup - if (!deviceMgr_->isRemote()) { - deviceMgr_->connectDevice(settingsPage_->selectedPort()); - } -} - -MainWindow::~MainWindow() = default; - -void MainWindow::setupUi() { - auto *centralWidget = new QWidget; - auto *mainLayout = new QHBoxLayout(centralWidget); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); - - // Navigation - navList_ = new QListWidget; - navList_->setFixedWidth(160); - navList_->setSpacing(2); - navList_->addItem(tr("Homepage")); - navList_->addItem(tr("Panorama")); - navList_->addItem(tr("Rota")); - navList_->addItem(tr("Settings")); - navList_->setCurrentRow(0); - - navList_->setStyleSheet( - "QListWidget {" - " background: #1a1a2e;" - " color: #aaa;" - " border: none;" - " font-size: 14px;" - " padding: 8px;" - "}" - "QListWidget::item {" - " padding: 12px 16px;" - " border-radius: 8px;" - " margin: 2px 4px;" - "}" - "QListWidget::item:selected {" - " background: #2d2d4a;" - " color: #fff;" - "}" - "QListWidget::item:hover {" - " background: #252540;" - "}"); - - mainLayout->addWidget(navList_); - - // Pages - stack_ = new QStackedWidget; - homepage_ = new Homepage; - homepage_->setCurrentLanguage(currentLanguage_); - panoramaPage_ = new PanoramaPage(deviceMgr_); - settingsPage_ = new SettingsPage(deviceMgr_); - - // Rota placeholder - auto *rotaPage = new QWidget; - auto *rotaLayout = new QVBoxLayout(rotaPage); - rotaLayout->setContentsMargins(40, 40, 40, 40); - rotaLayout->setAlignment(Qt::AlignTop); - - auto *rotaTitle = new QLabel(tr("ROTA")); - rotaTitle->setStyleSheet("color: #fff; font-size: 22px; font-weight: bold;"); - rotaLayout->addWidget(rotaTitle); - - auto *rotaSubtitle = new QLabel(tr("Lighting & Fan Speed Control")); - rotaSubtitle->setStyleSheet("color: #aaa; font-size: 13px;"); - rotaLayout->addWidget(rotaSubtitle); - - rotaLayout->addSpacing(30); - - auto *rotaStatus = new QLabel(tr("In Development")); - rotaStatus->setStyleSheet( - "color: #DEF750; font-size: 16px; font-weight: bold; " - "background: #2a2a3e; padding: 16px 32px; border-radius: 8px; border: 1px solid #DEF750;"); - rotaStatus->setAlignment(Qt::AlignCenter); - rotaLayout->addWidget(rotaStatus, 0, Qt::AlignCenter); - - rotaLayout->addSpacing(20); - - auto *rotaDesc = new QLabel( - tr("ROTA is the ARGB lighting and fan speed controller for TRYX coolers.\n\n" - "Planned features:\n" - " - ARGB lighting effects (15+ presets)\n" - " - Fan speed control (Smart/Fixed modes)\n" - " - Per-fan speed curves\n" - " - Motherboard ARGB sync")); - rotaDesc->setStyleSheet("color: #888; font-size: 12px;"); - rotaDesc->setWordWrap(true); - rotaLayout->addWidget(rotaDesc); - - rotaLayout->addStretch(); - - stack_->addWidget(homepage_); - stack_->addWidget(panoramaPage_); - stack_->addWidget(rotaPage); - stack_->addWidget(settingsPage_); - - mainLayout->addWidget(stack_, 1); - - setCentralWidget(centralWidget); - - connect(navList_, &QListWidget::currentRowChanged, stack_, &QStackedWidget::setCurrentIndex); - - // Status bar - statusLabel_ = new QLabel(tr("Disconnected")); - statusBar()->addPermanentWidget(statusLabel_); -} - -void MainWindow::setupConnections() { - // Device connection - connect(deviceMgr_, &DeviceManager::deviceConnected, this, - [this](const QString &pid, const QString &serial, - const QString &fw, const QString &) { - if (deviceMgr_->isPrinterClassDevicePresent()) { - connectedStatusText_ = - tr("Connected: %1 (%2)").arg(pid, serial); - statusLabel_->setText(connectedStatusText_); - trayMgr_->setConnected(true); - trayMgr_->showNotification("TRYX Panorama", tr("Device connected")); - return; - } - - connectedStatusText_ = - tr("Connected: %1 (S/N: %2, FW: %3)") - .arg(pid, serial, fw); - statusLabel_->setText(connectedStatusText_); - trayMgr_->setConnected(true); - trayMgr_->showNotification("TRYX Panorama", tr("Device connected")); - - deviceMgr_->startKeepalive(settingsPage_->keepaliveInterval()); - deviceMgr_->refreshMediaList(); - }); - - connect(deviceMgr_, &DeviceManager::deviceDisconnected, this, [this]() { - connectedStatusText_.clear(); - statusLabel_->setText(tr("Disconnected")); - trayMgr_->setConnected(false); - }); - - connect(deviceMgr_, &DeviceManager::deviceError, this, [this](const QString &msg) { - statusLabel_->setText(tr("Error: %1").arg(msg)); - }); - - connect(deviceMgr_, &DeviceManager::printerDisplaySessionChanged, this, - [this](bool active) { - if (active) { - statusLabel_->setText( - connectedStatusText_.isEmpty() - ? tr("Connected") - : connectedStatusText_); - deviceMgr_->refreshMediaList(); - } - }); - - connect(deviceMgr_, &DeviceManager::brightnessChanged, trayMgr_, &TrayManager::setBrightnessValue); - - setupPageConnections(); - - // Tray actions - connect(trayMgr_, &TrayManager::showWindowRequested, this, [this]() { - show(); - raise(); - activateWindow(); - }); - connect(trayMgr_, &TrayManager::hideWindowRequested, this, &QMainWindow::hide); - connect(trayMgr_, &TrayManager::quitRequested, this, [this]() { - minimizeToTray_ = false; - close(); - QApplication::quit(); - }); - connect(trayMgr_, &TrayManager::brightnessChangeRequested, - deviceMgr_, &DeviceManager::setBrightness); - connect(trayMgr_, &TrayManager::metricsToggleRequested, this, [this]() { - if (panoramaPage_->isMetricsRunning()) { - panoramaPage_->stopMetrics(); - } else { - panoramaPage_->startMetrics(); - } - }); -} - -void MainWindow::setupPageConnections() { - connect(homepage_, &Homepage::languageChanged, this, &MainWindow::onLanguageChanged); - - // Panorama page status - connect(panoramaPage_, &PanoramaPage::statusMessage, statusBar(), - [this](const QString &msg) { statusBar()->showMessage(msg, 5000); }); - connect(panoramaPage_, &PanoramaPage::metricsRunningChanged, trayMgr_, &TrayManager::setMetricsRunning); - - // Settings page status - connect(settingsPage_, &SettingsPage::statusMessage, statusBar(), - [this](const QString &msg) { statusBar()->showMessage(msg, 5000); }); -} - -void MainWindow::rebuildCentralUi() { - const int currentRow = navList_ ? navList_->currentRow() : 0; - if (statusLabel_) { - statusBar()->removeWidget(statusLabel_); - delete statusLabel_; - statusLabel_ = nullptr; - } - - setupUi(); - setupPageConnections(); - - if (navList_->count() > 0) { - navList_->setCurrentRow(qBound(0, currentRow, navList_->count() - 1)); - } - - statusLabel_->setText(deviceMgr_->isConnected() ? tr("Connected") : tr("Disconnected")); -} - -void MainWindow::onLanguageChanged(const QString &language) { - const QString normalized = (language == "en" || language == "ru") ? language : "system"; - if (normalized == currentLanguage_) { - return; - } - - currentLanguage_ = normalized; - if (languageHandler_) { - languageHandler_(currentLanguage_); - } - - QTimer::singleShot(0, this, &MainWindow::rebuildCentralUi); -} - -void MainWindow::closeEvent(QCloseEvent *event) { - if (minimizeToTray_ && settingsPage_->minimizeToTray()) { - hide(); - event->ignore(); - } else { - panoramaPage_->stopMetrics(); - if (!deviceMgr_->isRemote()) { - deviceMgr_->disconnectDevice(); - } - event->accept(); - } -} diff --git a/src/mainwindow.h b/src/mainwindow.h deleted file mode 100644 index 13e685f..0000000 --- a/src/mainwindow.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -class DeviceManager; -class Homepage; -class PanoramaPage; -class SettingsPage; -class TrayManager; - -class MainWindow : public QMainWindow { - Q_OBJECT -public: - explicit MainWindow(const QString ¤tLanguage, - std::function languageHandler, - DeviceManager *deviceManager, - QWidget *parent = nullptr); - ~MainWindow(); - -protected: - void closeEvent(QCloseEvent *event) override; - -private: - void setupUi(); - void setupConnections(); - void setupPageConnections(); - void rebuildCentralUi(); - void onLanguageChanged(const QString &language); - - DeviceManager *deviceMgr_ = nullptr; - Homepage *homepage_ = nullptr; - PanoramaPage *panoramaPage_ = nullptr; - SettingsPage *settingsPage_ = nullptr; - TrayManager *trayMgr_ = nullptr; - - QStackedWidget *stack_ = nullptr; - QListWidget *navList_ = nullptr; - QLabel *statusLabel_ = nullptr; - std::function languageHandler_; - QString currentLanguage_; - QString connectedStatusText_; - - bool minimizeToTray_ = true; -}; diff --git a/src/mediatransform.cpp b/src/mediatransform.cpp new file mode 100644 index 0000000..eddac02 --- /dev/null +++ b/src/mediatransform.cpp @@ -0,0 +1,206 @@ +#include "mediatransform.h" + +#include + +namespace { + +bool failValidation(QString *errorMessage, const QString &message) { + if (errorMessage) { + *errorMessage = message; + } + return false; +} + +bool isNeutralViewport(const TryxRuntimeMediaTransform &transform) { + return transform.zoomPermille == 1000 && + transform.focusX == 5000 && + transform.focusY == 5000; +} + +QString rotationFilter(quint32 rotationQuarterTurns) { + switch (rotationQuarterTurns) { + case 0: + return {}; + case 1: + return QStringLiteral("transpose=clock,"); + case 2: + return QStringLiteral("hflip,vflip,"); + case 3: + return QStringLiteral("transpose=cclock,"); + default: + return {}; + } +} + +QString squarePixelFilter() { + return QStringLiteral( + "scale='if(lte(sar,0),iw,max(1,round(iw*sar)))':ih," + "setsar=1,"); +} + +QString coverViewportFilter( + const QString &width, + const QString &height, + quint32 zoomPermille, + quint32 focusX, + quint32 focusY) { + return QStringLiteral( + "scale=%1:%2:force_original_aspect_ratio=increase:" + "force_divisible_by=2," + "scale='trunc(iw*%3/1000/2)*2':" + "'trunc(ih*%3/1000/2)*2'," + "crop=%1:%2:" + "'trunc((iw-%1)*%4/10000/2)*2':" + "'trunc((ih-%2)*%5/10000/2)*2',") + .arg(width, height) + .arg(zoomPermille) + .arg(focusX) + .arg(focusY); +} + +QString outputFilterSuffix() { + return QStringLiteral("setsar=1,format=yuv420p,fps=30"); +} + +} // namespace + +TryxRuntimeMediaTransform tryxLegacyFitMediaTransform() { + return {}; +} + +bool tryxMediaTransformIsValid( + const TryxRuntimeMediaTransform &transform, + QString *errorMessage) { + if (transform.schemaVersion != 1) { + return failValidation( + errorMessage, + QStringLiteral("Unsupported media transform schema version")); + } + if (transform.mode != QStringLiteral("Fit") && + transform.mode != QStringLiteral("Fill") && + transform.mode != QStringLiteral("Crop") && + transform.mode != QStringLiteral("Stretch")) { + return failValidation(errorMessage, + QStringLiteral("Unsupported media transform mode")); + } + if (transform.rotationQuarterTurns > 3) { + return failValidation( + errorMessage, + QStringLiteral("Media transform rotation is out of range")); + } + if (transform.zoomPermille < 1000 || + transform.zoomPermille > 4000) { + return failValidation( + errorMessage, + QStringLiteral("Media transform zoom is out of range")); + } + if (transform.focusX > 10000 || transform.focusY > 10000) { + return failValidation( + errorMessage, + QStringLiteral("Media transform focus is out of range")); + } + if (transform.backgroundRgb > 0x00FFFFFFU) { + return failValidation( + errorMessage, + QStringLiteral("Media transform background color is out of range")); + } + if (transform.mode != QStringLiteral("Crop") && + !isNeutralViewport(transform)) { + return failValidation( + errorMessage, + QStringLiteral( + "Zoom and focus are only supported in Crop mode")); + } + if (transform.mode != QStringLiteral("Fit") && + transform.backgroundRgb != 0) { + return failValidation( + errorMessage, + QStringLiteral( + "Background color is only supported in Fit mode")); + } + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +bool tryxMediaTransformIsLegacyFit( + const TryxRuntimeMediaTransform &transform) { + return tryxMediaTransformIsValid(transform) && + transform.schemaVersion == 1 && + transform.mode == QStringLiteral("Fit") && + transform.rotationQuarterTurns == 0 && + transform.zoomPermille == 1000 && + transform.focusX == 5000 && + transform.focusY == 5000 && + transform.backgroundRgb == 0; +} + +QString tryxMediaTransformCanonicalValue( + const TryxRuntimeMediaTransform &transform) { + if (!tryxMediaTransformIsValid(transform)) { + return {}; + } + return QStringLiteral( + "v=%1;mode=%2;rotation=%3;zoom=%4;focus-x=%5;focus-y=%6;" + "background=%7") + .arg(transform.schemaVersion) + .arg(transform.mode) + .arg(transform.rotationQuarterTurns) + .arg(transform.zoomPermille) + .arg(transform.focusX) + .arg(transform.focusY) + .arg(transform.backgroundRgb, 6, 16, QLatin1Char('0')) + .toLower(); +} + +QString tryxMediaTransformFingerprint( + const TryxRuntimeMediaTransform &transform) { + const QString canonical = tryxMediaTransformCanonicalValue(transform); + if (canonical.isEmpty()) { + return {}; + } + return QString::fromLatin1( + QCryptographicHash::hash(canonical.toUtf8(), + QCryptographicHash::Sha256) + .toHex()); +} + +QString tryxMediaTransformFfmpegFilter( + const TryxRuntimeMediaTransform &transform, + int targetWidth, + int targetHeight) { + if (!tryxMediaTransformIsValid(transform) || + targetWidth <= 0 || targetHeight <= 0 || + targetWidth % 2 != 0 || targetHeight % 2 != 0) { + return {}; + } + + const QString width = QString::number(targetWidth); + const QString height = QString::number(targetHeight); + QString filter = rotationFilter(transform.rotationQuarterTurns) + + squarePixelFilter(); + + if (transform.mode == QStringLiteral("Fit")) { + const QString background = + QStringLiteral("0x%1") + .arg(transform.backgroundRgb, 6, 16, QLatin1Char('0')); + filter += QStringLiteral( + "scale=%1:%2:force_original_aspect_ratio=decrease," + "pad=%1:%2:(ow-iw)/2:(oh-ih)/2:color=%3,") + .arg(width, height, background); + } else if (transform.mode == QStringLiteral("Fill")) { + filter += coverViewportFilter( + width, height, 1000, 5000, 5000); + } else if (transform.mode == QStringLiteral("Stretch")) { + filter += QStringLiteral("scale=%1:%2,").arg(width, height); + } else { + filter += coverViewportFilter( + width, height, + transform.zoomPermille, + transform.focusX, + transform.focusY); + } + filter += outputFilterSuffix(); + return filter; +} diff --git a/src/mediatransform.h b/src/mediatransform.h new file mode 100644 index 0000000..d7d986c --- /dev/null +++ b/src/mediatransform.h @@ -0,0 +1,23 @@ +#pragma once + +#include "runtimecontract.h" + +#include + +constexpr int kTryxMediaTargetWidth = 2240; +constexpr int kTryxMediaTargetHeight = 1080; + +TryxRuntimeMediaTransform tryxLegacyFitMediaTransform(); +bool tryxMediaTransformIsValid( + const TryxRuntimeMediaTransform &transform, + QString *errorMessage = nullptr); +bool tryxMediaTransformIsLegacyFit( + const TryxRuntimeMediaTransform &transform); +QString tryxMediaTransformCanonicalValue( + const TryxRuntimeMediaTransform &transform); +QString tryxMediaTransformFingerprint( + const TryxRuntimeMediaTransform &transform); +QString tryxMediaTransformFfmpegFilter( + const TryxRuntimeMediaTransform &transform, + int targetWidth = kTryxMediaTargetWidth, + int targetHeight = kTryxMediaTargetHeight); diff --git a/src/panoramapage.cpp b/src/panoramapage.cpp deleted file mode 100644 index 7481778..0000000 --- a/src/panoramapage.cpp +++ /dev/null @@ -1,2199 +0,0 @@ -#include "panoramapage.h" -#include "devicemanager.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static const QString THUMB_CACHE_DIR = "/tmp/tryx-panorama/thumbnails"; -static const int MEDIA_SIZE_ROLE = Qt::UserRole + 1; -static const int MEDIA_SOURCE_ROLE = Qt::UserRole + 2; -static const int MEDIA_READ_ONLY_ROLE = Qt::UserRole + 3; -static const int MEDIA_THUMBNAIL_KEY_ROLE = Qt::UserRole + 4; -static const int MEDIA_MANAGED_ORIGIN_ROLE = Qt::UserRole + 5; -static const int MEDIA_DELETE_ALLOWED_ROLE = Qt::UserRole + 6; -static const int MEDIA_DELETE_BLOCK_REASON_ROLE = Qt::UserRole + 7; -static const quint32 MEDIA_SOURCE_USER = 1U; -static const quint32 MEDIA_SOURCE_PRESET = 2U; - -PanoramaPage::PanoramaPage(DeviceManager *deviceMgr, QWidget *parent) - : QWidget(parent), deviceMgr_(deviceMgr) { - - monitor_ = new SystemMonitor(this); - metricsTimer_ = new QTimer(this); - - setupUi(); - restorePageState(); - - connect(metricsTimer_, &QTimer::timeout, this, &PanoramaPage::onSendMetrics); - - // Device signals - connect(deviceMgr_, &DeviceManager::mediaListUpdated, this, &PanoramaPage::onMediaListUpdated); - connect(deviceMgr_, &DeviceManager::mediaCatalogUpdated, this, - &PanoramaPage::onMediaCatalogUpdated); - connect(deviceMgr_, &DeviceManager::mediaUploaded, this, &PanoramaPage::onMediaUploaded); - connect(deviceMgr_, &DeviceManager::mediaDeleted, this, &PanoramaPage::onMediaDeleted); - connect(deviceMgr_, &DeviceManager::uploadStatus, this, &PanoramaPage::onUploadStatus); - connect(deviceMgr_, &DeviceManager::operationChanged, this, - &PanoramaPage::onOperationChanged); - connect(deviceMgr_, &DeviceManager::operationSnapshotUpdated, this, - [this](const TryxRuntimeOperationsSnapshot &snapshot) { - syncOperationPanel(snapshot); - }); - connect(deviceMgr_, &DeviceManager::operationRemoved, this, - [this](const QString &, quint64) { - syncOperationPanel(deviceMgr_->operationSnapshot()); - }); - connect(deviceMgr_, &DeviceManager::metricsStateUpdated, this, - &PanoramaPage::onMetricsStateUpdated); - connect(deviceMgr_, &DeviceManager::displayStateUpdated, this, - &PanoramaPage::onDisplayStateUpdated); - connect(deviceMgr_, &DeviceManager::printerDisplaySessionChanged, this, - [this](bool active) { - if (!active) { - refreshPending_ = false; - displayMutationReady_ = false; - finishBrightnessPipeline(false); - } - updateActionAvailability(); - }); - connect(deviceMgr_, &DeviceManager::printerTransportReady, this, - [this]() { - if (!deviceMgr_->isPrinterClassDevicePresent() || - !deviceMgr_->isPrinterDisplaySessionActive() || - !deviceMgr_->displayState().valid || - !deviceMgr_->operationSnapshot() - .activeOperationId.isEmpty()) { - return; - } - displayMutationReady_ = true; - updateActionAvailability(); - schedulePendingBrightness(); - }); - connect(deviceMgr_, &DeviceManager::printerPresenceChanged, this, - [this](bool present) { - if (!present) { - refreshPending_ = false; - displayMutationReady_ = false; - finishBrightnessPipeline(false); - } - updateActionAvailability(); - }); - connect(deviceMgr_, &DeviceManager::deviceError, this, - [this](const QString &message) { - refreshPending_ = false; - emit statusMessage(message); - updateActionAvailability(); - }); - connect(deviceMgr_, &DeviceManager::brightnessChanged, this, - [this](int val) { - if (deviceMgr_->isPrinterClassDevicePresent()) { - return; - } - const QSignalBlocker blocker(brightnessSlider_); - brightnessSlider_->setValue(val); - brightnessLabel_->setText(QString::number(val)); - }); - connect(deviceMgr_, &DeviceManager::screenConfigChanged, this, [this]() { - if (legacyMetricsStartPending_ && - !deviceMgr_->isPrinterClassDevicePresent()) { - legacyMetricsStartPending_ = false; - startMetrics(); - } - }); - - syncOperationPanel(deviceMgr_->operationSnapshot()); - if (deviceMgr_->hasTypedMediaCatalog()) { - onMediaCatalogUpdated(deviceMgr_->mediaCatalogSnapshot()); - } - onMetricsStateUpdated(deviceMgr_->metricsState()); - onDisplayStateUpdated(deviceMgr_->displayState()); - updateActionAvailability(); -} - -QString PanoramaPage::thumbnailCachePathForDeviceFile(const QString &fileName) const { - QString key = QFileInfo(fileName).fileName(); - if (key.isEmpty()) { - key = fileName; - } - key.replace(QLatin1Char(' '), QLatin1Char('_')); - key.replace(QLatin1Char('/'), QLatin1Char('_')); - key.replace(QLatin1Char('\\'), QLatin1Char('_')); - return THUMB_CACHE_DIR + QLatin1Char('/') + key + QStringLiteral(".jpg"); -} - -QString PanoramaPage::localPreviewSourceForDeviceFile(const QString &fileName) const { - const QString localPath = QDir(QFileInfo(THUMB_CACHE_DIR).absolutePath()) - .absoluteFilePath(QFileInfo(fileName).fileName()); - return QFileInfo::exists(localPath) ? localPath : QString(); -} - -void PanoramaPage::applyCachedThumbnailToDeviceItem(const QString &fileName) { - const QString cachePath = thumbnailCachePathForDeviceFile(fileName); - if (!QFileInfo::exists(cachePath)) { - return; - } - - QPixmap pix(cachePath); - if (pix.isNull()) { - return; - } - - const QString normalizedName = QFileInfo(fileName).fileName(); - for (int i = 0; i < fileList_->count(); ++i) { - auto *item = fileList_->item(i); - const QString itemName = QFileInfo(item->data(Qt::UserRole).toString()).fileName(); - if (itemName == normalizedName) { - item->setIcon(QIcon(pix.scaled(120, 80, - Qt::KeepAspectRatio, - Qt::SmoothTransformation))); - return; - } - } -} - -void PanoramaPage::cacheThumbnailForDeviceFile(const QString &fileName, - const QString &sourcePath) { - if (fileName.isEmpty() || sourcePath.isEmpty() || !QFileInfo::exists(sourcePath)) { - return; - } - - const QString cachePath = thumbnailCachePathForDeviceFile(fileName); - if (QFileInfo::exists(cachePath)) { - applyCachedThumbnailToDeviceItem(fileName); - return; - } - - QDir().mkpath(QFileInfo(cachePath).absolutePath()); - - QPixmap image(sourcePath); - if (!image.isNull()) { - image.scaled(384, 216, Qt::KeepAspectRatio, Qt::SmoothTransformation) - .save(cachePath, "JPG", 85); - applyCachedThumbnailToDeviceItem(fileName); - return; - } - - QStringList args = {"-y"}; - if (QFileInfo(sourcePath).fileName().contains(QStringLiteral(".h264_"))) { - args << "-f" << "h264" << "-framerate" << "30"; - } - args << "-i" << sourcePath - << "-vf" << "select=eq(n\\,0),scale=384:-1" - << "-frames:v" << "1" - << "-q:v" << "5" - << cachePath; - - auto *proc = new QProcess(this); - connect(proc, QOverload::of(&QProcess::finished), - this, [this, fileName, cachePath, proc](int exitCode, QProcess::ExitStatus) { - proc->deleteLater(); - if (exitCode == 0 && QFileInfo::exists(cachePath)) { - applyCachedThumbnailToDeviceItem(fileName); - } - }); - proc->start("ffmpeg", args); -} - -void PanoramaPage::setupUi() { - setAcceptDrops(true); - - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setSpacing(0); - mainLayout->setContentsMargins(0, 0, 0, 0); - - // Header - auto *headerWidget = new QWidget; - headerWidget->setStyleSheet("background: #1e1e2e;"); - auto *headerLayout = new QHBoxLayout(headerWidget); - headerLayout->setContentsMargins(20, 12, 20, 12); - - auto *titleLabel = new QLabel(tr("PANORAMA")); - QFont titleFont = titleLabel->font(); - titleFont.setPointSize(16); - titleFont.setBold(true); - titleLabel->setFont(titleFont); - titleLabel->setStyleSheet("color: #fff;"); - headerLayout->addWidget(titleLabel); - - headerLayout->addStretch(); - - mainLayout->addWidget(headerWidget); - - auto *customWidget = new QWidget; - setupCustomizationTab(customWidget); - mainLayout->addWidget(customWidget, 1); - - operationPanel_ = new QFrame; - operationPanel_->setStyleSheet( - "QFrame { background: #252532; border-top: 1px solid #444; }" - "QLabel { color: #ddd; }"); - auto *operationLayout = new QHBoxLayout(operationPanel_); - operationLayout->setContentsMargins(20, 8, 20, 8); - operationLayout->setSpacing(10); - - operationStatusLabel_ = new QLabel; - operationStatusLabel_->setWordWrap(true); - operationStatusLabel_->setMinimumWidth(260); - operationLayout->addWidget(operationStatusLabel_, 1); - - progressBar_ = new QProgressBar; - progressBar_->setRange(0, 0); - progressBar_->setMaximumHeight(20); - progressBar_->setMinimumWidth(220); - progressBar_->hide(); - operationLayout->addWidget(progressBar_); - - retryBtn_ = new QPushButton(tr("Retry transfer")); - retryBtn_->hide(); - operationLayout->addWidget(retryBtn_); - - cancelBtn_ = new QPushButton(tr("Cancel")); - cancelBtn_->hide(); - operationLayout->addWidget(cancelBtn_); - - operationPanel_->hide(); - mainLayout->addWidget(operationPanel_); - - connect(retryBtn_, &QPushButton::clicked, this, - &PanoramaPage::onRetryClicked); - connect(cancelBtn_, &QPushButton::clicked, this, - &PanoramaPage::onCancelClicked); - - // Display settings panel at bottom - setupDisplaySettings(); -} - -void PanoramaPage::setupCustomizationTab(QWidget *parent) { - auto *scroll = new QScrollArea; - scroll->setWidgetResizable(true); - scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scroll->setStyleSheet("QScrollArea { border: none; }"); - - auto *scrollWidget = new QWidget; - auto *layout = new QVBoxLayout(scrollWidget); - layout->setSpacing(12); - layout->setContentsMargins(20, 16, 20, 16); - - // Mode radio buttons - auto *radioLayout = new QHBoxLayout; - radioLayout->setSpacing(16); - fullScreenRadio_ = new QRadioButton(tr("Full Screen")); - fullScreenRadio_->setObjectName( - QStringLiteral("fullScreenRadioButton")); - splitScreenRadio_ = new QRadioButton(tr("Screen Splitting")); - splitScreenRadio_->setObjectName( - QStringLiteral("splitScreenRadioButton")); - fullScreenRadio_->setChecked(true); - fullScreenRadio_->setStyleSheet("color: #ccc;"); - splitScreenRadio_->setStyleSheet("color: #ccc;"); - radioLayout->addWidget(fullScreenRadio_); - radioLayout->addWidget(splitScreenRadio_); - radioLayout->addStretch(); - layout->addLayout(radioLayout); - - connect(fullScreenRadio_, &QRadioButton::toggled, this, &PanoramaPage::onScreenModeChanged); - - // --- Full Screen controls (existing) --- - fullScreenControls_ = new QWidget; - auto *fsLayout = new QVBoxLayout(fullScreenControls_); - fsLayout->setContentsMargins(0, 0, 0, 0); - fsLayout->setSpacing(8); - - auto *modeLayout = new QHBoxLayout; - modeLayout->setSpacing(12); - - screenModeCombo_ = new QComboBox(fullScreenControls_); - screenModeCombo_->addItem(tr("Full Screen"), "Full Screen"); - screenModeCombo_->addItem(tr("Screen Splitting"), "Screen Splitting"); - screenModeCombo_->hide(); // hidden, mode is now via radio buttons - - modeLayout->addWidget(new QLabel(tr("Play Mode:"))); - playModeCombo_ = new QComboBox; - playModeCombo_->addItem(tr("Single"), "Single"); - playModeCombo_->addItem(tr("Shuffle"), "Shuffle"); - playModeCombo_->addItem(tr("Loop"), "Loop"); - modeLayout->addWidget(playModeCombo_); - - modeLayout->addWidget(new QLabel(tr("Ratio:"))); - ratioCombo_ = new QComboBox; - ratioCombo_->addItems({"2:1", "1:1"}); - modeLayout->addWidget(ratioCombo_); - - modeLayout->addStretch(); - fsLayout->addLayout(modeLayout); - - // System info metrics for Full Screen - auto *fsMetricsLabel = new QLabel(tr("System info:")); - fsMetricsLabel->setStyleSheet("color: #aaa; font-size: 11px;"); - - customMetricsBtn_ = new QToolButton; - customMetricsBtn_->setText(QString::fromUtf8("0 / 3 \u25BC")); - customMetricsBtn_->setPopupMode(QToolButton::InstantPopup); - customMetricsBtn_->setStyleSheet( - "QToolButton { background: #2a2a3e; color: #fff; border: 1px solid #4a4a5e; " - "border-radius: 4px; padding: 6px 12px; min-width: 80px; font-size: 12px; } " - "QToolButton::menu-indicator { image: none; } " - "QToolButton:hover { background: #3a3a4e; }"); - - customMetricsMenu_ = new QMenu(this); - const char *metricLabels[] = { - QT_TR_NOOP("CPU Temperature"), QT_TR_NOOP("CPU Frequency"), - QT_TR_NOOP("CPU Usage"), QT_TR_NOOP("CPU Power"), - QT_TR_NOOP("GPU Temperature"), QT_TR_NOOP("GPU Frequency"), - QT_TR_NOOP("GPU Usage"), QT_TR_NOOP("GPU Power"), - QT_TR_NOOP("Memory Frequency"), QT_TR_NOOP("Memory Usage"), - QT_TR_NOOP("Date&Time") - }; - for (const auto *label : metricLabels) { - auto *wa = new QWidgetAction(customMetricsMenu_); - auto *cb = new QCheckBox(tr(label)); - cb->setProperty("protocolLabel", label); - cb->setStyleSheet("QCheckBox { color: #fff; padding: 4px 8px; } QCheckBox:hover { background: #3a3a4e; }"); - wa->setDefaultWidget(cb); - customMetricsMenu_->addAction(wa); - customMetricCheckboxes_.append(cb); - connect(cb, &QCheckBox::toggled, this, [this](bool) { - int count = 0; - for (auto *c : customMetricCheckboxes_) - if (c->isChecked()) count++; - if (count > 3) { - auto *s = qobject_cast(QObject::sender()); - if (s) s->setChecked(false); - return; - } - updateCustomMetricsButton(); - }); - } - customMetricsBtn_->setMenu(customMetricsMenu_); - - auto *metricsRow = new QHBoxLayout; - metricsRow->addWidget(fsMetricsLabel); - metricsRow->addWidget(customMetricsBtn_); - metricsRow->addStretch(); - fsLayout->addLayout(metricsRow); - - auto *overlayControls = new QHBoxLayout; - overlayControls->setSpacing(12); - overlayControls->addWidget(new QLabel(tr("Align:"))); - alignCombo_ = new QComboBox; - alignCombo_->setObjectName( - QStringLiteral("customAlignComboBox")); - alignCombo_->addItem(tr("Left"), "Left"); - alignCombo_->addItem(tr("Center"), "Center"); - alignCombo_->addItem(tr("Right"), "Right"); - overlayControls->addWidget(alignCombo_); - - textColorBtn_ = new QPushButton(tr("Color")); - textColorBtn_->setObjectName( - QStringLiteral("customTextColorButton")); - textColorBtn_->setStyleSheet( - "background-color: #DCDCDC; color: #000; padding: 4px 12px;"); - textColorBtn_->setMaximumWidth(80); - connect(textColorBtn_, &QPushButton::clicked, this, - &PanoramaPage::onChooseTextColor); - overlayControls->addWidget(textColorBtn_); - - cbCpuBadge_ = new QCheckBox(tr("CPU Badge")); - cbCpuBadge_->setObjectName( - QStringLiteral("customCpuBadgeCheckBox")); - cbCpuBadge_->setStyleSheet("color: #ccc;"); - cbGpuBadge_ = new QCheckBox(tr("GPU Badge")); - cbGpuBadge_->setObjectName( - QStringLiteral("customGpuBadgeCheckBox")); - cbGpuBadge_->setStyleSheet("color: #ccc;"); - overlayControls->addWidget(cbCpuBadge_); - overlayControls->addWidget(cbGpuBadge_); - overlayControls->addStretch(); - fsLayout->addLayout(overlayControls); - - layout->addWidget(fullScreenControls_); - - // --- Screen Splitting controls --- - splitConfigWidget_ = new SplitConfigWidget; - splitConfigWidget_->hide(); - layout->addWidget(splitConfigWidget_); - - // Drop zone - dropZone_ = new QLabel(tr("Upload a file\n(MP4, WEBM, GIF, JPG, PNG)")); - dropZone_->setAlignment(Qt::AlignCenter); - dropZone_->setMinimumHeight(80); - dropZone_->setStyleSheet( - "QLabel {" - " border: 2px dashed #555;" - " border-radius: 8px;" - " padding: 20px;" - " color: #888;" - " font-size: 13px;" - "}"); - layout->addWidget(dropZone_); - - // Upload controls - auto *uploadLayout = new QHBoxLayout; - uploadBtn_ = new QPushButton(tr("Upload File...")); - uploadBtn_->setStyleSheet( - "QPushButton { background: #6c5ce7; color: white; border: none; border-radius: 4px; padding: 6px 16px; }" - "QPushButton:hover { background: #5b4bd5; }"); - uploadLayout->addWidget(uploadBtn_); - uploadLayout->addStretch(); - layout->addLayout(uploadLayout); - - connect(uploadBtn_, &QPushButton::clicked, this, &PanoramaPage::onUploadClicked); - - // Media Library header - auto *mlHeader = new QLabel(tr("Media Library")); - QFont mlFont = mlHeader->font(); - mlFont.setPointSize(12); - mlFont.setBold(true); - mlHeader->setFont(mlFont); - mlHeader->setStyleSheet("color: #fff;"); - layout->addWidget(mlHeader); - - // File list (files on device) - visual grid with thumbnails - fileList_ = new QListWidget; - fileList_->setSelectionMode(QAbstractItemView::ExtendedSelection); - fileList_->setMinimumHeight(200); - fileList_->setMaximumHeight(320); - fileList_->setViewMode(QListView::IconMode); - fileList_->setIconSize(QSize(120, 80)); - fileList_->setGridSize(QSize(140, 120)); - fileList_->setResizeMode(QListView::Adjust); - fileList_->setWrapping(true); - fileList_->setWordWrap(true); - fileList_->setSpacing(6); - fileList_->setMovement(QListView::Static); - fileList_->setStyleSheet( - "QListWidget { background: #1e1e2e; border: 1px solid #444; border-radius: 6px; color: #ddd; padding: 6px; }" - "QListWidget::item { background: #2a2a3a; border: 1px solid #3a3a4a; border-radius: 4px; padding: 4px; }" - "QListWidget::item:selected { background: #6c5ce7; border: 1px solid #8b7cf7; }" - "QListWidget::item:hover { background: #3a3a4e; }"); - layout->addWidget(fileList_); - - // Context menu on file list (replaces buttons) - fileList_->setContextMenuPolicy(Qt::CustomContextMenu); - connect(fileList_, &QListWidget::customContextMenuRequested, - this, &PanoramaPage::onFileListContextMenu); - - // Hidden buttons for backward compat (not shown in UI) - setDisplayBtn_ = new QPushButton(scrollWidget); - setDisplayBtn_->hide(); - deleteBtn_ = new QPushButton(scrollWidget); - deleteBtn_->hide(); - - // Refresh + Save row - auto *actionLayout = new QHBoxLayout; - refreshBtn_ = new QPushButton(tr("Refresh")); - refreshBtn_->setStyleSheet( - "QPushButton { background: #3d3d4d; color: #ddd; border: none; border-radius: 4px; padding: 6px 12px; }" - "QPushButton:hover { background: #4d4d5d; }"); - connect(refreshBtn_, &QPushButton::clicked, this, &PanoramaPage::onRefreshClicked); - actionLayout->addWidget(refreshBtn_); - - actionLayout->addStretch(); - - customSaveBtn_ = new QPushButton(tr("Save")); - customSaveBtn_->setMinimumHeight(36); - customSaveBtn_->setMinimumWidth(120); - customSaveBtn_->setStyleSheet( - "QPushButton { background: #00b894; color: white; border: none; border-radius: 4px; padding: 8px 24px; font-weight: bold; font-size: 13px; }" - "QPushButton:hover { background: #00a381; }"); - connect(customSaveBtn_, &QPushButton::clicked, this, &PanoramaPage::onCustomSave); - actionLayout->addWidget(customSaveBtn_); - - layout->addLayout(actionLayout); - - metricsStatusLabel_ = new QLabel; - metricsStatusLabel_->setStyleSheet("color: #888;"); - layout->addWidget(metricsStatusLabel_); - - layout->addStretch(); - - scroll->setWidget(scrollWidget); - - auto *parentLayout = new QVBoxLayout(parent); - parentLayout->setContentsMargins(0, 0, 0, 0); - parentLayout->addWidget(scroll); -} - -void PanoramaPage::setupDisplaySettings() { - auto *settingsGroup = new QGroupBox(tr("Display Settings")); - settingsGroup->setStyleSheet( - "QGroupBox { border: 1px solid #444; border-radius: 6px; margin-top: 8px; padding-top: 16px; color: #fff; }" - "QGroupBox::title { subcontrol-origin: margin; left: 16px; padding: 0 4px; }"); - - auto *settingsLayout = new QHBoxLayout(settingsGroup); - settingsLayout->setSpacing(20); - - // Brightness - settingsLayout->addWidget(new QLabel(tr("Brightness:"))); - brightnessSlider_ = new QSlider(Qt::Horizontal); - brightnessSlider_->setObjectName( - QStringLiteral("displayBrightnessSlider")); - brightnessSlider_->setRange(0, 100); - brightnessSlider_->setValue(0); - brightnessSlider_->setMaximumWidth(200); - settingsLayout->addWidget(brightnessSlider_); - brightnessLabel_ = new QLabel(QStringLiteral("--")); - brightnessLabel_->setMinimumWidth(30); - settingsLayout->addWidget(brightnessLabel_); - - connect(brightnessSlider_, &QSlider::valueChanged, this, - [this](int val) { brightnessLabel_->setText(QString::number(val)); }); - connect(brightnessSlider_, &QSlider::sliderReleased, this, - [this]() { onBrightnessChanged(brightnessSlider_->value()); }); - - cbDisplayOff_ = new QCheckBox(tr("Display Off")); - cbDisplayOff_->setObjectName( - QStringLiteral("displayOffCheckBox")); - cbDisplayOff_->setToolTip( - tr("Disable only the PASE display backlight")); - cbDisplayOff_->setStyleSheet("color: #ccc;"); - settingsLayout->addWidget(cbDisplayOff_); - - // Mirror mode - cbMirrorMode_ = new QCheckBox(tr("Mirror Mode")); - cbMirrorMode_->setObjectName( - QStringLiteral("displayMirrorCheckBox")); - cbMirrorMode_->setStyleSheet("color: #ccc;"); - settingsLayout->addWidget(cbMirrorMode_); - - cbWaterfallMode_ = new QCheckBox(tr("Waterfall Mode")); - cbWaterfallMode_->setObjectName( - QStringLiteral("displayWaterfallCheckBox")); - cbWaterfallMode_->setToolTip( - tr("Rotate the user interface by 90 degrees")); - cbWaterfallMode_->setStyleSheet("color: #ccc;"); - settingsLayout->addWidget(cbWaterfallMode_); - - connect(cbDisplayOff_, &QCheckBox::clicked, this, - [this](bool checked) { - TryxRuntimeDisplayMutation mutation; - mutation.backlightPresent = true; - mutation.backlightEnabled = !checked; - submitDisplayMutation(mutation); - }); - const auto submitOrientation = [this]() { - TryxRuntimeDisplayMutation mutation; - mutation.orientationPresent = true; - mutation.mirrorMode = cbMirrorMode_->isChecked(); - mutation.waterfallMode = cbWaterfallMode_->isChecked(); - submitDisplayMutation(mutation); - }; - connect(cbMirrorMode_, &QCheckBox::clicked, this, - [submitOrientation](bool) { submitOrientation(); }); - connect(cbWaterfallMode_, &QCheckBox::clicked, this, - [this, submitOrientation](bool checked) { - QSettings settings( - QStringLiteral("tryx-panorama"), - QStringLiteral("PanoramaPage")); - if (checked && - !settings.value( - QStringLiteral( - "display/waterfallWarningAccepted"), - false) - .toBool()) { - const QMessageBox::StandardButton response = - QMessageBox::warning( - this, tr("Waterfall Mode"), - tr("Waterfall Mode rotates the PASE interface and media by 90 degrees. Continue?"), - QMessageBox::Ok | QMessageBox::Cancel, - QMessageBox::Cancel); - if (response != QMessageBox::Ok) { - const QSignalBlocker blocker(cbWaterfallMode_); - cbWaterfallMode_->setChecked(false); - return; - } - settings.setValue( - QStringLiteral( - "display/waterfallWarningAccepted"), - true); - } - submitOrientation(); - }); - - settingsLayout->addStretch(); - - // Add to main layout - auto *mainLayout = qobject_cast(layout()); - if (mainLayout) { - mainLayout->addWidget(settingsGroup); - } -} - -void PanoramaPage::onChooseTextColor() { - const QColor color = - QColorDialog::getColor(textColor_, this, tr("Text Color")); - if (!color.isValid()) { - return; - } - textColor_ = color; - textColorBtn_->setStyleSheet( - QStringLiteral( - "background-color: %1; color: %2; padding: 4px 12px;") - .arg(color.name()) - .arg(color.lightness() > 128 ? QStringLiteral("#000") - : QStringLiteral("#fff"))); -} - -TryxRuntimeApplyRequest PanoramaPage::fullScreenApplyRequest( - const QStringList &media, const QString &ratio, - const QString &playMode, const QStringList &metrics, - bool replaceOverlay) const { - TryxRuntimeApplyRequest request; - request.media = media; - request.ratio = ratio; - request.screenMode = QStringLiteral("Full Screen"); - request.playMode = playMode; - request.replaceOverlay = replaceOverlay; - request.waterfallMode = - cbWaterfallMode_ && cbWaterfallMode_->isChecked(); - if (replaceOverlay) { - const TryxRuntimeDisplayState state = - deviceMgr_->displayState(); - request.sysinfoLabels = metrics; - request.settingsPosition = - state.valid && !state.settingsPosition.isEmpty() - ? state.settingsPosition - : QStringLiteral("Top"); - request.settingsAlign = alignCombo_ - ? alignCombo_->currentData().toString() - : QStringLiteral("Left"); - request.settingsColor = textColor_.name(); - if (cbCpuBadge_ && cbCpuBadge_->isChecked()) { - request.settingsBadges.append( - QStringLiteral("CPU Badge")); - } - if (cbGpuBadge_ && cbGpuBadge_->isChecked()) { - request.settingsBadges.append( - QStringLiteral("GPU Badge")); - } - } - return request; -} - -QString PanoramaPage::startPrinterApply( - const QStringList &media, const QString &ratio, - const QString &playMode, const QStringList &metrics, - bool updateMetrics) { - if (!deviceMgr_->isPrinterDisplaySessionActive()) { - emit statusMessage(tr( - "The PASE display session is not ready. Reconnect or power-cycle the device and wait for it to become active.")); - return {}; - } - const TryxRuntimeApplyRequest request = - fullScreenApplyRequest( - media, ratio, playMode, metrics, updateMetrics); - const QString operationId = - deviceMgr_->queueApplyOperation(QString(), request, updateMetrics); - const TryxRuntimeOperationInfo operation = - deviceMgr_->operationInfo(operationId); - const bool accepted = operation.state != QStringLiteral("Failed") && - operation.state != QStringLiteral("Cancelled"); - if (updateMetrics && accepted) { - printerMetricsOperationId_ = operationId; - pendingPrinterMetrics_ = metrics; - } - syncOperationPanel(deviceMgr_->operationSnapshot()); - return operationId; -} - -void PanoramaPage::setUploadBusy(bool busy) { - uploadBusy_ = busy; - updateActionAvailability(); - if (!busy) { - progressBar_->setRange(0, 0); - } -} - -void PanoramaPage::updateActionAvailability() { - const bool printerClass = deviceMgr_->isPrinterClassDevicePresent(); - const bool sessionReady = - printerClass - ? deviceMgr_->isPrinterDisplaySessionActive() - : deviceMgr_->isConnected(); - const bool actionsEnabled = !uploadBusy_ && sessionReady; - const bool displayStateValid = - !printerClass || deviceMgr_->displayState().valid; - - uploadBtn_->setEnabled(actionsEnabled); - refreshBtn_->setEnabled(actionsEnabled && !refreshPending_); - setDisplayBtn_->setEnabled(actionsEnabled); - customSaveBtn_->setEnabled(actionsEnabled); - retryBtn_->setEnabled(actionsEnabled && !retryOperationId_.isEmpty()); - cancelBtn_->setEnabled(uploadBusy_ && !activeOperationId_.isEmpty()); - brightnessSlider_->setEnabled( - sessionReady && displayStateValid && - (actionsEnabled || !brightnessOperationId_.isEmpty())); - cbDisplayOff_->setEnabled( - printerClass && actionsEnabled && - deviceMgr_->displayState().valid && - displayMutationReady_); - cbMirrorMode_->setEnabled( - printerClass && actionsEnabled && - deviceMgr_->displayState().valid && - displayMutationReady_); - cbWaterfallMode_->setEnabled( - printerClass && actionsEnabled && - deviceMgr_->displayState().valid && - displayMutationReady_); - fullScreenRadio_->setEnabled(actionsEnabled); - splitConfigWidget_->setEnabled(actionsEnabled); - - if (printerClass) { - splitScreenRadio_->setEnabled(actionsEnabled); - fileList_->setSelectionMode( - splitScreenRadio_->isChecked() - ? QAbstractItemView::ExtendedSelection - : QAbstractItemView::SingleSelection); - const int ratioIndex = ratioCombo_->findText(QStringLiteral("2:1")); - if (ratioIndex >= 0) { - ratioCombo_->setCurrentIndex(ratioIndex); - } - ratioCombo_->setEnabled(false); - playModeCombo_->setEnabled(actionsEnabled); - splitConfigWidget_->setPaseMode(true); - } else { - splitScreenRadio_->setEnabled(actionsEnabled); - fileList_->setSelectionMode(QAbstractItemView::ExtendedSelection); - ratioCombo_->setEnabled(actionsEnabled); - playModeCombo_->setEnabled(actionsEnabled); - splitConfigWidget_->setPaseMode(false); - } -} - -QStringList PanoramaPage::selectedDeviceMediaNames() const { - QStringList names; - for (const QListWidgetItem *item : fileList_->selectedItems()) { - QString name = item->data(Qt::UserRole).toString(); - if (name.isEmpty()) { - name = item->text().section(QLatin1Char('\n'), 0, 0); - } - if (!name.isEmpty() && !names.contains(name)) { - names.append(name); - } - } - return names; -} - -void PanoramaPage::updateCustomMetricsButton() { - int count = 0; - for (const QCheckBox *checkbox : customMetricCheckboxes_) { - if (checkbox->isChecked()) { - ++count; - } - } - customMetricsBtn_->setText( - QString::fromUtf8("%1 / 3 \u25BC").arg(count)); -} - -QString PanoramaPage::operationStatusText( - const TryxRuntimeOperationInfo &info) const { - QString title = info.subject.trimmed(); - if (title.isEmpty()) { - title = info.kind.trimmed(); - } - QString detail = info.message.trimmed(); - if (detail.isEmpty()) { - detail = info.stage.trimmed(); - } - - QString text; - if (!title.isEmpty() && !detail.isEmpty()) { - text = tr("%1: %2").arg(title, detail); - } else { - text = title.isEmpty() ? detail : title; - } - if (!info.primaryErrorMessage.trimmed().isEmpty() && - (info.state == QStringLiteral("RetryAvailable") || - info.stage == QStringLiteral("RecoveringFinalization"))) { - const QString primaryError = - tr("Initial transfer error: %1") - .arg(info.primaryErrorMessage.trimmed()); - text = text.isEmpty() - ? primaryError - : text + QLatin1Char('\n') + primaryError; - } - if (info.total > 0) { - const bool showingPreviousAttempt = - info.state == QStringLiteral("RetryAvailable") && - info.confirmedBytes > 0; - const qint64 visibleCompleted = showingPreviousAttempt - ? info.confirmedBytes - : info.completed; - const QString progress = showingPreviousAttempt - ? tr("Confirmed in the previous attempt: %1 of %2 bytes") - .arg(qMax(0, visibleCompleted)) - .arg(info.total) - : tr("%1 of %2 bytes") - .arg(qMax(0, visibleCompleted)) - .arg(info.total); - text = text.isEmpty() ? progress : text + QLatin1Char('\n') + progress; - } - if (info.state == QStringLiteral("RetryAvailable") && - info.lastConfirmedChunkIndex >= 0) { - const QString confirmedChunk = - tr("Last confirmed chunk: %1") - .arg(info.lastConfirmedChunkIndex + 1); - text = text.isEmpty() - ? confirmedChunk - : text + QLatin1Char('\n') + confirmedChunk; - } - return text; -} - -void PanoramaPage::syncOperationPanel( - const TryxRuntimeOperationsSnapshot &snapshot) { - TryxRuntimeOperationInfo active; - QList retryCandidates; - TryxRuntimeOperationInfo deleteReconciliation; - for (const TryxRuntimeOperationInfo &operation : snapshot.operations) { - if (operation.id == snapshot.activeOperationId) { - active = operation; - } - if (operation.state == QStringLiteral("RetryAvailable") && - operation.retryMode == QStringLiteral("PreparedMedia")) { - retryCandidates.append(operation); - } - if (operation.state == QStringLiteral("RetryAvailable") && - operation.retryMode == QStringLiteral("DeleteReconcile")) { - deleteReconciliation = operation; - } - } - - const auto showProgress = [this](const TryxRuntimeOperationInfo &operation, - bool indeterminateWhenEmpty) { - if (operation.total > 0) { - progressBar_->setRange(0, 100); - const qint64 visibleCompleted = - operation.state == QStringLiteral("RetryAvailable") && - operation.confirmedBytes > 0 - ? operation.confirmedBytes - : operation.completed; - const int percent = static_cast(qBound( - qint64(0), - (qMax(qint64(0), visibleCompleted) * 100) / - operation.total, - qint64(100))); - progressBar_->setValue(percent); - progressBar_->show(); - } else if (indeterminateWhenEmpty) { - progressBar_->setRange(0, 0); - progressBar_->show(); - } else { - progressBar_->hide(); - } - }; - - if (!active.id.isEmpty()) { - displayMutationReady_ = false; - activeOperationId_ = active.id; - retryOperationId_ = retryCandidates.size() == 1 - ? retryCandidates.constFirst().id - : QString(); - setUploadBusy(true); - operationStatusLabel_->setText(operationStatusText(active)); - showProgress(active, true); - retryBtn_->hide(); - cancelBtn_->show(); - operationPanel_->show(); - return; - } - - activeOperationId_.clear(); - setUploadBusy(false); - cancelBtn_->hide(); - - if (retryCandidates.size() == 1) { - const TryxRuntimeOperationInfo &retry = retryCandidates.constFirst(); - retryOperationId_ = retry.id; - updateActionAvailability(); - operationStatusLabel_->setText(operationStatusText(retry)); - showProgress(retry, false); - retryBtn_->show(); - operationPanel_->show(); - return; - } - - retryOperationId_.clear(); - updateActionAvailability(); - retryBtn_->hide(); - if (retryCandidates.size() > 1) { - operationStatusLabel_->setText(tr( - "Multiple prepared uploads require reconciliation; restart the background runtime before retrying")); - progressBar_->hide(); - operationPanel_->show(); - return; - } - - if (!deleteReconciliation.id.isEmpty()) { - operationStatusLabel_->setText( - operationStatusText(deleteReconciliation)); - progressBar_->hide(); - operationPanel_->show(); - return; - } - - if (!snapshot.operations.isEmpty()) { - const TryxRuntimeOperationInfo &last = snapshot.operations.constLast(); - if (last.state == QStringLiteral("Failed")) { - operationStatusLabel_->setText(operationStatusText(last)); - showProgress(last, false); - operationPanel_->show(); - return; - } - } - - operationStatusLabel_->clear(); - progressBar_->hide(); - operationPanel_->hide(); -} - -void PanoramaPage::savePageState() { - QSettings settings("tryx-panorama", "PanoramaPage"); - - settings.remove(QStringLiteral("preset/selectedName")); - settings.remove(QStringLiteral("preset/selectedRemoteId")); - settings.remove(QStringLiteral("display/position")); - - QStringList checkedMetrics; - for (const QCheckBox *checkbox : customMetricCheckboxes_) { - if (checkbox->isChecked()) { - checkedMetrics.append( - checkbox->property("protocolLabel").toString()); - } - } - settings.setValue("metrics/checked", checkedMetrics); - - settings.setValue("display/align", alignCombo_->currentData().toString()); - settings.setValue("display/textColor", textColor_.name()); - settings.setValue("display/cpuBadge", cbCpuBadge_->isChecked()); - settings.setValue("display/gpuBadge", cbGpuBadge_->isChecked()); - settings.remove("display/sleepMode"); - settings.setValue("display/displayOff", - cbDisplayOff_->isChecked()); - settings.setValue("display/mirrorMode", cbMirrorMode_->isChecked()); - settings.setValue("display/waterfallMode", - cbWaterfallMode_->isChecked()); -} - -void PanoramaPage::restorePageState() { - QSettings settings("tryx-panorama", "PanoramaPage"); - - settings.remove(QStringLiteral("preset/selectedName")); - settings.remove(QStringLiteral("preset/selectedRemoteId")); - settings.remove(QStringLiteral("display/position")); - - const QStringList checkedMetrics = - settings.value("metrics/checked").toStringList(); - if (!checkedMetrics.isEmpty()) { - for (QCheckBox *checkbox : customMetricCheckboxes_) { - checkbox->setChecked(checkedMetrics.contains( - checkbox->property("protocolLabel").toString())); - } - updateCustomMetricsButton(); - } - - if (settings.contains("display/align")) { - int idx = alignCombo_->findData(settings.value("display/align").toString()); - if (idx >= 0) alignCombo_->setCurrentIndex(idx); - } - if (settings.contains("display/textColor")) { - const QColor savedColor( - settings.value("display/textColor").toString()); - textColor_ = savedColor.isValid() - ? savedColor - : QColor(QStringLiteral("#DCDCDC")); - textColorBtn_->setStyleSheet( - QString("background-color: %1; color: %2; padding: 4px 12px;") - .arg(textColor_.name()) - .arg(textColor_.lightness() > 128 ? "#000" : "#fff")); - } - if (settings.contains("display/cpuBadge")) { - cbCpuBadge_->setChecked(settings.value("display/cpuBadge").toBool()); - } - if (settings.contains("display/gpuBadge")) { - cbGpuBadge_->setChecked(settings.value("display/gpuBadge").toBool()); - } - if (settings.contains("display/displayOff")) { - cbDisplayOff_->setChecked( - settings.value("display/displayOff").toBool()); - } - if (settings.contains("display/mirrorMode")) { - cbMirrorMode_->setChecked( - settings.value("display/mirrorMode").toBool()); - } - if (settings.contains("display/waterfallMode")) { - cbWaterfallMode_->setChecked( - settings.value("display/waterfallMode").toBool()); - } -} - -void PanoramaPage::startMetrics() { - QStringList labels; - if (deviceMgr_->isPrinterClassDevicePresent()) { - labels = activePrinterMetrics_; - } else if (!activeLegacyMetrics_.isEmpty()) { - labels = activeLegacyMetrics_; - } else { - for (const QCheckBox *checkbox : customMetricCheckboxes_) { - if (checkbox->isChecked()) { - labels.append( - checkbox->property("protocolLabel").toString()); - } - } - } - if (labels.isEmpty()) return; - - if (deviceMgr_->isPrinterClassDevicePresent()) { - metricsTimer_->stop(); - const bool stateChanged = !metricsRunning_; - metricsRunning_ = true; - if (stateChanged) { - emit metricsRunningChanged(true); - } - metricsStatusLabel_->setText( - tr("Metrics active in background runtime")); - metricsStatusLabel_->setStyleSheet("color: #00b894;"); - return; - } - - if (metricsRunning_) { - metricsTimer_->setInterval(2000); - metricsStatusLabel_->setText(tr("Metrics active")); - return; - } - - metricsRunning_ = true; - metricsTimer_->start(2000); - emit metricsRunningChanged(true); - metricsStatusLabel_->setText(tr("Metrics active")); - metricsStatusLabel_->setStyleSheet("color: #00b894;"); - - onSendMetrics(); -} - -void PanoramaPage::stopMetrics() { - if (!metricsRunning_) return; - - if (deviceMgr_->isPrinterClassDevicePresent() && - !activePrinterMetrics_.isEmpty()) { - emit statusMessage(tr( - "PASE metrics are controlled by the saved display configuration")); - return; - } - - metricsRunning_ = false; - metricsTimer_->stop(); - emit metricsRunningChanged(false); - metricsStatusLabel_->setText(""); - metricsStatusLabel_->setStyleSheet("color: #888;"); -} - -void PanoramaPage::onSendMetrics() { - if (deviceMgr_->isPrinterClassDevicePresent()) { - return; - } - monitor_->update(); - const auto metrics = monitor_->currentMetrics(); - - QStringList labels, values, units; - - const auto appendMetric = [&labels, &values, &units]( - const QString &label, - double value, - const QString &unit, - bool available) { - if (!available) { - return; - } - labels << label; - values << QString::number(value, 'f', 0); - units << unit; - }; - - appendMetric(QStringLiteral("CPU Temperature"), - metrics.cpu.temperature, QStringLiteral("°C"), - metrics.cpu.temperatureAvailable); - appendMetric(QStringLiteral("CPU Usage"), metrics.cpu.usagePercent, - QStringLiteral("%"), metrics.cpu.usageAvailable); - appendMetric(QStringLiteral("CPU Frequency"), - metrics.cpu.frequencyMHz, QStringLiteral("MHZ"), - metrics.cpu.frequencyAvailable); - - if (!metrics.gpus.isEmpty()) { - const GpuMetrics &gpu = metrics.gpus.first(); - appendMetric(QStringLiteral("GPU Temperature"), gpu.temperature, - QStringLiteral("°C"), gpu.temperatureAvailable); - appendMetric(QStringLiteral("GPU Usage"), gpu.usagePercent, - QStringLiteral("%"), gpu.usageAvailable); - appendMetric(QStringLiteral("GPU Frequency"), gpu.frequencyMHz, - QStringLiteral("MHZ"), gpu.frequencyAvailable); - } - - appendMetric(QStringLiteral("Memory Usage"), metrics.ram.usagePercent, - QStringLiteral("%"), metrics.ram.usageAvailable); - - const QStringList selected = deviceMgr_->isPrinterClassDevicePresent() - ? activePrinterMetrics_ - : !activeLegacyMetrics_.isEmpty() - ? activeLegacyMetrics_ - : [&]() { - QStringList result; - for (const QCheckBox *checkbox : - customMetricCheckboxes_) { - if (checkbox->isChecked()) { - result.append( - checkbox->property( - "protocolLabel").toString()); - } - } - return result; - }(); - for (const QString &selectedLabel : selected) { - const int idx = labels.indexOf(selectedLabel); - if (idx >= 0) { - fprintf(stderr, "[sysinfo] %s = %s %s\n", - labels[idx].toStdString().c_str(), - values[idx].toStdString().c_str(), - units[idx].toStdString().c_str()); - } - } - - deviceMgr_->sendSysinfo(labels, values, units); - metricsStatusLabel_->setText(tr("Metrics active")); -} - -// Customization tab slots - -void PanoramaPage::onUploadClicked() { - if (deviceMgr_->isPrinterClassDevicePresent() && - !deviceMgr_->isPrinterDisplaySessionActive()) { - emit statusMessage(tr( - "The PASE display session is not ready. Reconnect or power-cycle the device and wait for it to become active.")); - return; - } - QString path = QFileDialog::getOpenFileName( - this, tr("Select media file"), QString(), - "Media (*.mp4 *.webm *.mkv *.avi *.mov *.gif *.jpg *.jpeg *.png *.bmp *.webp)"); - - if (!path.isEmpty()) { - pendingUploadSourcePath_ = path; - if (deviceMgr_->isPrinterClassDevicePresent()) { - activeOperationId_ = - deviceMgr_->queueUploadOperation(QString(), path, false); - const TryxRuntimeOperationInfo operation = - deviceMgr_->operationInfo(activeOperationId_); - if (operation.state != QStringLiteral("Failed") && - operation.state != QStringLiteral("Cancelled")) { - uploadSourcePaths_.insert(activeOperationId_, path); - } - syncOperationPanel(deviceMgr_->operationSnapshot()); - } else { - setUploadBusy(true); - deviceMgr_->uploadMedia(path); - } - } -} - -void PanoramaPage::onScreenModeChanged() { - bool isSplit = splitScreenRadio_->isChecked(); - fullScreenControls_->setVisible(!isSplit); - splitConfigWidget_->setVisible(isSplit); - if (deviceMgr_->isPrinterClassDevicePresent()) { - fileList_->setSelectionMode( - isSplit ? QAbstractItemView::ExtendedSelection - : QAbstractItemView::SingleSelection); - splitConfigWidget_->setPaseMode(true); - } -} - -void PanoramaPage::onCustomSave() { - if (deviceMgr_->isPrinterClassDevicePresent() && - !deviceMgr_->isPrinterDisplaySessionActive()) { - emit statusMessage(tr( - "The PASE display session is not ready. Reconnect or power-cycle the device and wait for it to become active.")); - return; - } - bool isSplit = splitScreenRadio_->isChecked(); - - if (isSplit) { - // Screen Splitting mode - QStringList leftMedia = splitConfigWidget_->leftMedia(); - QStringList rightMedia = splitConfigWidget_->rightMedia(); - - if (leftMedia.isEmpty() || rightMedia.isEmpty()) { - emit statusMessage(tr("Assign media to both left and right sides")); - return; - } - - QStringList allMedia; - allMedia << leftMedia << rightMedia; - - QStringList leftMetrics = splitConfigWidget_->leftMetrics(); - QStringList rightMetrics = splitConfigWidget_->rightMetrics(); - const QString playMode = - deviceMgr_->isPrinterClassDevicePresent() - ? QStringLiteral("Single") - : splitConfigWidget_->playMode(); - const QStringList leftBadges = - splitConfigWidget_->leftBadges(); - const QStringList rightBadges = - splitConfigWidget_->rightBadges(); - - fprintf(stderr, "[panorama] split save: left=%lld right=%lld leftMetrics=%lld rightMetrics=%lld\n", - (long long)leftMedia.size(), (long long)rightMedia.size(), - (long long)leftMetrics.size(), (long long)rightMetrics.size()); - - if (deviceMgr_->isPrinterClassDevicePresent()) { - TryxRuntimeApplyRequest request; - request.media = allMedia; - request.ratio = QStringLiteral("2:1"); - request.screenMode = - QStringLiteral("Screen Splitting"); - request.playMode = QStringLiteral("Single"); - request.sysinfoLabels = leftMetrics; - request.settingsBadges = leftBadges; - request.settingsPosition = - splitConfigWidget_->leftPosition(); - request.settingsColor = - splitConfigWidget_->leftColor(); - request.settingsAlign = - splitConfigWidget_->leftAlignment(); - request.sysinfoLabels2 = rightMetrics; - request.settingsBadges2 = rightBadges; - request.settingsPosition2 = - splitConfigWidget_->rightPosition(); - request.settingsColor2 = - splitConfigWidget_->rightColor(); - request.settingsAlign2 = - splitConfigWidget_->rightAlignment(); - request.waterfallMode = - cbWaterfallMode_->isChecked(); - request.replaceOverlay = true; - activeOperationId_ = - deviceMgr_->queueApplyOperation( - QString(), request, true); - const TryxRuntimeOperationInfo operation = - deviceMgr_->operationInfo(activeOperationId_); - if (operation.state != QStringLiteral("Failed") && - operation.state != QStringLiteral("Cancelled")) { - printerMetricsOperationId_ = activeOperationId_; - pendingPrinterMetrics_ = leftMetrics; - for (const QString &metric : rightMetrics) { - if (!pendingPrinterMetrics_.contains(metric)) { - pendingPrinterMetrics_.append(metric); - } - } - } - syncOperationPanel(deviceMgr_->operationSnapshot()); - } else { - activeLegacyMetrics_ = leftMetrics; - for (const QString &metric : rightMetrics) { - if (!activeLegacyMetrics_.contains(metric)) { - activeLegacyMetrics_.append(metric); - } - } - legacyMetricsStartPending_ = - !activeLegacyMetrics_.isEmpty(); - deviceMgr_->setScreenConfig( - allMedia, QStringLiteral("2:1"), - QStringLiteral("Screen Splitting"), playMode, - leftMetrics, splitConfigWidget_->leftPosition(), - splitConfigWidget_->leftColor(), - splitConfigWidget_->leftAlignment(), leftBadges, 0, - QString(), rightMetrics, rightBadges, - cbWaterfallMode_->isChecked()); - emit statusMessage( - tr("Screen Splitting configuration applied")); - } - } else { - // Full Screen mode - use selected files from list - const QStringList media = selectedDeviceMediaNames(); - - QString ratio = ratioCombo_->currentText(); - QString playMode = playModeCombo_->currentData().toString(); - - // Collect selected metrics - QStringList metrics; - for (auto *cb : customMetricCheckboxes_) - if (cb->isChecked()) metrics << cb->property("protocolLabel").toString(); - - if (deviceMgr_->isPrinterClassDevicePresent()) { - if (media.isEmpty()) { - const TryxRuntimeApplyRequest request = - fullScreenApplyRequest( - {}, ratio, playMode, metrics, true); - const QString operationId = - deviceMgr_->queueApplyOperation( - QString(), request, true); - const TryxRuntimeOperationInfo operation = - deviceMgr_->operationInfo(operationId); - if (operation.state != QStringLiteral("Failed") && - operation.state != QStringLiteral("Cancelled")) { - printerMetricsOperationId_ = operationId; - pendingPrinterMetrics_ = metrics; - } - syncOperationPanel(deviceMgr_->operationSnapshot()); - } else { - startPrinterApply(media, ratio, playMode, metrics); - } - } else { - if (media.isEmpty()) { - emit statusMessage(tr("Select files to display")); - return; - } - activeLegacyMetrics_ = metrics; - legacyMetricsStartPending_ = !metrics.isEmpty(); - QStringList badges; - if (cbCpuBadge_->isChecked()) { - badges.append(QStringLiteral("CPU Badge")); - } - if (cbGpuBadge_->isChecked()) { - badges.append(QStringLiteral("GPU Badge")); - } - deviceMgr_->setScreenConfig(media, ratio, "Full Screen", playMode, - metrics, "Top", textColor_.name(), - alignCombo_->currentData().toString(), - badges, 0); - } - - if (!deviceMgr_->isPrinterClassDevicePresent()) { - emit statusMessage(tr("Full Screen configuration applied")); - } - } - savePageState(); -} - -void PanoramaPage::onFileListContextMenu(const QPoint &pos) { - auto *item = fileList_->itemAt(pos); - if (!item) return; - - QMenu menu(this); - bool isSplit = splitScreenRadio_->isChecked(); - - if (isSplit) { - auto *setLeft = menu.addAction(tr("Set to left side")); - auto *setRight = menu.addAction(tr("Set to right side")); - - connect(setLeft, &QAction::triggered, this, [this, item]() { - QString filename = item->data(Qt::UserRole).toString(); - if (filename.isEmpty()) filename = item->text().section('\n', 0, 0); - QPixmap thumb; - const QString thumbPath = - thumbnailCachePathForDeviceFile(filename); - if (QFileInfo::exists(thumbPath)) { - thumb = QPixmap(thumbPath); - } - splitConfigWidget_->assignToLeft(filename, thumb); - }); - connect(setRight, &QAction::triggered, this, [this, item]() { - QString filename = item->data(Qt::UserRole).toString(); - if (filename.isEmpty()) filename = item->text().section('\n', 0, 0); - QPixmap thumb; - const QString thumbPath = - thumbnailCachePathForDeviceFile(filename); - if (QFileInfo::exists(thumbPath)) { - thumb = QPixmap(thumbPath); - } - splitConfigWidget_->assignToRight(filename, thumb); - }); - } else { - auto *setDisplay = menu.addAction(tr("Set as display")); - connect(setDisplay, &QAction::triggered, this, [this, item]() { - QString filename = item->data(Qt::UserRole).toString(); - if (filename.isEmpty()) filename = item->text().section('\n', 0, 0); - QStringList media; - media << filename; - QString ratio = ratioCombo_->currentText(); - QString playMode = playModeCombo_->currentData().toString(); - if (deviceMgr_->isPrinterClassDevicePresent()) { - startPrinterApply(media, ratio, playMode, {}, false); - } else { - deviceMgr_->setScreenConfig(media, ratio, "Full Screen", - playMode); - emit statusMessage(tr("Screen config applied")); - } - }); - } - - menu.addSeparator(); - auto *deleteAction = menu.addAction(tr("Delete")); - const bool printerClass = deviceMgr_->isPrinterClassDevicePresent(); - const bool deleteAllowed = !printerClass || - item->data(MEDIA_DELETE_ALLOWED_ROLE).toBool(); - deleteAction->setEnabled( - deleteAllowed && !uploadBusy_ && - (!printerClass || deviceMgr_->isPrinterDisplaySessionActive())); - if (printerClass && !deleteAllowed) { - deleteAction->setStatusTip( - item->data(MEDIA_DELETE_BLOCK_REASON_ROLE).toString()); - } - connect(deleteAction, &QAction::triggered, this, [this, item]() { - deleteDeviceItems({item}); - }); - - menu.exec(fileList_->mapToGlobal(pos)); -} - -void PanoramaPage::onSetDisplayClicked() { - const QStringList media = selectedDeviceMediaNames(); - if (media.isEmpty()) { - emit statusMessage(tr("Select files to display")); - return; - } - QString ratio = ratioCombo_ ? ratioCombo_->currentText() : "2:1"; - QString screenMode = splitScreenRadio_->isChecked() - ? QStringLiteral("Screen Splitting") - : QStringLiteral("Full Screen"); - QString playMode = playModeCombo_ ? playModeCombo_->currentData().toString() : "Single"; - - if (deviceMgr_->isPrinterClassDevicePresent()) { - if (!deviceMgr_->isPrinterDisplaySessionActive()) { - emit statusMessage(tr( - "The PASE display session is not ready. Reconnect or power-cycle the device and wait for it to become active.")); - return; - } - const int requiredMedia = - screenMode == QStringLiteral("Screen Splitting") ? 2 : 1; - if (media.size() != requiredMedia) { - emit statusMessage( - tr("Select %1 media file(s) for this screen mode") - .arg(requiredMedia)); - return; - } - TryxRuntimeApplyRequest request; - request.media = media; - request.ratio = ratio; - request.screenMode = screenMode; - request.playMode = - screenMode == QStringLiteral("Screen Splitting") - ? QStringLiteral("Single") - : playMode; - activeOperationId_ = - deviceMgr_->queueApplyOperation(QString(), request); - syncOperationPanel(deviceMgr_->operationSnapshot()); - } else { - deviceMgr_->setScreenConfig(media, ratio, screenMode, playMode); - emit statusMessage(tr("Screen config applied")); - } -} - -void PanoramaPage::onDeleteClicked() { - const QList selected = fileList_->selectedItems(); - if (selected.isEmpty()) { - emit statusMessage(tr("Select files to delete")); - return; - } - deleteDeviceItems(selected); -} - -void PanoramaPage::deleteDeviceItems( - const QList &items) { - QStringList files; - quint64 totalSize = 0; - for (QListWidgetItem *item : items) { - if (!item) { - continue; - } - QString filename = item->data(Qt::UserRole).toString(); - if (filename.isEmpty()) { - filename = item->text().section(QLatin1Char('\n'), 0, 0); - } - if (deviceMgr_->isPrinterClassDevicePresent() && - !item->data(MEDIA_DELETE_ALLOWED_ROLE).toBool()) { - const QString reason = - item->data(MEDIA_DELETE_BLOCK_REASON_ROLE).toString(); - emit statusMessage(reason.isEmpty() - ? tr("This PASE media file cannot be deleted") - : tr("This PASE media file cannot be deleted: %1") - .arg(reason)); - return; - } - files << filename; - totalSize += item->data(MEDIA_SIZE_ROLE).toULongLong(); - } - if (files.isEmpty()) { - return; - } - - const QString confirmation = - deviceMgr_->isPrinterClassDevicePresent() - ? (files.size() == 1 - ? tr("Delete this file from PASE?\n\nName: %1\nSize: %2 bytes\n\nThe operation cannot be undone.") - .arg(files.constFirst()) - .arg(totalSize) - : tr("Delete %1 files from PASE (%2 bytes total)?\n\n%3\n\nThe operation cannot be undone.") - .arg(files.size()) - .arg(totalSize) - .arg(files.join(QLatin1Char('\n')))) - : tr("Delete %1 file(s)?").arg(files.size()); - const auto reply = QMessageBox::question( - this, tr("Delete"), confirmation, - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (reply == QMessageBox::Yes) { - if (deviceMgr_->isPrinterClassDevicePresent()) { - activeOperationId_ = - deviceMgr_->queueDeleteMediaOperation(QString(), files); - syncOperationPanel(deviceMgr_->operationSnapshot()); - } else { - deviceMgr_->deleteMedia(files); - } - } -} - -void PanoramaPage::onRefreshClicked() { - if (!refreshBtn_->isEnabled()) { - return; - } - refreshPending_ = true; - updateActionAvailability(); - deviceMgr_->refreshMediaList(); -} - -void PanoramaPage::onBrightnessChanged(int value) { - if (deviceMgr_->isPrinterClassDevicePresent()) { - pendingBrightness_ = qBound(0, value, 100); - { - const QSignalBlocker blocker(brightnessSlider_); - brightnessSlider_->setValue(pendingBrightness_); - } - brightnessLabel_->setText( - QString::number(pendingBrightness_)); - schedulePendingBrightness(); - return; - } - if (!deviceMgr_->isConnected()) { - return; - } - deviceMgr_->setBrightness(value); -} - -void PanoramaPage::schedulePendingBrightness() { - if (brightnessDispatchQueued_ || pendingBrightness_ < 0) { - return; - } - brightnessDispatchQueued_ = true; - QMetaObject::invokeMethod( - this, - [this]() { - brightnessDispatchQueued_ = false; - submitPendingBrightness(); - }, - Qt::QueuedConnection); -} - -void PanoramaPage::submitPendingBrightness() { - if (pendingBrightness_ < 0 || - !brightnessOperationId_.isEmpty() || - !displayMutationReady_) { - return; - } - if (!deviceMgr_->isPrinterClassDevicePresent() || - !deviceMgr_->isPrinterDisplaySessionActive() || - !deviceMgr_->displayState().valid) { - pendingBrightness_ = -1; - onDisplayStateUpdated(deviceMgr_->displayState()); - emit statusMessage(tr( - "The PASE display state is not ready yet. Reconnect the device and wait for synchronization.")); - return; - } - if (!deviceMgr_->operationSnapshot() - .activeOperationId.isEmpty()) { - return; - } - - const int target = pendingBrightness_; - pendingBrightness_ = -1; - if (deviceMgr_->displayState().brightness == target) { - onDisplayStateUpdated(deviceMgr_->displayState()); - return; - } - - const QString operationId = - QUuid::createUuid().toString(QUuid::WithoutBraces); - brightnessOperationId_ = operationId; - brightnessOperationTarget_ = target; - brightnessBaseRevision_ = - deviceMgr_->displayState().revision; - brightnessOperationSucceeded_ = false; - brightnessReadbackConfirmed_ = false; - displayMutationReady_ = false; - - TryxRuntimeDisplayMutation mutation; - mutation.brightnessPresent = true; - mutation.brightness = target; - TryxRuntimeApplyRequest request; - request.display = mutation; - activeOperationId_ = - deviceMgr_->queueApplyOperation(operationId, request); - const TryxRuntimeOperationInfo operation = - deviceMgr_->operationInfo(activeOperationId_); - if (operation.state == QStringLiteral("Failed") || - operation.state == QStringLiteral("Cancelled")) { - displayMutationReady_ = false; - finishBrightnessPipeline(false); - emit statusMessage(operation.message); - } - syncOperationPanel(deviceMgr_->operationSnapshot()); -} - -void PanoramaPage::updateBrightnessPipelineFromState( - const TryxRuntimeDisplayState &state) { - if (brightnessOperationId_.isEmpty() || - !state.valid || - state.revision <= brightnessBaseRevision_ || - state.brightness != brightnessOperationTarget_) { - return; - } - brightnessReadbackConfirmed_ = true; - if (brightnessOperationSucceeded_) { - finishBrightnessPipeline(true); - } -} - -void PanoramaPage::finishBrightnessPipeline( - bool keepPending) { - brightnessOperationId_.clear(); - brightnessOperationTarget_ = -1; - brightnessBaseRevision_ = 0; - brightnessOperationSucceeded_ = false; - brightnessReadbackConfirmed_ = false; - if (!keepPending) { - pendingBrightness_ = -1; - } - if (pendingBrightness_ < 0 && - deviceMgr_->displayState().valid) { - const QSignalBlocker blocker(brightnessSlider_); - brightnessSlider_->setValue( - deviceMgr_->displayState().brightness); - brightnessLabel_->setText(QString::number( - deviceMgr_->displayState().brightness)); - } - updateActionAvailability(); - if (keepPending && displayMutationReady_) { - schedulePendingBrightness(); - } -} - -void PanoramaPage::submitDisplayMutation( - const TryxRuntimeDisplayMutation &mutation) { - if (!deviceMgr_->isPrinterClassDevicePresent()) { - return; - } - if (!deviceMgr_->isPrinterDisplaySessionActive() || - !deviceMgr_->displayState().valid) { - onDisplayStateUpdated(deviceMgr_->displayState()); - emit statusMessage(tr( - "The PASE display state is not ready yet. Reconnect the device and wait for synchronization.")); - return; - } - - TryxRuntimeApplyRequest request; - request.display = mutation; - displayMutationReady_ = false; - activeOperationId_ = - deviceMgr_->queueApplyOperation(QString(), request); - const TryxRuntimeOperationInfo operation = - deviceMgr_->operationInfo(activeOperationId_); - if (operation.state == QStringLiteral("Failed") || - operation.state == QStringLiteral("Cancelled")) { - displayMutationReady_ = false; - onDisplayStateUpdated(deviceMgr_->displayState()); - emit statusMessage(operation.message); - } - syncOperationPanel(deviceMgr_->operationSnapshot()); -} - -void PanoramaPage::selectDisplayMedia(const QStringList &media) { - if (!fileList_) { - return; - } - const QSignalBlocker blocker(fileList_); - fileList_->clearSelection(); - for (int index = 0; index < fileList_->count(); ++index) { - QListWidgetItem *item = fileList_->item(index); - QString fileName = item->data(Qt::UserRole).toString(); - if (fileName.isEmpty()) { - fileName = - item->text().section(QLatin1Char('\n'), 0, 0); - } - item->setSelected(media.contains(fileName)); - } -} - -void PanoramaPage::onDisplayStateUpdated( - const TryxRuntimeDisplayState &state) { - if (!state.valid) { - updateActionAvailability(); - return; - } - - updateBrightnessPipelineFromState(state); - const int visibleBrightness = - pendingBrightness_ >= 0 - ? pendingBrightness_ - : brightnessOperationTarget_ >= 0 - ? brightnessOperationTarget_ - : state.brightness; - { - const QSignalBlocker blocker(brightnessSlider_); - brightnessSlider_->setValue(visibleBrightness); - } - brightnessLabel_->setText( - QString::number(visibleBrightness)); - { - const QSignalBlocker blocker(cbDisplayOff_); - cbDisplayOff_->setChecked(!state.backlightEnabled); - } - { - const QSignalBlocker blocker(cbMirrorMode_); - cbMirrorMode_->setChecked(state.mirrorMode); - } - { - const QSignalBlocker blocker(cbWaterfallMode_); - cbWaterfallMode_->setChecked(state.waterfallMode); - } - - const bool split = - state.screenMode == QStringLiteral("Screen Splitting"); - { - const QSignalBlocker fullBlocker(fullScreenRadio_); - const QSignalBlocker splitBlocker(splitScreenRadio_); - fullScreenRadio_->setChecked(!split); - splitScreenRadio_->setChecked(split); - } - onScreenModeChanged(); - - const QStringList leftMetrics = state.sysinfoLabels; - QStringList allMetrics = leftMetrics; - for (const QString &metric : state.sysinfoLabels2) { - if (!allMetrics.contains(metric)) { - allMetrics.append(metric); - } - } - activePrinterMetrics_ = allMetrics; - for (QCheckBox *checkbox : customMetricCheckboxes_) { - const QString label = - checkbox->property("protocolLabel").toString(); - const QSignalBlocker blocker(checkbox); - checkbox->setChecked(leftMetrics.contains(label)); - } - updateCustomMetricsButton(); - - { - const QSignalBlocker blocker(cbCpuBadge_); - cbCpuBadge_->setChecked( - state.settingsBadges.contains( - QStringLiteral("CPU Badge"))); - } - { - const QSignalBlocker blocker(cbGpuBadge_); - cbGpuBadge_->setChecked( - state.settingsBadges.contains( - QStringLiteral("GPU Badge"))); - } - const int alignmentIndex = - alignCombo_->findData(state.settingsAlign); - if (alignmentIndex >= 0) { - const QSignalBlocker blocker(alignCombo_); - alignCombo_->setCurrentIndex(alignmentIndex); - } - if (QColor(state.settingsColor).isValid()) { - textColor_ = QColor(state.settingsColor); - textColorBtn_->setStyleSheet( - QString("background-color: %1; color: %2; padding: 4px 12px;") - .arg(textColor_.name()) - .arg(textColor_.lightness() > 128 ? "#000" : "#fff")); - } - - const int playModeIndex = - playModeCombo_->findData(state.playMode); - if (playModeIndex >= 0) { - const QSignalBlocker blocker(playModeCombo_); - playModeCombo_->setCurrentIndex(playModeIndex); - } - if (split) { - splitConfigWidget_->setConfiguration( - state.media.value(0), state.media.value(1), - state.sysinfoLabels, state.sysinfoLabels2, - state.settingsBadges, state.settingsBadges2, - state.playMode); - splitConfigWidget_->setAreaSettings( - state.settingsPosition, state.settingsColor, - state.settingsAlign, state.settingsPosition2, - state.settingsColor2, state.settingsAlign2); - const auto thumbnailFor = [this](const QString &mediaFile) { - for (int index = 0; index < fileList_->count(); ++index) { - QListWidgetItem *item = fileList_->item(index); - if (item->data(Qt::UserRole).toString() == mediaFile) { - return item->icon().pixmap(QSize(200, 150)); - } - } - return QPixmap(); - }; - splitConfigWidget_->assignToLeft( - state.media.value(0), - thumbnailFor(state.media.value(0))); - splitConfigWidget_->assignToRight( - state.media.value(1), - thumbnailFor(state.media.value(1))); - } - selectDisplayMedia(state.media); - - updateActionAvailability(); -} - -void PanoramaPage::onMediaListUpdated(const QStringList &files) { - refreshPending_ = false; - if (deviceMgr_->hasTypedMediaCatalog()) { - updateActionAvailability(); - return; - } - const QStringList selectedNames = selectedDeviceMediaNames(); - fileList_->clear(); - QDir().mkpath(THUMB_CACHE_DIR); - - for (const auto &f : files) { - QString ext = QFileInfo(f).suffix().toUpper(); - if (ext.isEmpty()) ext = "FILE"; - QString displayText = f + "\n" + ext; - - auto *item = new QListWidgetItem(displayText); - item->setData(Qt::UserRole, f); // Store original filename - item->setTextAlignment(Qt::AlignCenter); - - // Try to load cached thumbnail - QString thumbPath = thumbnailCachePathForDeviceFile(f); - if (QFileInfo::exists(thumbPath)) { - QPixmap pix(thumbPath); - if (!pix.isNull()) { - item->setIcon(QIcon(pix.scaled(120, 80, Qt::KeepAspectRatio, Qt::SmoothTransformation))); - } - } else { - // Dark placeholder icon - QPixmap placeholder(120, 80); - placeholder.fill(QColor("#2a2a3a")); - item->setIcon(QIcon(placeholder)); - } - - fileList_->addItem(item); - item->setSelected(selectedNames.contains(f)); - - if (!QFileInfo::exists(thumbPath)) { - cacheThumbnailForDeviceFile( - f, localPreviewSourceForDeviceFile(f)); - } - } - if (deviceMgr_->displayState().valid) { - onDisplayStateUpdated(deviceMgr_->displayState()); - } else { - updateActionAvailability(); - } - emit statusMessage(tr("Files on device: %1").arg(files.size())); -} - -void PanoramaPage::onMediaCatalogUpdated( - const TryxRuntimeMediaCatalogSnapshot &snapshot) { - refreshPending_ = false; - const QStringList selectedNames = selectedDeviceMediaNames(); - fileList_->clear(); - QDir().mkpath(THUMB_CACHE_DIR); - - int visibleEntryCount = 0; - for (const TryxRuntimeMediaEntry &entry : snapshot.entries) { - const QString sourceText = - entry.source == MEDIA_SOURCE_PRESET - ? tr("DEVICE PRESET") - : entry.source == MEDIA_SOURCE_USER - ? tr("USER UPLOAD") - : tr("UNKNOWN ORIGIN"); - const QString sizeText = entry.size >= 1024U * 1024U - ? tr("%1 MB").arg( - QString::number(static_cast(entry.size) / - (1024.0 * 1024.0), - 'f', 1)) - : tr("%1 KB").arg( - QString::number(static_cast(entry.size) / 1024.0, - 'f', 1)); - auto *item = new QListWidgetItem( - entry.name + QLatin1Char('\n') + sourceText + - QStringLiteral(" ") + sizeText); - item->setData(Qt::UserRole, entry.name); - item->setData(MEDIA_SIZE_ROLE, - QVariant::fromValue(entry.size)); - item->setData(MEDIA_SOURCE_ROLE, entry.source); - item->setData(MEDIA_READ_ONLY_ROLE, entry.readOnly); - item->setData(MEDIA_THUMBNAIL_KEY_ROLE, entry.thumbnailKey); - item->setData(MEDIA_MANAGED_ORIGIN_ROLE, entry.managedOrigin); - item->setData(MEDIA_DELETE_ALLOWED_ROLE, entry.deleteAllowed); - item->setData(MEDIA_DELETE_BLOCK_REASON_ROLE, - entry.deleteBlockReason); - item->setTextAlignment(Qt::AlignCenter); - - QString thumbnailPath; - if (entry.source == MEDIA_SOURCE_USER && entry.managedOrigin && - !entry.thumbnailKey.isEmpty()) { - thumbnailPath = deviceMgr_->mediaThumbnailPath( - entry.thumbnailKey); - } - QPixmap pix(thumbnailPath); - if (!pix.isNull()) { - item->setIcon(QIcon(pix.scaled( - 120, 80, Qt::KeepAspectRatio, - Qt::SmoothTransformation))); - } else { - QPixmap placeholder(120, 80); - placeholder.fill(QColor("#2a2a3a")); - item->setIcon(QIcon(placeholder)); - } - fileList_->addItem(item); - item->setSelected(selectedNames.contains(entry.name)); - ++visibleEntryCount; - } - if (deviceMgr_->displayState().valid) { - onDisplayStateUpdated(deviceMgr_->displayState()); - } else { - updateActionAvailability(); - } - emit statusMessage( - tr("Files on device: %1").arg(visibleEntryCount)); -} - -void PanoramaPage::onMediaUploaded(const QString &filename) { - if (deviceMgr_->isPrinterClassDevicePresent()) { - Q_UNUSED(filename); - return; - } - setUploadBusy(false); - if (!pendingUploadSourcePath_.isEmpty()) { - cacheThumbnailForDeviceFile(filename, pendingUploadSourcePath_); - pendingUploadSourcePath_.clear(); - } - emit statusMessage(tr("Uploaded: %1").arg(filename)); - deviceMgr_->refreshMediaList(); -} - -void PanoramaPage::onMediaDeleted() { - emit statusMessage(tr("Files deleted")); - deviceMgr_->refreshMediaList(); -} - -void PanoramaPage::onUploadStatus(const QString &status) { - emit statusMessage(status); -} - -void PanoramaPage::onOperationChanged( - const TryxRuntimeOperationInfo &info, quint64 revision) { - Q_UNUSED(revision); - const bool terminal = info.state == QStringLiteral("Succeeded") || - info.state == QStringLiteral("Failed") || - info.state == QStringLiteral("Cancelled") || - info.state == QStringLiteral("RetryAvailable"); - const bool wasActive = info.id == activeOperationId_; - if (terminal && info.state != QStringLiteral("RetryAvailable")) { - uploadSourcePaths_.remove(info.id); - } - if (terminal && wasActive) { - pendingUploadSourcePath_.clear(); - } - if (!info.message.isEmpty()) { - emit statusMessage(info.message); - } - if (terminal && info.id == printerMetricsOperationId_) { - printerMetricsOperationId_.clear(); - pendingPrinterMetrics_.clear(); - } - syncOperationPanel(deviceMgr_->operationSnapshot()); - if (terminal && info.id == brightnessOperationId_) { - if (info.state == QStringLiteral("Succeeded")) { - brightnessOperationSucceeded_ = true; - updateBrightnessPipelineFromState( - deviceMgr_->displayState()); - } else { - displayMutationReady_ = false; - finishBrightnessPipeline(false); - } - } -} - -void PanoramaPage::onMetricsStateUpdated( - const TryxRuntimeMetricsState &state) { - if (state.deviceSerial.trimmed().isEmpty()) { - activePrinterMetrics_.clear(); - availablePrinterMetrics_.clear(); - for (QCheckBox *checkbox : customMetricCheckboxes_) { - const QSignalBlocker blocker(checkbox); - checkbox->setChecked(false); - checkbox->setEnabled(true); - } - splitConfigWidget_->setAvailableMetrics({}); - updateCustomMetricsButton(); - metricsTimer_->stop(); - if (metricsRunning_) { - metricsRunning_ = false; - emit metricsRunningChanged(false); - } - if (state.diagnostic.isEmpty()) { - metricsStatusLabel_->clear(); - metricsStatusLabel_->setStyleSheet("color: #888;"); - } else { - metricsStatusLabel_->setText(state.diagnostic); - metricsStatusLabel_->setStyleSheet("color: #ff7675;"); - } - return; - } - activePrinterMetrics_ = state.metrics; - availablePrinterMetrics_ = state.availableMetrics; - splitConfigWidget_->setAvailableMetrics(state.availableMetrics); - - for (QCheckBox *checkbox : customMetricCheckboxes_) { - const QString label = - checkbox->property("protocolLabel").toString(); - const QSignalBlocker blocker(checkbox); - checkbox->setChecked(state.metrics.contains(label)); - const bool sensorAvailable = state.availableMetrics.isEmpty() || - state.availableMetrics.contains(label); - checkbox->setEnabled(checkbox->isChecked() || sensorAvailable); - } - updateCustomMetricsButton(); - const int alignmentIndex = alignCombo_->findData(state.alignment); - if (alignmentIndex >= 0) { - alignCombo_->setCurrentIndex(alignmentIndex); - } - textColor_ = QColor::fromRgb(state.textColor & 0x00FFFFFFU); - textColorBtn_->setStyleSheet( - QString("background-color: %1; color: %2; padding: 4px 12px;") - .arg(textColor_.name()) - .arg(textColor_.lightness() > 128 ? "#000" : "#fff")); - savePageState(); - - metricsTimer_->stop(); - const bool running = state.enabled && state.samplingActive && - !state.metrics.isEmpty(); - if (metricsRunning_ != running) { - metricsRunning_ = running; - emit metricsRunningChanged(running); - } - if (!state.diagnostic.isEmpty()) { - metricsStatusLabel_->setText(state.diagnostic); - metricsStatusLabel_->setStyleSheet("color: #ff7675;"); - return; - } - if (running) { - metricsStatusLabel_->setText( - tr("Metrics active in background runtime")); - metricsStatusLabel_->setStyleSheet("color: #00b894;"); - } else if (state.enabled) { - metricsStatusLabel_->setText( - tr("Metrics configured; waiting for the PASE session")); - metricsStatusLabel_->setStyleSheet("color: #fdcb6e;"); - } else { - metricsStatusLabel_->clear(); - metricsStatusLabel_->setStyleSheet("color: #888;"); - } -} - -void PanoramaPage::onRetryClicked() { - if (retryOperationId_.isEmpty()) { - return; - } - if (deviceMgr_->isPrinterClassDevicePresent() && - !deviceMgr_->isPrinterDisplaySessionActive()) { - emit statusMessage(tr( - "Retry is blocked until PASE is power-cycled and its display session becomes active.")); - return; - } - const QString sourcePath = uploadSourcePaths_.value(retryOperationId_); - const QString newOperationId = - deviceMgr_->retryOperation(retryOperationId_, QString()); - const TryxRuntimeOperationInfo operation = - deviceMgr_->operationInfo(newOperationId); - if (!sourcePath.isEmpty() && - operation.state != QStringLiteral("Failed") && - operation.state != QStringLiteral("Cancelled")) { - uploadSourcePaths_.insert(newOperationId, sourcePath); - } - syncOperationPanel(deviceMgr_->operationSnapshot()); -} - -void PanoramaPage::onCancelClicked() { - if (!activeOperationId_.isEmpty()) { - deviceMgr_->cancelOperation(activeOperationId_); - } -} - -void PanoramaPage::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasUrls()) { - event->acceptProposedAction(); - if (dropZone_) { - dropZone_->setStyleSheet( - "QLabel {" - " border: 2px dashed #6c5ce7;" - " border-radius: 8px;" - " padding: 20px;" - " color: #6c5ce7;" - " font-size: 13px;" - " background: rgba(108, 92, 231, 30);" - "}"); - } - } -} - -void PanoramaPage::dropEvent(QDropEvent *event) { - if (dropZone_) { - dropZone_->setStyleSheet( - "QLabel {" - " border: 2px dashed #555;" - " border-radius: 8px;" - " padding: 20px;" - " color: #888;" - " font-size: 13px;" - "}"); - } - - if (deviceMgr_->isPrinterClassDevicePresent() && - !deviceMgr_->isPrinterDisplaySessionActive()) { - emit statusMessage(tr( - "The PASE display session is not ready. Reconnect or power-cycle the device and wait for it to become active.")); - return; - } - - for (const auto &url : event->mimeData()->urls()) { - if (url.isLocalFile()) { - pendingUploadSourcePath_ = url.toLocalFile(); - if (deviceMgr_->isPrinterClassDevicePresent()) { - activeOperationId_ = deviceMgr_->queueUploadOperation( - QString(), pendingUploadSourcePath_, false); - const TryxRuntimeOperationInfo operation = - deviceMgr_->operationInfo(activeOperationId_); - if (operation.state != QStringLiteral("Failed") && - operation.state != QStringLiteral("Cancelled")) { - uploadSourcePaths_.insert(activeOperationId_, - pendingUploadSourcePath_); - } - syncOperationPanel(deviceMgr_->operationSnapshot()); - } else { - setUploadBusy(true); - deviceMgr_->uploadMedia(pendingUploadSourcePath_); - } - break; - } - } -} diff --git a/src/panoramapage.h b/src/panoramapage.h deleted file mode 100644 index 4cf8eda..0000000 --- a/src/panoramapage.h +++ /dev/null @@ -1,187 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include "systemmonitor.h" -#include "splitconfig.h" - -class DeviceManager; -struct TryxRuntimeOperationInfo; -struct TryxRuntimeOperationsSnapshot; -struct TryxRuntimeMediaCatalogSnapshot; -struct TryxRuntimeMetricsState; -struct TryxRuntimeDisplayMutation; -struct TryxRuntimeApplyRequest; -struct TryxRuntimeDisplayState; - -class PanoramaPage : public QWidget { - Q_OBJECT -public: - explicit PanoramaPage(DeviceManager *deviceMgr, QWidget *parent = nullptr); - - bool isMetricsRunning() const { return metricsRunning_; } - -signals: - void statusMessage(const QString &msg); - void metricsRunningChanged(bool running); - -public slots: - void startMetrics(); - void stopMetrics(); - -private slots: - void onChooseTextColor(); - void onUploadClicked(); - void onSetDisplayClicked(); - void onDeleteClicked(); - void onRefreshClicked(); - void onMediaListUpdated(const QStringList &files); - void onMediaCatalogUpdated( - const TryxRuntimeMediaCatalogSnapshot &snapshot); - void onMediaUploaded(const QString &filename); - void onMediaDeleted(); - void onUploadStatus(const QString &status); - void onOperationChanged(const TryxRuntimeOperationInfo &info, - quint64 revision); - void onMetricsStateUpdated(const TryxRuntimeMetricsState &state); - void onDisplayStateUpdated(const TryxRuntimeDisplayState &state); - void onRetryClicked(); - void onCancelClicked(); - void onScreenModeChanged(); - void onCustomSave(); - void onFileListContextMenu(const QPoint &pos); - - // Display settings - void onBrightnessChanged(int value); - - // Metrics sending - void onSendMetrics(); - -private: -#ifdef TRYX_PROTOCOL_TESTING - friend class PrinterProtocolTests; -#endif - void setupUi(); - void setupCustomizationTab(QWidget *parent); - void setupDisplaySettings(); - QString thumbnailCachePathForDeviceFile(const QString &fileName) const; - QString localPreviewSourceForDeviceFile(const QString &fileName) const; - void cacheThumbnailForDeviceFile(const QString &fileName, const QString &sourcePath); - void applyCachedThumbnailToDeviceItem(const QString &fileName); - void deleteDeviceItems(const QList &items); - QString startPrinterApply(const QStringList &media, - const QString &ratio, - const QString &playMode, - const QStringList &metrics = {}, - bool updateMetrics = true); - TryxRuntimeApplyRequest fullScreenApplyRequest( - const QStringList &media, const QString &ratio, - const QString &playMode, const QStringList &metrics, - bool replaceOverlay) const; - void submitDisplayMutation( - const TryxRuntimeDisplayMutation &mutation); - void submitPendingBrightness(); - void schedulePendingBrightness(); - void updateBrightnessPipelineFromState( - const TryxRuntimeDisplayState &state); - void finishBrightnessPipeline(bool keepPending); - void selectDisplayMedia(const QStringList &media); - void syncOperationPanel(const TryxRuntimeOperationsSnapshot &snapshot); - QString operationStatusText(const TryxRuntimeOperationInfo &info) const; - void setUploadBusy(bool busy); - void updateActionAvailability(); - QStringList selectedDeviceMediaNames() const; - void updateCustomMetricsButton(); - void savePageState(); - void restorePageState(); - - DeviceManager *deviceMgr_; - SystemMonitor *monitor_; - QTimer *metricsTimer_; - bool metricsRunning_ = false; - bool uploadBusy_ = false; - bool refreshPending_ = false; - - QWidget *operationPanel_; - QLabel *operationStatusLabel_; - - // Metrics status - QLabel *metricsStatusLabel_; - - // Full-screen overlay controls - QComboBox *alignCombo_; - QPushButton *textColorBtn_; - QColor textColor_ = QColor("#DCDCDC"); - QCheckBox *cbCpuBadge_; - QCheckBox *cbGpuBadge_; - - // Customization tab - file management - QListWidget *fileList_; - QComboBox *ratioCombo_; - QComboBox *screenModeCombo_; - QComboBox *playModeCombo_; - QPushButton *uploadBtn_; - QPushButton *setDisplayBtn_; - QPushButton *deleteBtn_; - QPushButton *refreshBtn_; - QPushButton *retryBtn_; - QPushButton *cancelBtn_; - QLabel *dropZone_; - QProgressBar *progressBar_; - - // Customization tab - Screen Splitting - QRadioButton *fullScreenRadio_; - QRadioButton *splitScreenRadio_; - QWidget *fullScreenControls_; - SplitConfigWidget *splitConfigWidget_; - QPushButton *customSaveBtn_; - QToolButton *customMetricsBtn_; - QMenu *customMetricsMenu_; - QList customMetricCheckboxes_; - - QString pendingUploadSourcePath_; - QString activeOperationId_; - QString retryOperationId_; - QString printerMetricsOperationId_; - QStringList pendingPrinterMetrics_; - QStringList activePrinterMetrics_; - QStringList availablePrinterMetrics_; - QStringList activeLegacyMetrics_; - bool legacyMetricsStartPending_ = false; - QHash uploadSourcePaths_; - - // Display settings panel - QSlider *brightnessSlider_; - QLabel *brightnessLabel_; - QCheckBox *cbDisplayOff_; - QCheckBox *cbMirrorMode_; - QCheckBox *cbWaterfallMode_; - QString brightnessOperationId_; - int brightnessOperationTarget_ = -1; - int pendingBrightness_ = -1; - quint64 brightnessBaseRevision_ = 0; - bool brightnessOperationSucceeded_ = false; - bool brightnessReadbackConfirmed_ = false; - bool brightnessDispatchQueued_ = false; - bool displayMutationReady_ = false; - -protected: - void dragEnterEvent(QDragEnterEvent *event) override; - void dropEvent(QDropEvent *event) override; -}; diff --git a/src/printerprotocol.cpp b/src/printerprotocol.cpp index 32ff188..88a8c82 100644 --- a/src/printerprotocol.cpp +++ b/src/printerprotocol.cpp @@ -38,6 +38,13 @@ constexpr qsizetype kFileTransmitChunkSize = 0x40000; constexpr int kTransferChunkWriteTimeoutMs = 15000; constexpr int kFileTransmitResponseTimeoutMs = 30000; constexpr qint64 kMaxMediaUploadSize = 500LL * 1024LL * 1024LL; +constexpr qint64 kMaxMediaPullSize = 500LL * 1024LL * 1024LL; +constexpr int kMaxMediaPullChunks = 16384; +constexpr qint64 kMediaPullDeadlineBaseMs = 30000; +constexpr qint64 kMediaPullDeadlinePerMiBMs = 2000; +constexpr qint64 kMediaPullDeadlineHardLimitMs = 30LL * 60LL * 1000LL; +constexpr qint64 kBytesPerMiB = 1024LL * 1024LL; +constexpr qsizetype kMediaPullCancellationCheckInterval = 64 * 1024; constexpr quint16 kTryxVendorId = 0x391a; constexpr quint16 kTransitionProductId = 0x0006; constexpr quint16 kPaseProductId = 0x1021; @@ -1751,6 +1758,17 @@ QString normalizedMediaName(const panorama::wire::v1::MediaEntry &media) { return name; } +QString normalizedMediaReference(const std::string &value) { + QString reference = + QString::fromStdString(value).trimmed(); + reference.replace(QLatin1Char('\\'), QLatin1Char('/')); + const qsizetype separator = reference.lastIndexOf(QLatin1Char('/')); + if (separator >= 0) { + reference = reference.mid(separator + 1); + } + return reference.trimmed(); +} + bool isSafeDeviceMediaName(const QString &fileName) { if (fileName.isEmpty() || fileName.size() > 128 || fileName.startsWith(QLatin1Char('.')) || @@ -1789,6 +1807,147 @@ bool isSafeUploadFileName(const QString &fileName) { return false; } +struct MediaPullCandidate { + QByteArray rawPath; + QString mediaName; + qint64 fileSize = 0; +}; + +bool validateMediaPullPath(const QByteArray &rawPath, + const QString &mediaName) { + static const QByteArray prefix = + QByteArrayLiteral("/userdata/user/"); + if (!isSafeUploadFileName(mediaName) || + rawPath.isEmpty() || + rawPath.size() > prefix.size() + 512 || + !rawPath.startsWith(prefix) || + rawPath != prefix + mediaName.toUtf8() || + QString::fromUtf8(rawPath).toUtf8() != rawPath) { + return false; + } + + for (const char byte : rawPath) { + const auto value = static_cast(byte); + if (value == 0 || value < 0x20 || value == 0x7f || + byte == '\\') { + return false; + } + } + return true; +} + +QByteArray applyMediaPullXor(const QByteArray &bytes, + quint64 absoluteOffset, + const PrinterProtocol::OperationContext *context, + bool *cancelled) { + if (cancelled) { + *cancelled = false; + } + QByteArray transformed = bytes; + for (qsizetype index = 0; index < transformed.size(); ++index) { + if (context && + index % kMediaPullCancellationCheckInterval == 0 && + operationIsCancelled(*context)) { + if (cancelled) { + *cancelled = true; + } + return {}; + } + const quint8 mask = static_cast( + (absoluteOffset + static_cast(index)) & 0xffU); + transformed[index] = + static_cast( + static_cast(transformed.at(index)) ^ mask); + } + return transformed; +} + +qint64 mediaPullDeadlineForSize(qint64 fileSize) { + const qint64 roundedMiB = + qMax(1, (fileSize + kBytesPerMiB - 1) / kBytesPerMiB); + return qMin( + kMediaPullDeadlineHardLimitMs, + kMediaPullDeadlineBaseMs + + roundedMiB * kMediaPullDeadlinePerMiBMs); +} + +bool resolveMediaPullCandidate( + const panorama::wire::v1::MediaCatalog &catalog, + const QString &mediaName, qint64 expectedSize, + qint64 maximumBytes, MediaPullCandidate *candidate, + QString *errorMessage) { + if (!candidate) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Media pull candidate storage is not available"); + } + return false; + } + + int matchingEntries = 0; + bool matchedPreset = false; + panorama::wire::v1::MediaEntry selected; + const auto inspect = [&](const auto &entries, bool preset) { + for (const auto &entry : entries) { + if (normalizedMediaName(entry) != mediaName) { + continue; + } + ++matchingEntries; + if (preset) { + matchedPreset = true; + } else { + selected = entry; + } + } + }; + inspect(catalog.media_file_list(), false); + inspect(catalog.preset_file_list(), true); + + if (matchingEntries != 1 || matchedPreset) { + if (errorMessage) { + *errorMessage = matchingEntries == 0 + ? QObject::tr( + "Selected media is no longer present in the fresh device catalog") + : QObject::tr( + "Selected media is not a unique writable user entry in the fresh device catalog"); + } + return false; + } + if (selected.read_only()) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Selected media is read-only and cannot be pulled"); + } + return false; + } + const qint64 catalogSize = + static_cast(selected.file_size()); + if (catalogSize <= 0 || catalogSize != expectedSize || + catalogSize > maximumBytes) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Selected media size changed or exceeds the bounded pull limit"); + } + return false; + } + + const std::string &path = selected.file_path(); + const QByteArray rawPath( + path.data(), static_cast(path.size())); + if (!validateMediaPullPath(rawPath, mediaName)) { + if (errorMessage) { + *errorMessage = QObject::tr( + "Selected media has an unsafe or ambiguous device path"); + } + return false; + } + + candidate->rawPath = rawPath; + candidate->mediaName = mediaName; + candidate->fileSize = catalogSize; + return true; +} + QString transmitStatusText(panorama::wire::v1::TransferStatus::Code status) { switch (status) { case panorama::wire::v1::TransferStatus::OK: @@ -2085,9 +2244,27 @@ class PrinterProtocol::Impl { enum class TransactionProfile { Default, - FileTransmit + FileTransmit, + MediaPull }; + static bool usesFileTransferProfile(TransactionProfile profile) { + return profile == TransactionProfile::FileTransmit || + profile == TransactionProfile::MediaPull; + } + + static QString transactionProfileName(TransactionProfile profile) { + switch (profile) { + case TransactionProfile::Default: + return QStringLiteral("default"); + case TransactionProfile::FileTransmit: + return QStringLiteral("file-transmit"); + case TransactionProfile::MediaPull: + return QStringLiteral("media-pull"); + } + return QStringLiteral("unknown"); + } + explicit Impl(int transactionTimeoutMs, int deviceInfoReadyTimeoutMs, int fileTransmitResponseTimeoutMs) : transactionTimeoutMs_(qMax(1, transactionTimeoutMs)), @@ -2160,6 +2337,13 @@ class PrinterProtocol::Impl { fileTransmitResponseTimeoutMs_ = qMax(1, timeoutMs); } + void setMediaPullLimitsForTesting( + qint64 maximumBytes, int maximumChunks, int deadlineMs) { + mediaPullMaximumBytes_ = qMax(1, maximumBytes); + mediaPullMaximumChunks_ = qMax(1, maximumChunks); + mediaPullDeadlineOverrideMs_ = qMax(1, deadlineMs); + } + void setBootstrapZeroByteWriteFailuresForTesting(int failureCount) { bootstrapZeroByteWriteFailuresForTesting_ = qMax(0, failureCount); @@ -2210,8 +2394,10 @@ class PrinterProtocol::Impl { } const quint64 trackId = fixedTrackId != 0 ? fixedTrackId : allocateTrackId(); + const bool fileTransferProfile = + usesFileTransferProfile(profile); const quint32 requestVersion = - profile == TransactionProfile::FileTransmit ? 0U : 1U; + fileTransferProfile ? 0U : 1U; auto *header = request->mutable_header(); header->set_version(requestVersion); header->set_track_id(trackId); @@ -2251,7 +2437,7 @@ class PrinterProtocol::Impl { qsizetype writtenBytes = 0; WriteFailureKind writeFailure = WriteFailureKind::None; const int writeTimeoutMs = - profile == TransactionProfile::FileTransmit + fileTransferProfile ? fileTransmitDataWriteTimeoutMs_ : transactionTimeoutMs_; if (!writeAll(frame, context, writeTimeoutMs, errorMessage, @@ -2276,8 +2462,7 @@ class PrinterProtocol::Impl { const UnframedResponseValidator unframedResponseValidator = [trackId, expectedBody, requestVersion, - fileTransmit = - profile == TransactionProfile::FileTransmit, + fileTransferProfile, acceptHeaderOnlySuccess](const QByteArray &candidate) { if (candidate.isEmpty() || candidate.size() > PrinterFrameCodec::MaxPayloadSize) { @@ -2289,7 +2474,7 @@ class PrinterProtocol::Impl { static_cast(candidate.size())) && parsed.has_header() && (parsed.header().version() == requestVersion || - (fileTransmit && + (fileTransferProfile && parsed.header().version() == 1)) && parsed.header().track_id() == trackId && (parsed.body_case() == expectedBody || @@ -2299,7 +2484,7 @@ class PrinterProtocol::Impl { }; const int responseTimeoutMs = - profile == TransactionProfile::FileTransmit + fileTransferProfile ? fileTransmitResponseTimeoutMs_ : transactionTimeoutMs_; QElapsedTimer responseTimer; @@ -2322,10 +2507,7 @@ class PrinterProtocol::Impl { qWarning().noquote() << QStringLiteral( "TRYX response wait failed: profile=%1 track_id=%2 expected_body=%3 elapsed=%4ms buffered_bytes=%5 input_error_delta=%6 error=%7") - .arg( - profile == TransactionProfile::FileTransmit - ? QStringLiteral("file-transmit") - : QStringLiteral("default")) + .arg(transactionProfileName(profile)) .arg(trackId) .arg(static_cast(expectedBody)) .arg(responseTimer.elapsed()) @@ -2388,6 +2570,20 @@ class PrinterProtocol::Impl { skippedResponseBytes += payload.size() + 8; continue; } + if (profile == TransactionProfile::MediaPull && + parsed.header().version() != 0 && + parsed.header().version() != 1) { + if (errorMessage) { + *errorMessage = QObject::tr( + "TRYX media pull response protocol version %1 is not supported") + .arg(parsed.header().version()); + } + if (outcome) { + *outcome = TransactionOutcome::InvalidResponse; + } + closeDevice(); + return false; + } if (parsed.has_error() && parsed.error().code() != panorama::wire::v1::ProtocolError::SUCCESS) { if (outcome) { @@ -3168,6 +3364,12 @@ class PrinterProtocol::Impl { return true; } + MediaPullResult pullUserMedia( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, const MediaPullChunkSink &sink, + const MediaPullProgress &progress, + const OperationContext &context); + #ifdef TRYX_PROTOCOL_TESTING void setPersistentUsbInputFailureForTesting(bool persistent) { persistentUsbInputFailureLatched_ = persistent; @@ -4250,6 +4452,9 @@ class PrinterProtocol::Impl { int deviceInfoReadyTimeoutMs_ = 30000; int fileTransmitResponseTimeoutMs_ = kFileTransmitResponseTimeoutMs; + qint64 mediaPullMaximumBytes_ = kMaxMediaPullSize; + int mediaPullMaximumChunks_ = kMaxMediaPullChunks; + qint64 mediaPullDeadlineOverrideMs_ = 0; quint64 nextTrackId_ = 1; QString devicePath_; QByteArray receiveBuffer_; @@ -4263,6 +4468,296 @@ class PrinterProtocol::Impl { QElapsedTimer lastOutboundTimer_; }; +PrinterProtocol::MediaPullResult +PrinterProtocol::Impl::pullUserMedia( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, const MediaPullChunkSink &sink, + const MediaPullProgress &progress, + const OperationContext &context) { + MediaPullResult result; + result.mediaName = mediaName; + result.fileSize = expectedSize; + + const auto cancel = [&result]() { + result.cancelled = true; + result.error = QObject::tr( + "TRYX media pull was cancelled because the device state changed"); + return result; + }; + const auto fail = [&result](const QString &error) { + result.error = error; + return result; + }; + const auto rejectResponse = + [this, &result](const QString &error) { + result.error = error; + closeDevice(); + return result; + }; + + if (isCancelled(context)) { + return cancel(); + } + if (!sink) { + return fail(QObject::tr( + "Media pull requires a bounded decoded-chunk sink")); + } + if (!isSafeUploadFileName(mediaName) || + expectedSize <= 0 || + expectedSize > mediaPullMaximumBytes_) { + return fail(QObject::tr( + "Selected media identity is not eligible for a bounded pull")); + } + + const qint64 deadlineMs = + mediaPullDeadlineOverrideMs_ > 0 + ? mediaPullDeadlineOverrideMs_ + : mediaPullDeadlineForSize(expectedSize); + QElapsedTimer operationTimer; + operationTimer.start(); + const auto deadlineExpired = [&operationTimer, deadlineMs]() { + return operationTimer.elapsed() >= deadlineMs; + }; + OperationContext boundedContext = context; + boundedContext.isCancelled = + [&context, &deadlineExpired]() { + return operationIsCancelled(context) || + deadlineExpired(); + }; + + panorama::wire::v1::Request catalogRequest; + catalogRequest.mutable_media_catalog_query(); + panorama::wire::v1::Response catalogResponse; + QString transactionError; + TransactionOutcome transactionOutcome = + TransactionOutcome::NotSent; + if (!execute( + &catalogRequest, + panorama::wire::v1::Response::kMediaCatalog, + &catalogResponse, devicePath, boundedContext, + &transactionError, &transactionOutcome)) { + if (deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline during catalog preflight")); + } + if (transactionOutcome == TransactionOutcome::Cancelled || + isCancelled(context)) { + return cancel(); + } + return fail(QObject::tr( + "Cannot read the fresh media catalog before pull: %1") + .arg(transactionError)); + } + if (isCancelled(context)) { + return cancel(); + } + if (deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline during catalog preflight")); + } + + MediaPullCandidate candidate; + QString candidateError; + if (!resolveMediaPullCandidate( + catalogResponse.media_catalog(), mediaName, + expectedSize, mediaPullMaximumBytes_, + &candidate, &candidateError)) { + return fail(candidateError); + } + + quint64 sessionId = 0; + while (sessionId == 0) { + sessionId = + QRandomGenerator::global()->generate64(); + } + + QCryptographicHash rawHash(QCryptographicHash::Sha256); + QCryptographicHash decodedHash(QCryptographicHash::Sha256); + const quint64 exactFileSize = + static_cast(candidate.fileSize); + quint64 offset = 0; + int chunks = 0; + while (offset < exactFileSize) { + if (isCancelled(context)) { + return cancel(); + } + if (deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline")); + } + if (chunks >= mediaPullMaximumChunks_) { + return fail(QObject::tr( + "Media pull exceeded the bounded chunk count")); + } + + panorama::wire::v1::Request request; + auto *read = request.mutable_media_read_chunk(); + read->set_remote_path( + candidate.rawPath.constData(), + static_cast(candidate.rawPath.size())); + read->set_session_id(sessionId); + read->set_offset(offset); + + panorama::wire::v1::Response response; + transactionError.clear(); + transactionOutcome = TransactionOutcome::NotSent; + if (!execute( + &request, + panorama::wire::v1::Response::kMediaReadChunk, + &response, devicePath, boundedContext, + &transactionError, &transactionOutcome, + TransactionProfile::MediaPull)) { + if (deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline while waiting for a chunk")); + } + if (transactionOutcome == + TransactionOutcome::Cancelled || + isCancelled(context)) { + return cancel(); + } + return fail(QObject::tr( + "TRYX media pull request failed at offset %1: %2") + .arg(offset) + .arg(transactionError)); + } + if (isCancelled(context)) { + return cancel(); + } + if (deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline while receiving a chunk")); + } + + const auto &readResponse = + response.media_read_chunk(); + if (readResponse.status() != + panorama::wire::v1::MediaReadChunkResponse::OK) { + return fail( + readResponse.status() == + panorama::wire::v1:: + MediaReadChunkResponse::FILE_ERROR + ? QObject::tr( + "TRYX device reported a file error while pulling media") + : QObject::tr( + "TRYX device returned an unknown media pull status")); + } + + const std::string &responsePath = + readResponse.remote_path(); + const QByteArray echoedPath( + responsePath.data(), + static_cast(responsePath.size())); + if (echoedPath != candidate.rawPath) { + return rejectResponse(QObject::tr( + "TRYX media pull response changed the validated device path")); + } + if (readResponse.session_id() != sessionId) { + return rejectResponse(QObject::tr( + "TRYX media pull response changed the session identifier")); + } + if (readResponse.offset() != offset) { + return rejectResponse(QObject::tr( + "TRYX media pull response offset does not match the requested offset")); + } + if (readResponse.file_size() != exactFileSize) { + return rejectResponse(QObject::tr( + "TRYX media pull response changed the fresh catalog file size")); + } + + const std::string &responseData = + readResponse.data(); + const QByteArray rawChunk( + responseData.data(), + static_cast(responseData.size())); + if (rawChunk.isEmpty()) { + return rejectResponse(QObject::tr( + "TRYX media pull made no progress before end of file")); + } + const quint64 chunkSize = + static_cast(rawChunk.size()); + if (offset > + std::numeric_limits::max() - + chunkSize || + chunkSize > exactFileSize - offset) { + return rejectResponse(QObject::tr( + "TRYX media pull chunk exceeds the validated file size")); + } + + bool decodeCancelled = false; + const QByteArray decodedChunk = + applyMediaPullXor( + rawChunk, offset, &boundedContext, + &decodeCancelled); + if (decodeCancelled && deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline while decoding a chunk")); + } + if (decodeCancelled || isCancelled(context)) { + return cancel(); + } + if (decodedChunk.size() != rawChunk.size()) { + return rejectResponse(QObject::tr( + "TRYX media pull failed to decode a complete chunk")); + } + if (deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline while decoding a chunk")); + } + + QString sinkError; + if (!sink( + static_cast(offset), + decodedChunk, &sinkError)) { + return fail( + sinkError.isEmpty() + ? QObject::tr( + "Decoded media pull sink rejected a chunk") + : sinkError); + } + if (isCancelled(context)) { + return cancel(); + } + if (deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline while storing a chunk")); + } + + rawHash.addData(rawChunk); + decodedHash.addData(decodedChunk); + offset += chunkSize; + ++chunks; + result.bytesDecoded = + static_cast(offset); + result.chunkCount = chunks; + if (progress) { + progress( + result.bytesDecoded, + candidate.fileSize); + } + if (isCancelled(context)) { + return cancel(); + } + if (deadlineExpired()) { + return fail(QObject::tr( + "Media pull exceeded its bounded operation deadline while reporting progress")); + } + } + + if (offset != exactFileSize) { + return rejectResponse(QObject::tr( + "TRYX media pull did not finish at the validated file size")); + } + result.success = true; + result.fileSize = candidate.fileSize; + result.rawSha256 = + QString::fromLatin1(rawHash.result().toHex()); + result.decodedSha256 = + QString::fromLatin1(decodedHash.result().toHex()); + result.error.clear(); + return result; +} + PrinterProtocol::PrinterProtocol() : impl_(std::make_unique( 3000, kDeviceInformationReadinessDeadlineMs, @@ -4690,17 +5185,198 @@ PrinterProtocol::MediaListResult PrinterProtocol::readMediaList( return {true, {}, files}; } +PrinterProtocol::MediaPullResult PrinterProtocol::pullUserMedia( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, const MediaPullChunkSink &sink, + const MediaPullProgress &progress, + const OperationContext &context) { + return impl_->pullUserMedia( + devicePath, mediaName, expectedSize, + sink, progress, context); +} + +PrinterProtocol::MediaReferenceResult +PrinterProtocol::readUserMediaReferences( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, + const OperationContext &context) { + MediaReferenceResult result; + if (!isSafeUploadFileName(mediaName) || + expectedSize <= 0 || + expectedSize > + static_cast( + std::numeric_limits::max())) { + result.error = QObject::tr( + "Selected media identity is not eligible for reference preflight"); + return result; + } + const bool replacementExpected = + !expectedReplacementName.isEmpty() || + expectedReplacementSize != 0; + if (replacementExpected && + (!isSafeUploadFileName(expectedReplacementName) || + expectedReplacementName == mediaName || + expectedReplacementSize <= 0 || + expectedReplacementSize > + static_cast( + std::numeric_limits::max()))) { + result.error = QObject::tr( + "Expected replacement media identity is not eligible for reference preflight"); + return result; + } + + const MediaListResult currentList = + readMediaList(devicePath, context); + if (!currentList.success) { + result.error = QObject::tr( + "Cannot read the fresh media catalog before reference preflight: %1") + .arg(currentList.error); + return result; + } + + QList matches; + QList replacementMatches; + for (const MediaFile &media : currentList.files) { + if (media.name == mediaName) { + matches.append(media); + } + if (replacementExpected && + media.name == expectedReplacementName) { + replacementMatches.append(media); + } + } + result.originalIdentityVerified = + matches.size() == 1 && + matches.constFirst().source == MediaSource::User && + !matches.constFirst().readOnly && + matches.constFirst().size == + static_cast(expectedSize); + result.replacementIdentityVerified = + replacementExpected && + replacementMatches.size() == 1 && + replacementMatches.constFirst().source == + MediaSource::User && + !replacementMatches.constFirst().readOnly && + replacementMatches.constFirst().size == + static_cast(expectedReplacementSize); + if (!result.originalIdentityVerified) { + result.error = matches.isEmpty() + ? QObject::tr( + "Selected media is no longer present in the fresh device catalog") + : QObject::tr( + "Selected media identity changed in the fresh device catalog"); + return result; + } + result.media = matches.constFirst(); + if (replacementExpected && + !result.replacementIdentityVerified) { + result.error = replacementMatches.isEmpty() + ? QObject::tr( + "The expected replacement copy is absent from the fresh device catalog") + : QObject::tr( + "The expected replacement identity changed in the fresh device catalog"); + return result; + } + + panorama::wire::v1::Request configRequest; + configRequest.mutable_user_configuration_query(); + panorama::wire::v1::Response configResponse; + QString configError; + if (!impl_->execute( + &configRequest, + panorama::wire::v1::Response::kUserConfiguration, + &configResponse, devicePath, context, + &configError)) { + result.error = QObject::tr( + "Cannot read device configuration during reference preflight: %1") + .arg(configError); + return result; + } + + const auto &configuration = + configResponse.user_configuration(); + const auto &work = configuration.work_config(); + const auto &filter = configuration.filter_config(); + result.references = { + normalizedMediaReference( + configuration.poweron_config().media_file()), + normalizedMediaReference( + configuration.standby_config().media_file()), + normalizedMediaReference( + work.single_mode_media_file()), + normalizedMediaReference( + work.dual_mode_left_media_file()), + normalizedMediaReference( + work.dual_mode_right_media_file()), + normalizedMediaReference( + work.kaleidoscope_media_file()), + normalizedMediaReference( + filter.filter_file()), + normalizedMediaReference( + filter.dual_mode_left_file()), + normalizedMediaReference( + filter.dual_mode_right_file())}; + const QStringList referenceSlotNames = { + QStringLiteral("PowerOn"), + QStringLiteral("Standby"), + QStringLiteral("Single"), + QStringLiteral("DualLeft"), + QStringLiteral("DualRight"), + QStringLiteral("Kaleidoscope"), + QStringLiteral("FilterSingle"), + QStringLiteral("FilterDualLeft"), + QStringLiteral("FilterDualRight")}; + for (qsizetype index = 0; + index < result.references.size(); ++index) { + if (result.references.at(index) == mediaName) { + result.referencingSlots.append( + referenceSlotNames.at(index)); + } + } + result.success = true; + return result; +} + PrinterProtocol::DeleteResult PrinterProtocol::removeUserMedia( const QString &devicePath, const QStringList &fileNames, const BeforeDeleteDispatch &beforeDispatch, const DeleteProgress &progress, const OperationContext &context, - bool reconcileOnly) { + bool reconcileOnly, qint64 expectedSingleSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize) { DeleteResult result; if (fileNames.isEmpty()) { result.error = QObject::tr("No media files were selected for deletion"); return result; } + if (expectedSingleSize < 0 || + expectedSingleSize > + static_cast( + std::numeric_limits::max()) || + (expectedSingleSize > 0 && fileNames.size() != 1)) { + result.error = QObject::tr( + "Expected delete media identity is invalid"); + return result; + } + const bool replacementExpected = + !expectedReplacementName.isEmpty() || + expectedReplacementSize != 0; + if (replacementExpected && + (fileNames.size() != 1 || + expectedSingleSize <= 0 || + !isSafeUploadFileName(expectedReplacementName) || + expectedReplacementName == fileNames.constFirst() || + expectedReplacementSize <= 0 || + expectedReplacementSize > + static_cast( + std::numeric_limits::max()))) { + result.error = QObject::tr( + "Expected replacement identity before deletion is invalid"); + return result; + } QSet uniqueNames; for (const QString &fileName : fileNames) { if (!isSafeUploadFileName(fileName) || @@ -4730,33 +5406,38 @@ PrinterProtocol::DeleteResult PrinterProtocol::removeUserMedia( if (reconcileOnly) { const QString target = fileNames.constFirst(); result.currentName = target; - const bool present = std::any_of( - currentList.files.cbegin(), currentList.files.cend(), - [&target](const MediaFile &media) { - return media.name == target; - }); - if (!present) { + QList matches; + for (const MediaFile &media : + std::as_const(currentList.files)) { + if (media.name == target) { + matches.append(media); + } + } + if (matches.isEmpty()) { result.success = true; result.outcome = MutationOutcome::Succeeded; result.deletedNames.append(target); - } else { + } else if ( + matches.size() == 1 && + matches.constFirst().source == MediaSource::User && + !matches.constFirst().readOnly && + (expectedSingleSize == 0 || + matches.constFirst().size == + static_cast( + expectedSingleSize))) { result.outcome = MutationOutcome::PartialOrUnknown; result.error = QObject::tr( "The file is still present during delete reconciliation; FileRemove will not be repeated: %1") .arg(target); + } else { + result.outcome = MutationOutcome::PartialOrUnknown; + result.error = QObject::tr( + "The media identity changed during delete reconciliation; FileRemove will not be repeated: %1") + .arg(target); } return result; } - const auto normalizedReference = [](const std::string &value) { - QString reference = - QString::fromStdString(value).trimmed(); - if (reference.contains(QLatin1Char('/')) || - reference.contains(QLatin1Char('\\'))) { - reference = QFileInfo(reference).fileName(); - } - return reference; - }; constexpr int kMaxDeleteReconciliationReads = 4; for (int index = 0; index < fileNames.size(); ++index) { @@ -4786,12 +5467,21 @@ PrinterProtocol::DeleteResult PrinterProtocol::removeUserMedia( } if (matches.size() != 1 || matches.constFirst().source != MediaSource::User || - matches.constFirst().readOnly) { + matches.constFirst().readOnly || + (expectedSingleSize > 0 && + matches.constFirst().size != + static_cast( + expectedSingleSize))) { result.outcome = MutationOutcome::NotStarted; result.error = matches.isEmpty() ? QObject::tr("Media file is absent from the fresh device list: %1") .arg(target) - : QObject::tr("Media file is protected or ambiguous: %1") + : expectedSingleSize > 0 + ? QObject::tr( + "Media identity changed before deletion: %1") + .arg(target) + : QObject::tr( + "Media file is protected or ambiguous: %1") .arg(target); return result; } @@ -4814,30 +5504,30 @@ PrinterProtocol::DeleteResult PrinterProtocol::removeUserMedia( configResponse.user_configuration(); QStringList references; if (config.has_poweron_config()) { - references.append(normalizedReference( + references.append(normalizedMediaReference( config.poweron_config().media_file())); } if (config.has_standby_config()) { - references.append(normalizedReference( + references.append(normalizedMediaReference( config.standby_config().media_file())); } if (config.has_work_config()) { const auto &work = config.work_config(); - references.append(normalizedReference( + references.append(normalizedMediaReference( work.single_mode_media_file())); - references.append(normalizedReference( + references.append(normalizedMediaReference( work.dual_mode_left_media_file())); - references.append(normalizedReference( + references.append(normalizedMediaReference( work.dual_mode_right_media_file())); - references.append(normalizedReference( + references.append(normalizedMediaReference( work.kaleidoscope_media_file())); } if (config.has_filter_config()) { const auto &filter = config.filter_config(); - references.append(normalizedReference(filter.filter_file())); - references.append(normalizedReference( + references.append(normalizedMediaReference(filter.filter_file())); + references.append(normalizedMediaReference( filter.dual_mode_left_file())); - references.append(normalizedReference( + references.append(normalizedMediaReference( filter.dual_mode_right_file())); } references.removeAll(QString()); @@ -4849,6 +5539,69 @@ PrinterProtocol::DeleteResult PrinterProtocol::removeUserMedia( return result; } + if (expectedSingleSize > 0) { + const MediaListResult dispatchList = + readMediaList(devicePath, context); + if (!dispatchList.success) { + result.outcome = + MutationOutcome::NotStarted; + result.error = QObject::tr( + "Cannot revalidate media identity immediately before deleting %1: %2") + .arg( + target, + dispatchList.error); + return result; + } + result.files = dispatchList.files; + QList dispatchMatches; + for (const MediaFile &media : + dispatchList.files) { + if (media.name == target) { + dispatchMatches.append(media); + } + } + if (dispatchMatches.size() != 1 || + dispatchMatches.constFirst().source != + MediaSource::User || + dispatchMatches.constFirst().readOnly || + dispatchMatches.constFirst().size != + static_cast( + expectedSingleSize)) { + result.outcome = + MutationOutcome::NotStarted; + result.error = QObject::tr( + "Media identity changed immediately before deletion: %1") + .arg(target); + return result; + } + if (replacementExpected) { + QList replacementMatches; + for (const MediaFile &media : + dispatchList.files) { + if (media.name == + expectedReplacementName) { + replacementMatches.append(media); + } + } + if (replacementMatches.size() != 1 || + replacementMatches.constFirst().source != + MediaSource::User || + replacementMatches.constFirst().readOnly || + replacementMatches.constFirst().size != + static_cast( + expectedReplacementSize)) { + result.outcome = + MutationOutcome::NotStarted; + result.error = QObject::tr( + "Replacement identity changed immediately before deleting the original: %1") + .arg( + expectedReplacementName); + return result; + } + } + matches = dispatchMatches; + } + QString dispatchError; if (!beforeDispatch || !beforeDispatch(index, matches.constFirst(), @@ -5988,6 +6741,12 @@ void PrinterProtocol::setFileTransmitResponseTimeoutForTesting(int timeoutMs) { impl_->setFileTransmitResponseTimeoutForTesting(timeoutMs); } +void PrinterProtocol::setMediaPullLimitsForTesting( + qint64 maximumBytes, int maximumChunks, int deadlineMs) { + impl_->setMediaPullLimitsForTesting( + maximumBytes, maximumChunks, deadlineMs); +} + void PrinterProtocol::setPersistentUsbInputFailureForTesting( bool persistent) { impl_->setPersistentUsbInputFailureForTesting(persistent); @@ -6016,6 +6775,17 @@ bool PrinterProtocol::validateEndpointForTesting( errorMessage); } +QByteArray PrinterProtocol::applyMediaPullXorForTesting( + const QByteArray &bytes, quint64 absoluteOffset) { + return applyMediaPullXor( + bytes, absoluteOffset, nullptr, nullptr); +} + +bool PrinterProtocol::validateMediaPullPathForTesting( + const QByteArray &rawPath, const QString &mediaName) { + return validateMediaPullPath(rawPath, mediaName); +} + PrinterProtocol::DuplexTestResult PrinterProtocol::runDuplexTransportScenarioForTesting( const QList &events, const QByteArray &request, diff --git a/src/printerprotocol.h b/src/printerprotocol.h index 6fccd52..3edce4b 100644 --- a/src/printerprotocol.h +++ b/src/printerprotocol.h @@ -110,6 +110,30 @@ class PrinterProtocol { QList files; }; + struct MediaPullResult { + bool success = false; + bool cancelled = false; + QString error; + QString mediaName; + qint64 fileSize = 0; + qint64 bytesDecoded = 0; + int chunkCount = 0; + QString rawSha256; + QString decodedSha256; + }; + + struct MediaReferenceResult { + bool success = false; + bool originalIdentityVerified = false; + bool replacementIdentityVerified = false; + QString error; + MediaFile media; + // Fixed order: PowerOn, Standby, Single, DualLeft, DualRight, + // Kaleidoscope, FilterSingle, FilterDualLeft, FilterDualRight. + QStringList references; + QStringList referencingSlots; + }; + struct ReadinessRetryInfo { int attempt = 0; qint64 expectedBytes = 0; @@ -222,6 +246,11 @@ class PrinterProtocol { }; using UploadProgress = std::function; + using MediaPullChunkSink = std::function; + using MediaPullProgress = std::function; using DeleteProgress = std::function; @@ -244,12 +273,26 @@ class PrinterProtocol { const OperationContext &context); MediaListResult readMediaList(const QString &devicePath, const OperationContext &context); + MediaPullResult pullUserMedia( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, const MediaPullChunkSink &sink, + const MediaPullProgress &progress, + const OperationContext &context); + MediaReferenceResult readUserMediaReferences( + const QString &devicePath, const QString &mediaName, + qint64 expectedSize, + const QString &expectedReplacementName, + qint64 expectedReplacementSize, + const OperationContext &context); DeleteResult removeUserMedia( const QString &devicePath, const QStringList &fileNames, const BeforeDeleteDispatch &beforeDispatch, const DeleteProgress &progress, const OperationContext &context, - bool reconcileOnly = false); + bool reconcileOnly = false, + qint64 expectedSingleSize = 0, + const QString &expectedReplacementName = QString(), + qint64 expectedReplacementSize = 0); bool uploadMedia(const QString &devicePath, const QString &localPath, const QString &remoteFileName, QString *uploadedName, QString *errorMessage, const UploadProgress &progress, @@ -343,6 +386,8 @@ class PrinterProtocol { void setUnframedRecoveryEligibleForTesting(bool eligible); void setFileTransmitDataWriteTimeoutForTesting(int timeoutMs); void setFileTransmitResponseTimeoutForTesting(int timeoutMs); + void setMediaPullLimitsForTesting( + qint64 maximumBytes, int maximumChunks, int deadlineMs); void setPersistentUsbInputFailureForTesting(bool persistent); void setBootstrapZeroByteWriteFailuresForTesting(int failureCount); QList bootstrapReadinessAttemptOffsetsForTesting() const; @@ -358,6 +403,10 @@ class PrinterProtocol { const QByteArray &request, int writeTimeoutMs = 100, int readTimeoutMs = 100); + static QByteArray applyMediaPullXorForTesting( + const QByteArray &bytes, quint64 absoluteOffset); + static bool validateMediaPullPathForTesting( + const QByteArray &rawPath, const QString &mediaName); #endif private: diff --git a/src/quick/appsettingscontroller.cpp b/src/quick/appsettingscontroller.cpp new file mode 100644 index 0000000..e629c13 --- /dev/null +++ b/src/quick/appsettingscontroller.cpp @@ -0,0 +1,496 @@ +#include "appsettingscontroller.h" + +#include + +#include +#include +#include +#include + +#include + +namespace { + +constexpr int kAutostartCommandTimeoutMs = 8000; +const QString kAutostartUnit = + QStringLiteral("tryx-panorama.service"); + +QString commandFailureMessage(const QByteArray &output, + int exitCode) { + const QString detail = + QString::fromLocal8Bit(output).trimmed(); + return detail.isEmpty() + ? AppSettingsController::tr( + "systemctl failed with exit code %1") + .arg(exitCode) + : detail; +} + +} // namespace + +AppSettingsController::AppSettingsController( + bool offline, QObject *parent) + : QObject(parent), + offline_(offline), + autostartProcess_(new QProcess(this)), + autostartDeadline_(new QTimer(this)) { + try { + const auto config = + panorama::ConfigManager::load_config(); + if (!config) { + setConfigError( + tr("The application settings file is unreadable or invalid")); + } else { + const QString configured = + QString::fromStdString(config->language) + .trimmed() + .toLower(); + if (isSupportedLanguage(configured)) { + language_ = configured; + } else { + setConfigError( + tr("Unsupported application language setting: %1") + .arg(configured)); + } + devicePort_ = + QString::fromStdString(config->port).trimmed(); + keepaliveInterval_ = + qBound(5, config->keepalive_interval, 60); + } + } catch (const std::exception &error) { + setConfigError( + tr("Failed to read application settings: %1") + .arg(QString::fromLocal8Bit(error.what()))); + } + + autostartProcess_->setProcessChannelMode( + QProcess::MergedChannels); + connect( + autostartProcess_, + qOverload( + &QProcess::finished), + this, + &AppSettingsController::finishAutostartCommand); + connect( + autostartProcess_, &QProcess::errorOccurred, + this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) { + handleAutostartProcessError(); + } + }); + + autostartDeadline_->setSingleShot(true); + autostartDeadline_->setInterval( + kAutostartCommandTimeoutMs); + connect( + autostartDeadline_, &QTimer::timeout, + this, &AppSettingsController::handleAutostartTimeout); + + if (!offline_) { + refreshSerialPorts(); + refreshAutostart(); + } +} + +QString AppSettingsController::language() const { + return language_; +} + +QString AppSettingsController::devicePort() const { + return devicePort_; +} + +int AppSettingsController::keepaliveInterval() const { + return keepaliveInterval_; +} + +QStringList AppSettingsController::serialPorts() const { + return serialPorts_; +} + +bool AppSettingsController::autostartEnabled() const { + return autostartEnabled_; +} + +bool AppSettingsController::autostartAvailable() const { + return autostartAvailable_; +} + +bool AppSettingsController::busy() const { + return busy_; +} + +QString AppSettingsController::errorMessage() const { + return errorMessage_; +} + +void AppSettingsController::setLanguage( + const QString &code) { + const QString normalized = + code.trimmed().toLower(); + if (!isSupportedLanguage(normalized)) { + setConfigError( + tr("Unsupported application language: %1") + .arg(code)); + return; + } + + try { + const auto loaded = + panorama::ConfigManager::load_config(); + if (!loaded) { + setConfigError( + tr("The application settings file is unreadable or invalid")); + return; + } + + panorama::Config config = *loaded; + config.language = normalized.toStdString(); + if (!panorama::ConfigManager::save_config(config)) { + setConfigError( + tr("Failed to save the application language")); + return; + } + } catch (const std::exception &error) { + setConfigError( + tr("Failed to save application settings: %1") + .arg(QString::fromLocal8Bit(error.what()))); + return; + } + + setConfigError({}); + if (language_ == normalized) { + return; + } + language_ = normalized; + emit languageChanged(); +} + +void AppSettingsController::setDevicePort( + const QString &port) { + const QString normalized = port.trimmed(); + if (!normalized.isEmpty() && + (!normalized.startsWith(QStringLiteral("/dev/ttyACM")) || + normalized.contains(QStringLiteral("/../")))) { + setConfigError( + tr("Only Auto or a /dev/ttyACM device can be selected")); + return; + } + if (devicePort_ == normalized) { + return; + } + if (!saveDeviceSettings(normalized, keepaliveInterval_)) { + return; + } + devicePort_ = normalized; + emit deviceSettingsChanged(); +} + +void AppSettingsController::setKeepaliveInterval( + int seconds) { + const int bounded = qBound(5, seconds, 60); + if (keepaliveInterval_ == bounded) { + return; + } + if (!saveDeviceSettings(devicePort_, bounded)) { + return; + } + keepaliveInterval_ = bounded; + emit deviceSettingsChanged(); +} + +void AppSettingsController::refreshSerialPorts() { + QStringList ports; + const QDir devices(QStringLiteral("/dev")); + const QFileInfoList entries = devices.entryInfoList( + {QStringLiteral("ttyACM*")}, + QDir::System | QDir::Files | QDir::Readable, + QDir::Name); + for (const QFileInfo &entry : entries) { + ports.append( + QStringLiteral("/dev/") + entry.fileName()); + } + if (!devicePort_.isEmpty() && + !ports.contains(devicePort_)) { + ports.prepend(devicePort_); + } + ports.removeDuplicates(); + if (serialPorts_ == ports) { + return; + } + serialPorts_ = ports; + emit serialPortsChanged(); +} + +void AppSettingsController::setAutostartEnabled( + bool enabled) { + if (offline_) { + setAutostartError( + tr("Autostart management is unavailable in offline mode")); + return; + } + if (busy_ || + autostartProcess_->state() != QProcess::NotRunning) { + setAutostartError( + tr("Another autostart operation is still in progress")); + return; + } + if (autostartAvailable_ && + autostartEnabled_ == enabled) { + setAutostartError({}); + return; + } + + startAutostartCommand( + enabled + ? AutostartOperation::Enable + : AutostartOperation::Disable); +} + +void AppSettingsController::refreshAutostart() { + if (offline_) { + setAutostartAvailableState(false); + return; + } + if (busy_ || + autostartProcess_->state() != QProcess::NotRunning) { + setAutostartError( + tr("Another autostart operation is still in progress")); + return; + } + startAutostartCommand(AutostartOperation::Query); +} + +bool AppSettingsController::isSupportedLanguage( + const QString &code) { + return code == QStringLiteral("en") || + code == QStringLiteral("ru") || + code == QStringLiteral("system"); +} + +bool AppSettingsController::saveDeviceSettings( + const QString &port, int keepaliveInterval) { + try { + const auto loaded = + panorama::ConfigManager::load_config(); + if (!loaded) { + setConfigError( + tr("The application settings file is unreadable or invalid")); + return false; + } + panorama::Config config = *loaded; + config.port = port.toStdString(); + config.keepalive_interval = keepaliveInterval; + if (!panorama::ConfigManager::save_config(config)) { + setConfigError( + tr("Failed to save device connection settings")); + return false; + } + } catch (const std::exception &error) { + setConfigError( + tr("Failed to save device connection settings: %1") + .arg(QString::fromLocal8Bit(error.what()))); + return false; + } + setConfigError({}); + return true; +} + +bool AppSettingsController::isEnabledState( + const QString &state) { + return state == QStringLiteral("enabled") || + state == QStringLiteral("enabled-runtime") || + state == QStringLiteral("linked") || + state == QStringLiteral("linked-runtime"); +} + +bool AppSettingsController::isDisabledState( + const QString &state) { + return state == QStringLiteral("disabled") || + state == QStringLiteral("disabled-runtime"); +} + +void AppSettingsController::startAutostartCommand( + AutostartOperation operation) { + autostartOperation_ = operation; + autostartTimedOut_ = false; + setAutostartError({}); + setBusy(true); + + QString action; + switch (operation) { + case AutostartOperation::Query: + action = QStringLiteral("is-enabled"); + break; + case AutostartOperation::Enable: + action = QStringLiteral("enable"); + break; + case AutostartOperation::Disable: + action = QStringLiteral("disable"); + break; + case AutostartOperation::None: + setBusy(false); + return; + } + + autostartDeadline_->start(); + autostartProcess_->start( + QStringLiteral("systemctl"), + {QStringLiteral("--user"), action, + kAutostartUnit}); +} + +void AppSettingsController::finishAutostartCommand( + int exitCode, QProcess::ExitStatus exitStatus) { + if (autostartOperation_ == + AutostartOperation::None) { + return; + } + + autostartDeadline_->stop(); + const AutostartOperation completed = + autostartOperation_; + autostartOperation_ = AutostartOperation::None; + const QByteArray output = + autostartProcess_->readAll(); + const bool succeeded = + !autostartTimedOut_ && + exitStatus == QProcess::NormalExit && + exitCode == 0; + const bool timedOut = autostartTimedOut_; + autostartTimedOut_ = false; + setBusy(false); + + if (completed == AutostartOperation::Query) { + if (succeeded) { + const QString state = + QString::fromLocal8Bit(output) + .trimmed() + .toLower(); + if (isEnabledState(state)) { + setAutostartAvailableState(true); + setAutostartEnabledState(true); + setAutostartError({}); + return; + } + } else { + const QString state = + QString::fromLocal8Bit(output) + .trimmed() + .toLower(); + if (isDisabledState(state)) { + setAutostartAvailableState(true); + setAutostartEnabledState(false); + setAutostartError({}); + return; + } + } + + setAutostartAvailableState(false); + setAutostartEnabledState(false); + setAutostartError( + timedOut + ? tr("Timed out while checking autostart") + : commandFailureMessage(output, exitCode)); + return; + } + + if (succeeded) { + setAutostartAvailableState(true); + setAutostartEnabledState( + completed == AutostartOperation::Enable); + setAutostartError({}); + return; + } + + setAutostartError( + timedOut + ? tr("Timed out while changing autostart") + : commandFailureMessage(output, exitCode)); +} + +void AppSettingsController::handleAutostartProcessError() { + if (autostartOperation_ == + AutostartOperation::None) { + return; + } + + autostartDeadline_->stop(); + autostartOperation_ = AutostartOperation::None; + autostartTimedOut_ = false; + setBusy(false); + setAutostartAvailableState(false); + setAutostartError( + tr("Failed to start systemctl: %1") + .arg(autostartProcess_->errorString())); +} + +void AppSettingsController::handleAutostartTimeout() { + if (autostartOperation_ == + AutostartOperation::None) { + return; + } + autostartTimedOut_ = true; + autostartProcess_->kill(); +} + +void AppSettingsController::setAutostartEnabledState( + bool enabled) { + if (autostartEnabled_ == enabled) { + return; + } + autostartEnabled_ = enabled; + emit autostartEnabledChanged(); +} + +void AppSettingsController::setAutostartAvailableState( + bool available) { + if (autostartAvailable_ == available) { + return; + } + autostartAvailable_ = available; + emit autostartAvailableChanged(); +} + +void AppSettingsController::setBusy(bool busy) { + if (busy_ == busy) { + return; + } + busy_ = busy; + emit busyChanged(); +} + +void AppSettingsController::setConfigError( + const QString &message) { + if (configError_ == message) { + return; + } + configError_ = message; + updateErrorMessage(); +} + +void AppSettingsController::setAutostartError( + const QString &message) { + if (autostartError_ == message) { + return; + } + autostartError_ = message; + updateErrorMessage(); +} + +void AppSettingsController::updateErrorMessage() { + QStringList messages; + if (!configError_.isEmpty()) { + messages.append(configError_); + } + if (!autostartError_.isEmpty()) { + messages.append(autostartError_); + } + const QString combined = + messages.join(QLatin1Char('\n')); + if (errorMessage_ == combined) { + return; + } + errorMessage_ = combined; + emit errorMessageChanged(); +} diff --git a/src/quick/appsettingscontroller.h b/src/quick/appsettingscontroller.h new file mode 100644 index 0000000..6364d37 --- /dev/null +++ b/src/quick/appsettingscontroller.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include + +class QTimer; + +class AppSettingsController final : public QObject { + Q_OBJECT + Q_PROPERTY(QString language READ language NOTIFY languageChanged) + Q_PROPERTY(QString devicePort READ devicePort + NOTIFY deviceSettingsChanged) + Q_PROPERTY(int keepaliveInterval READ keepaliveInterval + NOTIFY deviceSettingsChanged) + Q_PROPERTY(QStringList serialPorts READ serialPorts + NOTIFY serialPortsChanged) + Q_PROPERTY(bool autostartEnabled READ autostartEnabled + NOTIFY autostartEnabledChanged) + Q_PROPERTY(bool autostartAvailable READ autostartAvailable + NOTIFY autostartAvailableChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString errorMessage READ errorMessage + NOTIFY errorMessageChanged) + +public: + explicit AppSettingsController(bool offline = false, + QObject *parent = nullptr); + + QString language() const; + QString devicePort() const; + int keepaliveInterval() const; + QStringList serialPorts() const; + bool autostartEnabled() const; + bool autostartAvailable() const; + bool busy() const; + QString errorMessage() const; + + Q_INVOKABLE void setLanguage(const QString &code); + Q_INVOKABLE void setDevicePort(const QString &port); + Q_INVOKABLE void setKeepaliveInterval(int seconds); + Q_INVOKABLE void refreshSerialPorts(); + Q_INVOKABLE void setAutostartEnabled(bool enabled); + Q_INVOKABLE void refreshAutostart(); + +signals: + void languageChanged(); + void deviceSettingsChanged(); + void serialPortsChanged(); + void autostartEnabledChanged(); + void autostartAvailableChanged(); + void busyChanged(); + void errorMessageChanged(); + +private: + enum class AutostartOperation { + None, + Query, + Enable, + Disable + }; + + static bool isSupportedLanguage(const QString &code); + bool saveDeviceSettings(const QString &port, + int keepaliveInterval); + static bool isEnabledState(const QString &state); + static bool isDisabledState(const QString &state); + + void startAutostartCommand(AutostartOperation operation); + void finishAutostartCommand(int exitCode, + QProcess::ExitStatus exitStatus); + void handleAutostartProcessError(); + void handleAutostartTimeout(); + void setAutostartEnabledState(bool enabled); + void setAutostartAvailableState(bool available); + void setBusy(bool busy); + void setConfigError(const QString &message); + void setAutostartError(const QString &message); + void updateErrorMessage(); + + bool offline_ = false; + QString language_ = QStringLiteral("en"); + QString devicePort_; + int keepaliveInterval_ = 10; + QStringList serialPorts_; + bool autostartEnabled_ = false; + bool autostartAvailable_ = false; + bool busy_ = false; + QString configError_; + QString autostartError_; + QString errorMessage_; + QProcess *autostartProcess_ = nullptr; + QTimer *autostartDeadline_ = nullptr; + AutostartOperation autostartOperation_ = + AutostartOperation::None; + bool autostartTimedOut_ = false; +}; diff --git a/src/quick/devicemediaworkflowcontroller.cpp b/src/quick/devicemediaworkflowcontroller.cpp new file mode 100644 index 0000000..bfaeb3b --- /dev/null +++ b/src/quick/devicemediaworkflowcontroller.cpp @@ -0,0 +1,835 @@ +#include "devicemediaworkflowcontroller.h" + +#include "mediaeditorcontroller.h" +#include "operationlistmodel.h" +#include "runtimeclient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kLeaseRenewIntervalMs = 30000; +constexpr int kExportHardDeadlineMs = 10 * 60 * 1000; +constexpr int kExportDeadlineSafetyMs = 5000; +constexpr int kCopyBufferBytes = 1024 * 1024; +constexpr qsizetype kMaxDiagnosticBytes = 4096; +constexpr quint64 kMaxExportBytes = + 500ULL * 1024ULL * 1024ULL; +constexpr quint64 kExportFreeSpaceReserveBytes = + 16ULL * 1024ULL * 1024ULL; + +bool isSuccess(const TryxRuntimeOperationInfo &info) { + return info.state == QStringLiteral("Succeeded") || + info.state == QStringLiteral("Completed"); +} + +QString operationError(const TryxRuntimeOperationInfo &info) { + if (!info.message.trimmed().isEmpty()) { + return info.message.trimmed(); + } + if (!info.primaryErrorMessage.trimmed().isEmpty()) { + return info.primaryErrorMessage.trimmed(); + } + return QObject::tr("The media operation did not complete"); +} + +bool privateRecoveredSource(const QString &path, quint64 expectedSize) { + const QString outbox = tryxRuntimeDeviceMediaOutboxPath(); + if (path.isEmpty() || outbox.isEmpty() || + !QDir::isAbsolutePath(path) || + QDir::cleanPath(path) != path || + QFileInfo(path).absolutePath() != + QFileInfo(outbox).absoluteFilePath() || + QFileInfo(path).canonicalFilePath() != path) { + return false; + } + const QByteArray encoded = QFile::encodeName(path); + struct stat status {}; + return ::lstat(encoded.constData(), &status) == 0 && + S_ISREG(status.st_mode) && + status.st_uid == ::geteuid() && + (status.st_mode & 07777) == + (S_IRUSR | S_IWUSR) && + status.st_nlink == 1 && status.st_size > 0 && + static_cast(status.st_size) == expectedSize; +} + +bool writeAll(QSaveFile *destination, const char *data, qint64 size) { + qint64 written = 0; + while (written < size) { + const qint64 count = + destination->write(data + written, size - written); + if (count <= 0) { + return false; + } + written += count; + } + return true; +} + +} // namespace + +DeviceMediaWorkflowController::DeviceMediaWorkflowController( + RuntimeClient *runtime, MediaEditorController *editor, + QObject *parent) + : QObject(parent), runtime_(runtime), editor_(editor) { + exportProcess_.setProcessChannelMode(QProcess::MergedChannels); + exportDeadline_.setSingleShot(true); + renewTimer_.setSingleShot(false); + renewTimer_.setInterval(kLeaseRenewIntervalMs); + + connect( + runtime_, &RuntimeClient::operationUpdated, + this, &DeviceMediaWorkflowController::onOperationUpdated); + connect( + runtime_, &RuntimeClient::artifactClaimed, + this, &DeviceMediaWorkflowController::onArtifactClaimed); + connect( + runtime_, &RuntimeClient::artifactClaimFailed, + this, &DeviceMediaWorkflowController::onArtifactClaimFailed); + connect( + runtime_, &RuntimeClient::artifactLeaseRenewFailed, + this, + [this](const QString &artifactId, const QString &leaseId, + const QString &message) { + if (artifact_.artifactId == artifactId && + artifact_.leaseId == leaseId) { + fail( + message.isEmpty() + ? tr("The device media lease expired") + : message); + } + }); + connect( + runtime_, &RuntimeClient::operationRequestRejected, + this, + [this](const QString &operationId, const QString &, + const QString &message) { + if (operationId == pendingStageOperationId_) { + fail(message); + return; + } + if (operationId == pendingMutationOperationId_) { + const QString rejectedId = + pendingMutationOperationId_; + pendingMutationOperationId_.clear(); + editor_->finishRecoveredSubmission( + rejectedId, false, message); + emit stateChanged(); + } + }); + connect( + runtime_, &RuntimeClient::runtimeInvalidated, + this, &DeviceMediaWorkflowController::onRuntimeInvalidated); + connect( + editor_, + &MediaEditorController::recoveredSaveAsNewRequested, + this, + &DeviceMediaWorkflowController::onSaveAsNewRequested); + connect( + editor_, + &MediaEditorController::recoveredReplaceRequested, + this, &DeviceMediaWorkflowController::onReplaceRequested); + connect( + editor_, &MediaEditorController::recoveredClosed, + this, [this]() { + releaseArtifact(); + clearPendingWorkflow(); + emit stateChanged(); + }); + connect( + &renewTimer_, &QTimer::timeout, this, [this]() { + if (!artifact_.artifactId.isEmpty()) { + runtime_->renewDeviceMediaArtifactLease( + artifact_.artifactId, artifact_.leaseId); + } + }); + connect( + &exportProcess_, &QProcess::readyRead, this, [this]() { + exportDiagnostic_.append(exportProcess_.readAll()); + if (exportDiagnostic_.size() > kMaxDiagnosticBytes) { + exportDiagnostic_ = + exportDiagnostic_.right(kMaxDiagnosticBytes); + } + }); + connect( + &exportProcess_, + qOverload(&QProcess::finished), + this, &DeviceMediaWorkflowController::finishExport); + connect( + &exportProcess_, &QProcess::errorOccurred, this, + [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) { + fail(tr("The device media export helper could not start")); + } + }); + connect( + &exportDeadline_, &QTimer::timeout, this, [this]() { + if (exportProcess_.state() != QProcess::NotRunning) { + exportProcess_.kill(); + } + fail(tr("Exporting the device media copy timed out")); + }); +} + +DeviceMediaWorkflowController::~DeviceMediaWorkflowController() { + renewTimer_.stop(); + exportDeadline_.stop(); + if (exportProcess_.state() != QProcess::NotRunning) { + exportProcess_.kill(); + exportProcess_.waitForFinished(1000); + } + if (!artifact_.artifactId.isEmpty()) { + runtime_->releaseDeviceMediaArtifact( + artifact_.artifactId, artifact_.leaseId); + } +} + +bool DeviceMediaWorkflowController::busy() const { + return overwriteConfirmationPending_ || + !pendingStageOperationId_.isEmpty() || claimPending_ || + exportProcess_.state() != QProcess::NotRunning || + !pendingMutationOperationId_.isEmpty(); +} + +QString DeviceMediaWorkflowController::action() const { + switch (intent_) { + case Intent::Edit: + return QStringLiteral("Edit"); + case Intent::Export: + return QStringLiteral("Export"); + case Intent::None: + return {}; + } + return {}; +} + +QString DeviceMediaWorkflowController::mediaName() const { + return mediaName_; +} + +QString DeviceMediaWorkflowController::error() const { + return error_; +} + +bool DeviceMediaWorkflowController::overwriteConfirmationPending() const { + return overwriteConfirmationPending_; +} + +QString DeviceMediaWorkflowController::overwriteFileName() const { + return exportFileName_; +} + +void DeviceMediaWorkflowController::beginEdit( + const QString &mediaId, const QString &mediaName) { + if (busy() || !artifact_.artifactId.isEmpty()) { + emit userMessage( + tr("Finish the current device media action first"), true); + return; + } + startStage(Intent::Edit, mediaId, mediaName); +} + +void DeviceMediaWorkflowController::beginExport( + const QString &mediaId, const QString &mediaName, + const QUrl &folder, const QString &fileName) { + if (busy() || !artifact_.artifactId.isEmpty()) { + emit userMessage( + tr("Finish the current device media action first"), true); + return; + } + + QString destination; + QString normalizedName; + QString validationError; + if (!resolveExportDestination( + folder, fileName, &destination, + &normalizedName, &validationError)) { + fail(validationError); + return; + } + mediaId_ = mediaId; + mediaName_ = mediaName; + exportFolderPath_ = QFileInfo(destination).absolutePath(); + exportFileName_ = normalizedName; + exportDestinationPath_ = destination; + overwriteConfirmed_ = false; + if (QFileInfo::exists(exportDestinationPath_)) { + overwriteConfirmationPending_ = true; + emit stateChanged(); + return; + } + startStage(Intent::Export, mediaId, mediaName); +} + +void DeviceMediaWorkflowController::confirmOverwrite() { + if (!overwriteConfirmationPending_ || + exportDestinationPath_.isEmpty()) { + return; + } + overwriteConfirmationPending_ = false; + overwriteConfirmed_ = true; + startStage(Intent::Export, mediaId_, mediaName_); +} + +void DeviceMediaWorkflowController::cancelOverwrite() { + overwriteConfirmationPending_ = false; + overwriteConfirmed_ = false; + clearPendingWorkflow(); + emit stateChanged(); +} + +void DeviceMediaWorkflowController::cancelCurrent() { + if (!pendingMutationOperationId_.isEmpty() || + !pendingStageOperationId_.isEmpty()) { + runtime_->cancelActiveOperation(); + } + if (exportProcess_.state() != QProcess::NotRunning) { + exportProcess_.kill(); + } + if (editor_->recoveredDeviceCopy() && + !editor_->submissionPending()) { + editor_->cancel(); + return; + } + releaseArtifact(); + clearPendingWorkflow(); + emit stateChanged(); +} + +QString DeviceMediaWorkflowController::suggestedExportFileName( + const QString &remoteName) const { + QString base = remoteName.trimmed(); + static const QRegularExpression preparedSuffix( + QStringLiteral("\\.h264_[0-9]+x[0-9]+$"), + QRegularExpression::CaseInsensitiveOption); + base.remove(preparedSuffix); + static const QRegularExpression sourceSuffix( + QStringLiteral("\\.(mp4|webm|mkv|avi|mov|gif|jpg|jpeg|png|bmp|webp)$"), + QRegularExpression::CaseInsensitiveOption); + base.remove(sourceSuffix); + base.replace( + QRegularExpression(QStringLiteral("[^A-Za-z0-9._-]+")), + QStringLiteral("-")); + base = base.left(120).trimmed(); + while (base.startsWith(QLatin1Char('.'))) { + base.remove(0, 1); + } + if (base.isEmpty()) { + base = QStringLiteral("pase-media"); + } + return base + QStringLiteral("-device-copy.h264"); +} + +int DeviceMediaWorkflowController::runExportHelper( + const QStringList &arguments) { + if (arguments.size() != 5) { + return 2; + } + bool sizeOk = false; + const quint64 expectedSize = + arguments.at(2).toULongLong(&sizeOk); + const QString expectedSha = + arguments.at(3).trimmed().toLower(); + const bool overwrite = arguments.at(4) == QStringLiteral("1"); + static const QRegularExpression sha256( + QStringLiteral("^[0-9a-f]{64}$")); + if (!sizeOk || expectedSize == 0 || + expectedSize > kMaxExportBytes || + !sha256.match(expectedSha).hasMatch() || + !privateRecoveredSource(arguments.at(0), expectedSize)) { + return 2; + } + + const QString destinationPath = arguments.at(1); + const QFileInfo destinationInfo(destinationPath); + if (!QDir::isAbsolutePath(destinationPath) || + QDir::cleanPath(destinationPath) != destinationPath || + destinationInfo.fileName().isEmpty() || + destinationInfo.suffix().compare( + QStringLiteral("h264"), + Qt::CaseInsensitive) != 0 || + destinationInfo.absoluteDir().canonicalPath() != + destinationInfo.absolutePath()) { + return 2; + } + if (destinationInfo.exists() && + (!overwrite || !destinationInfo.isFile() || + destinationInfo.isSymLink())) { + return 3; + } + QStorageInfo destinationStorage( + destinationInfo.absolutePath()); + destinationStorage.refresh(); + const qint64 availableBytes = + destinationStorage.bytesAvailable(); + if (!destinationStorage.isValid() || + !destinationStorage.isReady() || + availableBytes < 0 || + static_cast(availableBytes) < + expectedSize + + kExportFreeSpaceReserveBytes) { + return 4; + } + + QString committedPath = destinationPath; + if (!overwrite) { + committedPath = + destinationInfo.absoluteDir().filePath( + QStringLiteral(".tryx-export-%1.part") + .arg(QUuid::createUuid().toString( + QUuid::WithoutBraces))); + if (QFileInfo::exists(committedPath)) { + return 4; + } + } + + QFile source(arguments.at(0)); + QSaveFile destination(committedPath); + destination.setDirectWriteFallback(false); + if (!source.open(QIODevice::ReadOnly) || + !destination.open(QIODevice::WriteOnly) || + !destination.setPermissions( + QFileDevice::ReadOwner | QFileDevice::WriteOwner)) { + return 4; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + QByteArray buffer(kCopyBufferBytes, Qt::Uninitialized); + quint64 copied = 0; + while (copied < expectedSize) { + const qint64 count = source.read( + buffer.data(), + static_cast(qMin( + static_cast(buffer.size()), + expectedSize - copied))); + if (count <= 0 || + !writeAll(&destination, buffer.constData(), count)) { + destination.cancelWriting(); + return 4; + } + hash.addData( + QByteArrayView(buffer.constData(), count)); + copied += static_cast(count); + } + if (!source.atEnd() || + copied != expectedSize || + QString::fromLatin1(hash.result().toHex()) != + expectedSha) { + destination.cancelWriting(); + return 5; + } + if (!overwrite && QFileInfo::exists(destinationPath)) { + destination.cancelWriting(); + return 3; + } + if (!destination.commit()) { + return 4; + } + if (!overwrite) { + const QByteArray committed = + QFile::encodeName(committedPath); + const QByteArray final = + QFile::encodeName(destinationPath); + if (::syscall( + SYS_renameat2, AT_FDCWD, committed.constData(), + AT_FDCWD, final.constData(), + RENAME_NOREPLACE) != 0) { + const int renameError = errno; + QFile::remove(committedPath); + return renameError == EEXIST ? 3 : 4; + } + } + const QFileInfo completed(destinationPath); + return completed.exists() && completed.isFile() && + !completed.isSymLink() && + static_cast(completed.size()) == + expectedSize + ? 0 + : 5; +} + +void DeviceMediaWorkflowController::startStage( + Intent intent, const QString &mediaId, + const QString &mediaName) { + if (!runtime_->mediaModel()->canStageDeviceCopy(mediaId)) { + fail(runtime_->mediaModel()->deviceCopyBlockReason(mediaId)); + return; + } + intent_ = intent; + mediaId_ = mediaId; + mediaName_ = mediaName; + error_.clear(); + pendingStageOperationId_ = + runtime_->queueStageDeviceMedia(mediaId); + if (pendingStageOperationId_.isEmpty()) { + fail(runtime_->diagnostic()); + return; + } + emit stateChanged(); +} + +bool DeviceMediaWorkflowController::resolveExportDestination( + const QUrl &folder, const QString &fileName, + QString *destination, QString *normalizedName, + QString *errorMessage) const { + if (!folder.isLocalFile()) { + *errorMessage = tr("Choose a local export folder"); + return false; + } + const QFileInfo folderInfo(folder.toLocalFile()); + const QString canonicalFolder = + folderInfo.canonicalFilePath(); + if (canonicalFolder.isEmpty() || !folderInfo.isDir()) { + *errorMessage = tr("The selected export folder is unavailable"); + return false; + } + QString name = fileName.trimmed(); + if (!name.endsWith( + QStringLiteral(".h264"), + Qt::CaseInsensitive)) { + name += QStringLiteral(".h264"); + } + if (name.isEmpty() || name == QStringLiteral(".") || + name == QStringLiteral("..") || + name.contains(QLatin1Char('/')) || + name.contains(QLatin1Char('\\')) || + name.contains(QChar::Null) || + QFileInfo(name).fileName() != name) { + *errorMessage = tr("Enter a valid H.264 export file name"); + return false; + } + *normalizedName = name; + *destination = QDir(canonicalFolder).filePath(name); + return true; +} + +void DeviceMediaWorkflowController::startExport() { + if (artifact_.artifactId.isEmpty() || + exportDestinationPath_.isEmpty()) { + fail(tr("The claimed device media copy is unavailable")); + return; + } + const qint64 remaining = + artifact_.leaseExpiresUtcMs - + QDateTime::currentMSecsSinceEpoch() - + kExportDeadlineSafetyMs; + if (remaining <= 0) { + fail(tr("The device media lease expired before export started")); + return; + } + exportDiagnostic_.clear(); + exportProcess_.setProgram( + QCoreApplication::applicationFilePath()); + exportProcess_.setArguments({ + QStringLiteral("--internal-export-device-media"), + artifact_.localPath, + exportDestinationPath_, + QString::number(artifact_.size), + artifact_.decodedSha256, + overwriteConfirmed_ ? QStringLiteral("1") + : QStringLiteral("0"), + }); + exportProcess_.start(); + exportDeadline_.start( + static_cast(qMin( + remaining, kExportHardDeadlineMs))); + emit stateChanged(); +} + +void DeviceMediaWorkflowController::finishExport( + int exitCode, QProcess::ExitStatus status) { + exportDeadline_.stop(); + exportDiagnostic_.append(exportProcess_.readAll()); + if (intent_ != Intent::Export || + artifact_.artifactId.isEmpty()) { + return; + } + if (status != QProcess::NormalExit || exitCode != 0) { + const QString message = + exitCode == 3 + ? tr("The export destination already exists") + : tr("The device media copy could not be exported"); + fail(message); + return; + } + const QString completedName = exportFileName_; + releaseArtifact(); + clearPendingWorkflow(); + emit stateChanged(); + emit userMessage( + tr("Exported device media copy as %1") + .arg(completedName), + false); +} + +void DeviceMediaWorkflowController::fail( + const QString &message, bool keepEditor) { + exportDeadline_.stop(); + if (exportProcess_.state() != QProcess::NotRunning) { + exportProcess_.kill(); + } + error_ = message.isEmpty() + ? tr("The device media action failed") + : message; + if (!keepEditor) { + if (editor_->recoveredDeviceCopy() && + !editor_->submissionPending()) { + editor_->cancel(); + } else { + releaseArtifact(); + clearPendingWorkflow(); + } + } + emit stateChanged(); + emit userMessage(error_, true); +} + +void DeviceMediaWorkflowController::releaseArtifact() { + renewTimer_.stop(); + if (!artifact_.artifactId.isEmpty()) { + runtime_->releaseDeviceMediaArtifact( + artifact_.artifactId, artifact_.leaseId); + } + clearArtifact(); +} + +void DeviceMediaWorkflowController::clearArtifact() { + artifact_ = {}; + pendingClaimOperationId_.clear(); + pendingArtifactId_.clear(); + claimPending_ = false; +} + +void DeviceMediaWorkflowController::clearPendingWorkflow() { + pendingStageOperationId_.clear(); + pendingMutationOperationId_.clear(); + claimPending_ = false; + overwriteConfirmationPending_ = false; + overwriteConfirmed_ = false; + intent_ = Intent::None; + mediaId_.clear(); + mediaName_.clear(); + exportFolderPath_.clear(); + exportFileName_.clear(); + exportDestinationPath_.clear(); +} + +void DeviceMediaWorkflowController::updateRenewTimer() { + if (artifact_.artifactId.isEmpty()) { + renewTimer_.stop(); + return; + } + const qint64 remaining = + artifact_.leaseExpiresUtcMs - + QDateTime::currentMSecsSinceEpoch(); + if (remaining <= kExportDeadlineSafetyMs) { + fail(tr("The claimed device media lease is already expired")); + return; + } + renewTimer_.setInterval( + static_cast(qBound( + qint64{1000}, remaining / 3, + static_cast( + kLeaseRenewIntervalMs)))); + renewTimer_.start(); +} + +void DeviceMediaWorkflowController::onOperationUpdated( + const TryxRuntimeOperationInfo &info) { + if (info.id == pendingStageOperationId_) { + if (!OperationListModel::isTerminal(info)) { + return; + } + const QString operationId = + pendingStageOperationId_; + pendingStageOperationId_.clear(); + if (!isSuccess(info) || + info.kind != QStringLiteral("StageDeviceMedia") || + info.resultName.isEmpty()) { + fail(operationError(info)); + return; + } + pendingClaimOperationId_ = operationId; + pendingArtifactId_ = info.resultName; + claimPending_ = true; + runtime_->claimDeviceMediaArtifact( + operationId, pendingArtifactId_); + emit stateChanged(); + return; + } + + if (info.id != pendingMutationOperationId_ || + !OperationListModel::isTerminal(info)) { + return; + } + const QString operationId = + pendingMutationOperationId_; + pendingMutationOperationId_.clear(); + if (isSuccess(info)) { + const bool replaceOperation = + info.kind == QStringLiteral("ReplaceDeviceMedia"); + const bool replaced = + replaceOperation && + info.terminalOutcome == + QStringLiteral("Replaced"); + const QString successMessage = + replaceOperation + ? replaced + ? tr("The edited device media replaced the original") + : info.terminalOutcome == + QStringLiteral("NewCopyReady") + ? tr("The edited copy is ready, but the original media was retained") + : operationError(info) + : tr("The edited device media was saved as a new copy"); + const QString userFacingSuccessMessage = + replaceOperation + ? successMessage + : tr("The edited copy was saved. Select it in the Media Library and apply it to the display."); + editor_->finishRecoveredSubmission( + operationId, true, {}); + releaseArtifact(); + clearPendingWorkflow(); + emit stateChanged(); + emit userMessage(userFacingSuccessMessage, false); + return; + } + const QString message = operationError(info); + editor_->finishRecoveredSubmission( + operationId, false, message); + error_ = message; + emit stateChanged(); + emit userMessage(message, true); +} + +void DeviceMediaWorkflowController::onArtifactClaimed( + const QString &operationId, + const TryxRuntimeDeviceMediaArtifact &artifact) { + if (!claimPending_ || + operationId != pendingClaimOperationId_) { + return; + } + if (artifact.operationId != operationId || + artifact.artifactId != pendingArtifactId_ || + artifact.mediaId != mediaId_ || + artifact.deviceIdentity != + runtime_->mediaModel()->deviceIdentity()) { + if (!artifact.artifactId.isEmpty()) { + runtime_->releaseDeviceMediaArtifact( + artifact.artifactId, artifact.leaseId); + } + fail(tr("The runtime returned an unexpected device media artifact")); + return; + } + claimPending_ = false; + pendingClaimOperationId_.clear(); + pendingArtifactId_.clear(); + artifact_ = artifact; + updateRenewTimer(); + if (artifact_.artifactId.isEmpty()) { + return; + } + if (intent_ == Intent::Edit) { + editor_->beginRecoveredVideo(artifact_); + } else if (intent_ == Intent::Export) { + startExport(); + } else { + fail(tr("The device media action is no longer active")); + return; + } + emit stateChanged(); +} + +void DeviceMediaWorkflowController::onArtifactClaimFailed( + const QString &operationId, const QString &artifactId, + const QString &message) { + if (!claimPending_ || + operationId != pendingClaimOperationId_ || + artifactId != pendingArtifactId_) { + return; + } + fail(message); +} + +void DeviceMediaWorkflowController::onSaveAsNewRequested( + const TryxRuntimeMediaTransform &transform) { + if (artifact_.artifactId.isEmpty() || + !pendingMutationOperationId_.isEmpty()) { + return; + } + const QString operationId = + runtime_->queueRecoveredMediaUploadWithTransform( + artifact_.artifactId, artifact_.leaseId, transform); + if (operationId.isEmpty()) { + error_ = runtime_->diagnostic(); + emit stateChanged(); + emit userMessage(error_, true); + return; + } + pendingMutationOperationId_ = operationId; + editor_->beginRecoveredSubmission( + operationId, QStringLiteral("SaveAsNew")); + emit stateChanged(); +} + +void DeviceMediaWorkflowController::onReplaceRequested( + const TryxRuntimeMediaTransform &transform) { + if (artifact_.artifactId.isEmpty() || + !pendingMutationOperationId_.isEmpty()) { + return; + } + const QString operationId = + runtime_->queueReplaceDeviceMedia( + artifact_.artifactId, artifact_.leaseId, + artifact_.mediaId, + runtime_->currentDisplayApplyRequest(), + transform); + if (operationId.isEmpty()) { + error_ = runtime_->diagnostic(); + emit stateChanged(); + emit userMessage(error_, true); + return; + } + pendingMutationOperationId_ = operationId; + editor_->beginRecoveredSubmission( + operationId, QStringLiteral("Replace")); + emit stateChanged(); +} + +void DeviceMediaWorkflowController::onRuntimeInvalidated() { + exportDeadline_.stop(); + renewTimer_.stop(); + if (exportProcess_.state() != QProcess::NotRunning) { + exportProcess_.kill(); + } + clearArtifact(); + if (!pendingMutationOperationId_.isEmpty()) { + editor_->finishRecoveredSubmission( + pendingMutationOperationId_, false, + tr("The runtime stopped during the media operation")); + } + if (editor_->recoveredDeviceCopy() && + !editor_->submissionPending()) { + editor_->cancel(); + } + clearPendingWorkflow(); + error_ = tr("The runtime stopped during the device media action"); + emit stateChanged(); +} diff --git a/src/quick/devicemediaworkflowcontroller.h b/src/quick/devicemediaworkflowcontroller.h new file mode 100644 index 0000000..a9c320f --- /dev/null +++ b/src/quick/devicemediaworkflowcontroller.h @@ -0,0 +1,112 @@ +#pragma once + +#include "runtimecontract.h" + +#include +#include +#include +#include + +class MediaEditorController; +class RuntimeClient; + +class DeviceMediaWorkflowController final : public QObject { + Q_OBJECT + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(QString action READ action NOTIFY stateChanged) + Q_PROPERTY(QString mediaName READ mediaName NOTIFY stateChanged) + Q_PROPERTY(QString error READ error NOTIFY stateChanged) + Q_PROPERTY(bool overwriteConfirmationPending + READ overwriteConfirmationPending + NOTIFY stateChanged) + Q_PROPERTY(QString overwriteFileName READ overwriteFileName + NOTIFY stateChanged) + +public: + explicit DeviceMediaWorkflowController( + RuntimeClient *runtime, MediaEditorController *editor, + QObject *parent = nullptr); + ~DeviceMediaWorkflowController() override; + + bool busy() const; + QString action() const; + QString mediaName() const; + QString error() const; + bool overwriteConfirmationPending() const; + QString overwriteFileName() const; + + Q_INVOKABLE void beginEdit( + const QString &mediaId, const QString &mediaName); + Q_INVOKABLE void beginExport( + const QString &mediaId, const QString &mediaName, + const QUrl &folder, const QString &fileName); + Q_INVOKABLE void confirmOverwrite(); + Q_INVOKABLE void cancelOverwrite(); + Q_INVOKABLE void cancelCurrent(); + Q_INVOKABLE QString suggestedExportFileName( + const QString &remoteName) const; + + // Internal process boundary used by the Quick executable and tests. + static int runExportHelper(const QStringList &arguments); + +signals: + void stateChanged(); + void userMessage(const QString &message, bool error); + +private: + friend class QuickClientTests; + + enum class Intent { + None, + Edit, + Export + }; + + void startStage(Intent intent, const QString &mediaId, + const QString &mediaName); + bool resolveExportDestination( + const QUrl &folder, const QString &fileName, + QString *destination, QString *normalizedName, + QString *errorMessage) const; + void startExport(); + void finishExport(int exitCode, QProcess::ExitStatus status); + void fail(const QString &message, bool keepEditor = false); + void releaseArtifact(); + void clearArtifact(); + void clearPendingWorkflow(); + void updateRenewTimer(); + void onOperationUpdated(const TryxRuntimeOperationInfo &info); + void onArtifactClaimed( + const QString &operationId, + const TryxRuntimeDeviceMediaArtifact &artifact); + void onArtifactClaimFailed( + const QString &operationId, const QString &artifactId, + const QString &message); + void onSaveAsNewRequested( + const TryxRuntimeMediaTransform &transform); + void onReplaceRequested( + const TryxRuntimeMediaTransform &transform); + void onRuntimeInvalidated(); + + RuntimeClient *runtime_; + MediaEditorController *editor_; + QProcess exportProcess_; + QTimer exportDeadline_; + QTimer renewTimer_; + Intent intent_ = Intent::None; + QString mediaId_; + QString mediaName_; + QString error_; + QString pendingStageOperationId_; + QString pendingClaimOperationId_; + QString pendingArtifactId_; + QString pendingMutationOperationId_; + TryxRuntimeDeviceMediaArtifact artifact_; + QString exportFolderPath_; + QString exportFileName_; + QString exportDestinationPath_; + QByteArray exportDiagnostic_; + bool claimPending_ = false; + bool overwriteConfirmationPending_ = false; + bool overwriteConfirmed_ = false; +}; diff --git a/src/quick/firmwarecontroller.cpp b/src/quick/firmwarecontroller.cpp new file mode 100644 index 0000000..0578240 --- /dev/null +++ b/src/quick/firmwarecontroller.cpp @@ -0,0 +1,655 @@ +#include "firmwarecontroller.h" + +#include "runtimecontract.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr quint32 kFirmwareApiVersion = 2; +constexpr int kFirmwareCallTimeoutMs = 5000; + +QString firmwareInterfaceName() { + return QStringLiteral("org.tryx.Panorama.Firmware1"); +} + +} // namespace + +FirmwareController::FirmwareController(QObject *parent) + : QObject(parent), + bus_(QDBusConnection::sessionBus()), + serviceWatcher_( + tryxRuntimeServiceName(), bus_, + QDBusServiceWatcher::WatchForRegistration | + QDBusServiceWatcher::WatchForUnregistration, + this) { + connect(&serviceWatcher_, &QDBusServiceWatcher::serviceRegistered, + this, &FirmwareController::onServiceRegistered); + connect(&serviceWatcher_, &QDBusServiceWatcher::serviceUnregistered, + this, &FirmwareController::onServiceUnregistered); + subscribeSignals(); + + if (!bus_.isConnected() || !bus_.interface()) { + clearRemoteState(tr("The D-Bus session bus is unavailable")); + return; + } + serviceAvailable_ = + bus_.interface()->isServiceRegistered( + tryxRuntimeServiceName()); + if (serviceAvailable_) { + startHandshake(); + } else { + status_ = tr("Background service is not running"); + } +} + +bool FirmwareController::serviceAvailable() const { + return serviceAvailable_; +} + +bool FirmwareController::compatible() const { + return compatible_; +} + +bool FirmwareController::ready() const { + return ready_; +} + +QString FirmwareController::packagePath() const { + return packagePath_; +} + +QUrl FirmwareController::homeFolder() const { + return QUrl::fromLocalFile( + QStandardPaths::writableLocation( + QStandardPaths::HomeLocation)); +} + +bool FirmwareController::busy() const { + return remoteBusy_ || requestPending_; +} + +bool FirmwareController::validationBusy() const { + return validationBusy_; +} + +bool FirmwareController::flashBusy() const { + return flashBusy_; +} + +bool FirmwareController::approvalAvailable() const { + return !approvalToken_.isEmpty(); +} + +bool FirmwareController::flashSupported() const { + return flashSupported_; +} + +bool FirmwareController::recoveryRequired() const { + return recoveryRequired_; +} + +bool FirmwareController::canValidate() const { + return serviceAvailable_ && compatible_ && ready_ && + !busy() && !packagePath_.trimmed().isEmpty(); +} + +bool FirmwareController::canFlash() const { + return serviceAvailable_ && compatible_ && ready_ && + !busy() && approvalAvailable() && + flashSupported_; +} + +bool FirmwareController::confirmationRequired() const { + return confirmationRequired_; +} + +int FirmwareController::progress() const { + return progress_; +} + +QString FirmwareController::phase() const { + return phase_; +} + +QString FirmwareController::status() const { + return status_; +} + +QString FirmwareController::kind() const { + return kind_; +} + +QString FirmwareController::canonicalPath() const { + return canonicalPath_; +} + +QString FirmwareController::sha256() const { + return sha256_; +} + +qint64 FirmwareController::sizeBytes() const { + return sizeBytes_; +} + +QString FirmwareController::productCode() const { + return productCode_; +} + +QString FirmwareController::firmwareVersion() const { + return firmwareVersion_; +} + +QString FirmwareController::appVersion() const { + return appVersion_; +} + +QString FirmwareController::errorMessage() const { + if (!transportError_.isEmpty()) { + return transportError_; + } + return phase_ == QStringLiteral("Failed") + ? status_ + : QString(); +} + +void FirmwareController::setPackagePath( + const QString &path) { + QString normalized = path.trimmed(); + const QUrl url(normalized); + if (url.isLocalFile()) { + normalized = url.toLocalFile(); + } + if (packagePath_ == normalized) { + return; + } + packagePath_ = normalized; + approvalToken_.clear(); + if (confirmationRequired_) { + confirmationRequired_ = false; + emit confirmationRequiredChanged(); + } + emit packagePathChanged(); + emitDerivedChanges(); +} + +void FirmwareController::validatePackage() { + if (!canValidate()) { + setTransportError( + tr("Select a local firmware ZIP while the firmware service is ready")); + return; + } + + approvalToken_.clear(); + confirmationRequired_ = false; + emit confirmationRequiredChanged(); + approvalSourcePath_ = packagePath_; + setTransportError({}); + setRequestPending(true); + + QDBusInterface firmware( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + firmwareInterfaceName(), bus_); + firmware.setTimeout(kFirmwareCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + firmware.asyncCall( + QStringLiteral("ValidateFirmware"), + packagePath_), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + setRequestPending(false); + if (!reply.isValid()) { + setTransportError(reply.error().message()); + return; + } + if (!reply.value()) { + setTransportError( + tr("The firmware service rejected the validation request")); + } + requestState(); + }); +} + +void FirmwareController::requestFlashConfirmation() { + if (!canFlash()) { + setTransportError( + tr("Validate a flashable firmware package first")); + return; + } + if (confirmationRequired_) { + return; + } + confirmationRequired_ = true; + emit confirmationRequiredChanged(); +} + +void FirmwareController::cancelFlashConfirmation() { + if (!confirmationRequired_) { + return; + } + confirmationRequired_ = false; + emit confirmationRequiredChanged(); +} + +void FirmwareController::confirmFlash() { + if (!confirmationRequired_ || !canFlash()) { + cancelFlashConfirmation(); + setTransportError( + tr("Firmware approval is no longer available; validate the package again")); + return; + } + + confirmationRequired_ = false; + emit confirmationRequiredChanged(); + const QString token = approvalToken_; + approvalToken_.clear(); + approvalSourcePath_.clear(); + setTransportError({}); + setRequestPending(true); + emitDerivedChanges(); + + QDBusInterface firmware( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + firmwareInterfaceName(), bus_); + firmware.setTimeout(kFirmwareCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + firmware.asyncCall( + QStringLiteral("StartFirmwareFlash"), token), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + setRequestPending(false); + if (!reply.isValid()) { + setTransportError(reply.error().message()); + return; + } + if (!reply.value()) { + setTransportError( + tr("The firmware service rejected the flash request")); + } + requestState(); + }); +} + +void FirmwareController::requestCancel() { + if (!serviceAvailable_ || !compatible_ || + !flashBusy_ || requestPending_) { + return; + } + + QDBusInterface firmware( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + firmwareInterfaceName(), bus_); + firmware.setTimeout(kFirmwareCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + firmware.asyncCall( + QStringLiteral("CancelFirmware")), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply<> reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + setTransportError(reply.error().message()); + } + // The daemon owns the irreversible-step lock. A successful D-Bus + // reply only means that it received the request. + requestState(); + }); +} + +void FirmwareController::acknowledgeFirmwareRecovery() { + if (!serviceAvailable_ || !compatible_ || + !recoveryRequired_ || busy()) { + setTransportError( + tr("Firmware recovery acknowledgement is not available")); + return; + } + + setTransportError({}); + setRequestPending(true); + + QDBusInterface firmware( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + firmwareInterfaceName(), bus_); + firmware.setTimeout(kFirmwareCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + firmware.asyncCall( + QStringLiteral( + "AcknowledgeFirmwareRecovery")), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + setRequestPending(false); + if (!reply.isValid()) { + setTransportError(reply.error().message()); + return; + } + if (!reply.value()) { + setTransportError( + tr("The firmware service rejected the recovery acknowledgement")); + } + // The daemon remains authoritative. Do not clear recovery locally + // or reconnect from the GUI; refresh the caller-owned state after + // the acknowledgement has been processed. + requestState(); + }); +} + +void FirmwareController::refresh() { + if (!serviceAvailable_) { + return; + } + if (!compatible_) { + startHandshake(); + return; + } + requestState(); +} + +void FirmwareController::retranslate() { + emit connectionChanged(); + emit packagePathChanged(); + emit stateChanged(); + emitDerivedChanges(); +} + +void FirmwareController::onServiceRegistered( + const QString &) { + ++serviceEpoch_; + serviceAvailable_ = true; + compatible_ = false; + emit connectionChanged(); + emitDerivedChanges(); + startHandshake(); +} + +void FirmwareController::onServiceUnregistered( + const QString &) { + ++serviceEpoch_; + serviceAvailable_ = false; + compatible_ = false; + clearRemoteState(tr("Background service stopped")); + emit connectionChanged(); + emitDerivedChanges(); +} + +void FirmwareController::onRemoteStateChanged( + QVariantMap state) { + if (!compatible_) { + return; + } + applyState(state, false); + requestState(); +} + +void FirmwareController::onRemoteProgressChanged( + int progress, QString message) { + if (!compatible_) { + return; + } + progress_ = qBound(0, progress, 100); + status_ = message; + emit stateChanged(); +} + +void FirmwareController::onRemoteFinished( + bool success, QString message) { + if (!compatible_) { + return; + } + status_ = message; + emit stateChanged(); + emit finished(success, message); + requestState(); +} + +void FirmwareController::subscribeSignals() { + if (signalsSubscribed_ || !bus_.isConnected()) { + return; + } + const QString service = tryxRuntimeServiceName(); + const QString path = tryxRuntimeObjectPath(); + const QString interfaceName = firmwareInterfaceName(); + bool ok = true; + ok &= bus_.connect( + service, path, interfaceName, + QStringLiteral("StateChanged"), this, + SLOT(onRemoteStateChanged(QVariantMap))); + ok &= bus_.connect( + service, path, interfaceName, + QStringLiteral("ProgressChanged"), this, + SLOT(onRemoteProgressChanged(int,QString))); + ok &= bus_.connect( + service, path, interfaceName, + QStringLiteral("Finished"), this, + SLOT(onRemoteFinished(bool,QString))); + signalsSubscribed_ = ok; + if (!ok) { + setTransportError( + tr("Could not subscribe to firmware service signals")); + } +} + +void FirmwareController::startHandshake() { + if (!serviceAvailable_) { + return; + } + QDBusInterface firmware( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + firmwareInterfaceName(), bus_); + firmware.setTimeout(kFirmwareCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + firmware.asyncCall( + QStringLiteral("GetFirmwareApiVersion")), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + compatible_ = false; + setTransportError(reply.error().message()); + emit connectionChanged(); + emitDerivedChanges(); + return; + } + apiVersion_ = reply.value(); + compatible_ = + apiVersion_ == kFirmwareApiVersion; + if (!compatible_) { + clearRemoteState( + tr("Firmware API %1 is incompatible; this client requires API %2") + .arg(apiVersion_) + .arg(kFirmwareApiVersion)); + } else { + setTransportError({}); + requestState(); + } + emit connectionChanged(); + emitDerivedChanges(); + }); +} + +void FirmwareController::requestState() { + if (!serviceAvailable_ || !compatible_) { + return; + } + if (stateRequestPending_) { + stateRefreshAgain_ = true; + return; + } + stateRequestPending_ = true; + stateRefreshAgain_ = false; + + QDBusInterface firmware( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + firmwareInterfaceName(), bus_); + firmware.setTimeout(kFirmwareCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + firmware.asyncCall( + QStringLiteral("GetFirmwareState")), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + stateRequestPending_ = false; + if (!reply.isValid()) { + setTransportError(reply.error().message()); + } else { + setTransportError({}); + applyState(reply.value(), true); + } + if (stateRefreshAgain_) { + stateRefreshAgain_ = false; + requestState(); + } + }); +} + +void FirmwareController::applyState( + const QVariantMap &state, bool includeApproval) { + ready_ = state.value(QStringLiteral("ready")).toBool(); + remoteBusy_ = + state.value(QStringLiteral("busy")).toBool(); + validationBusy_ = + state.value(QStringLiteral("validationBusy")).toBool(); + flashBusy_ = + state.value(QStringLiteral("flashBusy")).toBool(); + flashSupported_ = + state.value(QStringLiteral("flashSupported")).toBool(); + if (state.contains( + QStringLiteral("recoveryRequired"))) { + recoveryRequired_ = + state.value( + QStringLiteral("recoveryRequired")).toBool(); + } + progress_ = qBound( + 0, state.value(QStringLiteral("progress")).toInt(), + 100); + phase_ = state.value(QStringLiteral("phase")).toString(); + status_ = state.value(QStringLiteral("status")).toString(); + kind_ = state.value(QStringLiteral("kind")).toString(); + canonicalPath_ = + state.value(QStringLiteral("canonicalPath")).toString(); + sha256_ = state.value(QStringLiteral("sha256")).toString(); + sizeBytes_ = + state.value(QStringLiteral("size")).toLongLong(); + productCode_ = + state.value(QStringLiteral("productCode")).toString(); + firmwareVersion_ = + state.value(QStringLiteral("firmwareVersion")).toString(); + appVersion_ = + state.value(QStringLiteral("appVersion")).toString(); + if (includeApproval) { + const bool approvalMatchesInput = + approvalSourcePath_ == packagePath_; + approvalToken_ = + approvalMatchesInput && + state.value( + QStringLiteral("approvalAvailable")).toBool() + ? state.value( + QStringLiteral("approvalToken")).toString() + : QString(); + } + if (!approvalAvailable() && confirmationRequired_) { + confirmationRequired_ = false; + emit confirmationRequiredChanged(); + } + emit stateChanged(); + emitDerivedChanges(); +} + +void FirmwareController::clearRemoteState( + const QString &status) { + ready_ = false; + remoteBusy_ = false; + validationBusy_ = false; + flashBusy_ = false; + flashSupported_ = false; + recoveryRequired_ = false; + requestPending_ = false; + stateRequestPending_ = false; + stateRefreshAgain_ = false; + confirmationRequired_ = false; + progress_ = 0; + approvalSourcePath_.clear(); + approvalToken_.clear(); + phase_ = QStringLiteral("Unavailable"); + status_ = status; + kind_.clear(); + canonicalPath_.clear(); + sha256_.clear(); + sizeBytes_ = 0; + productCode_.clear(); + firmwareVersion_.clear(); + appVersion_.clear(); + transportError_.clear(); + emit confirmationRequiredChanged(); + emit stateChanged(); +} + +void FirmwareController::setRequestPending(bool pending) { + if (requestPending_ == pending) { + return; + } + requestPending_ = pending; + emit stateChanged(); + emitDerivedChanges(); +} + +void FirmwareController::setTransportError( + const QString &message) { + if (transportError_ == message) { + return; + } + transportError_ = message; + emit stateChanged(); +} + +void FirmwareController::emitDerivedChanges() { + emit availabilityChanged(); +} diff --git a/src/quick/firmwarecontroller.h b/src/quick/firmwarecontroller.h new file mode 100644 index 0000000..ff6e46d --- /dev/null +++ b/src/quick/firmwarecontroller.h @@ -0,0 +1,141 @@ +#pragma once + +#include +#include +#include +#include +#include + +class FirmwareController final : public QObject { + Q_OBJECT + Q_PROPERTY(bool serviceAvailable READ serviceAvailable + NOTIFY connectionChanged) + Q_PROPERTY(bool compatible READ compatible NOTIFY connectionChanged) + Q_PROPERTY(bool ready READ ready NOTIFY stateChanged) + Q_PROPERTY(QString packagePath READ packagePath WRITE setPackagePath + NOTIFY packagePathChanged) + Q_PROPERTY(QUrl homeFolder READ homeFolder CONSTANT) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(bool validationBusy READ validationBusy NOTIFY stateChanged) + Q_PROPERTY(bool flashBusy READ flashBusy NOTIFY stateChanged) + Q_PROPERTY(bool approvalAvailable READ approvalAvailable + NOTIFY stateChanged) + Q_PROPERTY(bool flashSupported READ flashSupported + NOTIFY stateChanged) + Q_PROPERTY(bool recoveryRequired READ recoveryRequired + NOTIFY stateChanged) + Q_PROPERTY(bool canValidate READ canValidate + NOTIFY availabilityChanged) + Q_PROPERTY(bool canFlash READ canFlash NOTIFY availabilityChanged) + Q_PROPERTY(bool confirmationRequired READ confirmationRequired + NOTIFY confirmationRequiredChanged) + Q_PROPERTY(int progress READ progress NOTIFY stateChanged) + Q_PROPERTY(QString phase READ phase NOTIFY stateChanged) + Q_PROPERTY(QString status READ status NOTIFY stateChanged) + Q_PROPERTY(QString kind READ kind NOTIFY stateChanged) + Q_PROPERTY(QString canonicalPath READ canonicalPath NOTIFY stateChanged) + Q_PROPERTY(QString sha256 READ sha256 NOTIFY stateChanged) + Q_PROPERTY(qint64 sizeBytes READ sizeBytes NOTIFY stateChanged) + Q_PROPERTY(QString productCode READ productCode NOTIFY stateChanged) + Q_PROPERTY(QString firmwareVersion READ firmwareVersion + NOTIFY stateChanged) + Q_PROPERTY(QString appVersion READ appVersion NOTIFY stateChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY stateChanged) + +public: + explicit FirmwareController(QObject *parent = nullptr); + + bool serviceAvailable() const; + bool compatible() const; + bool ready() const; + QString packagePath() const; + QUrl homeFolder() const; + bool busy() const; + bool validationBusy() const; + bool flashBusy() const; + bool approvalAvailable() const; + bool flashSupported() const; + bool recoveryRequired() const; + bool canValidate() const; + bool canFlash() const; + bool confirmationRequired() const; + int progress() const; + QString phase() const; + QString status() const; + QString kind() const; + QString canonicalPath() const; + QString sha256() const; + qint64 sizeBytes() const; + QString productCode() const; + QString firmwareVersion() const; + QString appVersion() const; + QString errorMessage() const; + + Q_INVOKABLE void setPackagePath(const QString &path); + + Q_INVOKABLE void validatePackage(); + Q_INVOKABLE void requestFlashConfirmation(); + Q_INVOKABLE void cancelFlashConfirmation(); + Q_INVOKABLE void confirmFlash(); + Q_INVOKABLE void requestCancel(); + Q_INVOKABLE void acknowledgeFirmwareRecovery(); + Q_INVOKABLE void refresh(); + void retranslate(); + +signals: + void connectionChanged(); + void packagePathChanged(); + void stateChanged(); + void availabilityChanged(); + void confirmationRequiredChanged(); + void finished(bool success, const QString &message); + +private slots: + void onServiceRegistered(const QString &service); + void onServiceUnregistered(const QString &service); + void onRemoteStateChanged(QVariantMap state); + void onRemoteProgressChanged(int progress, QString message); + void onRemoteFinished(bool success, QString message); + +private: + void subscribeSignals(); + void startHandshake(); + void requestState(); + void applyState(const QVariantMap &state, bool includeApproval); + void clearRemoteState(const QString &status); + void setRequestPending(bool pending); + void setTransportError(const QString &message); + void emitDerivedChanges(); + + QDBusConnection bus_; + QDBusServiceWatcher serviceWatcher_; + bool signalsSubscribed_ = false; + bool serviceAvailable_ = false; + bool compatible_ = false; + bool ready_ = false; + bool remoteBusy_ = false; + bool validationBusy_ = false; + bool flashBusy_ = false; + bool flashSupported_ = false; + bool recoveryRequired_ = false; + bool requestPending_ = false; + bool stateRequestPending_ = false; + bool stateRefreshAgain_ = false; + bool confirmationRequired_ = false; + int progress_ = 0; + quint32 apiVersion_ = 0; + quint64 serviceEpoch_ = 1; + QString packagePath_; + QString approvalSourcePath_; + QString approvalToken_; + QString phase_ = QStringLiteral("Unavailable"); + QString status_; + QString kind_; + QString canonicalPath_; + QString sha256_; + qint64 sizeBytes_ = 0; + QString productCode_; + QString firmwareVersion_; + QString appVersion_; + QString transportError_; +}; diff --git a/src/quick/linuxtraycontroller.cpp b/src/quick/linuxtraycontroller.cpp new file mode 100644 index 0000000..e3e1b65 --- /dev/null +++ b/src/quick/linuxtraycontroller.cpp @@ -0,0 +1,938 @@ +#include "linuxtraycontroller.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const QString kWatcherService = + QStringLiteral("org.kde.StatusNotifierWatcher"); +const QString kWatcherPath = + QStringLiteral("/StatusNotifierWatcher"); +const QString kWatcherInterface = + QStringLiteral("org.kde.StatusNotifierWatcher"); +const QString kPropertiesInterface = + QStringLiteral("org.freedesktop.DBus.Properties"); +const QString kItemPath = + QStringLiteral("/StatusNotifierItem"); +const QString kMenuPath = + QStringLiteral("/StatusNotifierItem/Menu"); + +const QString kNotificationsService = + QStringLiteral("org.freedesktop.Notifications"); +const QString kNotificationsPath = + QStringLiteral("/org/freedesktop/Notifications"); +const QString kNotificationsInterface = + QStringLiteral("org.freedesktop.Notifications"); + +linuxtray::MenuLayout buildMenuLayout( + const linuxtray::MenuModel &model, int itemId, + int recursionDepth, const QStringList &propertyNames) { + linuxtray::MenuLayout layout; + layout.id = itemId; + layout.properties = + model.properties(itemId, propertyNames); + if (recursionDepth == 0) { + return layout; + } + + const int childDepth = + recursionDepth < 0 ? -1 : recursionDepth - 1; + const QList childIds = model.children(itemId); + layout.children.reserve(childIds.size()); + for (const int childId : childIds) { + const linuxtray::MenuLayout child = + buildMenuLayout( + model, childId, childDepth, propertyNames); + layout.children.append( + QDBusVariant(QVariant::fromValue(child))); + } + return layout; +} + +} // namespace + +namespace linuxtray { + +QDBusArgument &operator<<(QDBusArgument &argument, + const IconPixmap &pixmap) { + argument.beginStructure(); + argument << pixmap.width << pixmap.height << pixmap.data; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + IconPixmap &pixmap) { + argument.beginStructure(); + argument >> pixmap.width >> pixmap.height >> pixmap.data; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const ToolTip &toolTip) { + argument.beginStructure(); + argument << toolTip.iconName << toolTip.iconPixmap + << toolTip.title << toolTip.description; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + ToolTip &toolTip) { + argument.beginStructure(); + argument >> toolTip.iconName >> toolTip.iconPixmap + >> toolTip.title >> toolTip.description; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const MenuLayout &layout) { + argument.beginStructure(); + argument << layout.id << layout.properties + << layout.children; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + MenuLayout &layout) { + argument.beginStructure(); + argument >> layout.id >> layout.properties + >> layout.children; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<( + QDBusArgument &argument, + const MenuItemProperties &properties) { + argument.beginStructure(); + argument << properties.id << properties.properties; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>( + const QDBusArgument &argument, + MenuItemProperties &properties) { + argument.beginStructure(); + argument >> properties.id >> properties.properties; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<( + QDBusArgument &argument, + const MenuItemsPropertiesRemoved &properties) { + argument.beginStructure(); + argument << properties.id << properties.properties; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>( + const QDBusArgument &argument, + MenuItemsPropertiesRemoved &properties) { + argument.beginStructure(); + argument >> properties.id >> properties.properties; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const MenuEvent &event) { + argument.beginStructure(); + argument << event.id << event.eventId + << event.data << event.timestamp; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + MenuEvent &event) { + argument.beginStructure(); + argument >> event.id >> event.eventId + >> event.data >> event.timestamp; + argument.endStructure(); + return argument; +} + +void registerDBusTypes() { + static const bool registered = []() { + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType< + MenuItemsPropertiesRemovedList>(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + return true; + }(); + Q_UNUSED(registered); +} + +MenuModel::MenuModel() + : openLabel_(QStringLiteral("Open")), + quitLabel_(QStringLiteral("Quit")) {} + +quint32 MenuModel::revision() const { + return revision_; +} + +QList MenuModel::children(int parentId) const { + if (parentId != Root) { + return {}; + } + return {Open, Separator, Quit}; +} + +QVariantMap MenuModel::properties( + int itemId, const QStringList &requestedNames) const { + QVariantMap result; + switch (itemId) { + case Root: + break; + case Open: + result.insert( + QStringLiteral("label"), openLabel_); + result.insert( + QStringLiteral("enabled"), true); + result.insert( + QStringLiteral("visible"), true); + break; + case Separator: + result.insert( + QStringLiteral("type"), + QStringLiteral("separator")); + result.insert( + QStringLiteral("visible"), true); + break; + case Quit: + result.insert( + QStringLiteral("label"), quitLabel_); + result.insert( + QStringLiteral("enabled"), true); + result.insert( + QStringLiteral("visible"), true); + break; + default: + return {}; + } + return filtered(result, requestedNames); +} + +bool MenuModel::contains(int itemId) const { + return itemId >= Root && itemId <= Quit; +} + +MenuModel::Action MenuModel::actionForEvent( + int itemId, const QString &eventId) const { + if (eventId != QStringLiteral("clicked")) { + return Action::None; + } + if (itemId == Open) { + return Action::Show; + } + if (itemId == Quit) { + return Action::Quit; + } + return Action::None; +} + +void MenuModel::setLabels( + const QString &openLabel, + const QString &quitLabel) { + const QString normalizedOpen = + openLabel.trimmed().isEmpty() + ? QStringLiteral("Open") + : openLabel.trimmed(); + const QString normalizedQuit = + quitLabel.trimmed().isEmpty() + ? QStringLiteral("Quit") + : quitLabel.trimmed(); + if (openLabel_ == normalizedOpen && + quitLabel_ == normalizedQuit) { + return; + } + openLabel_ = normalizedOpen; + quitLabel_ = normalizedQuit; + ++revision_; + if (revision_ == 0) { + revision_ = 1; + } +} + +QVariantMap MenuModel::filtered( + const QVariantMap &source, + const QStringList &requestedNames) { + if (requestedNames.isEmpty()) { + return source; + } + const QSet requested( + requestedNames.cbegin(), requestedNames.cend()); + QVariantMap result; + for (auto it = source.cbegin(); it != source.cend(); ++it) { + if (requested.contains(it.key())) { + result.insert(it.key(), it.value()); + } + } + return result; +} + +} // namespace linuxtray + +class LinuxTrayStatusNotifierItem final : public QObject { + Q_OBJECT + Q_CLASSINFO( + "D-Bus Interface", + "org.kde.StatusNotifierItem") + Q_PROPERTY(QString Category READ category) + Q_PROPERTY(QString Id READ id) + Q_PROPERTY(QString Title READ title) + Q_PROPERTY(QString Status READ status) + Q_PROPERTY(quint32 WindowId READ windowId) + Q_PROPERTY(QString IconName READ iconName) + Q_PROPERTY(linuxtray::IconPixmapList IconPixmap + READ iconPixmap) + Q_PROPERTY(QString OverlayIconName READ overlayIconName) + Q_PROPERTY(linuxtray::IconPixmapList OverlayIconPixmap + READ overlayIconPixmap) + Q_PROPERTY(QString AttentionIconName + READ attentionIconName) + Q_PROPERTY(linuxtray::IconPixmapList AttentionIconPixmap + READ attentionIconPixmap) + Q_PROPERTY(QString AttentionMovieName + READ attentionMovieName) + Q_PROPERTY(linuxtray::ToolTip ToolTip READ toolTip) + Q_PROPERTY(bool ItemIsMenu READ itemIsMenu) + Q_PROPERTY(QDBusObjectPath Menu READ menu) + +public: + explicit LinuxTrayStatusNotifierItem( + QObject *parent = nullptr) + : QObject(parent) { + toolTip_.iconName = iconName(); + toolTip_.title = + QStringLiteral("TRYX Panorama Manager"); + toolTip_.description = + QStringLiteral("TRYX Panorama display control"); + } + + QString category() const { + return QStringLiteral("ApplicationStatus"); + } + QString id() const { + return QStringLiteral("tryx-panorama-manager"); + } + QString title() const { + return QStringLiteral("TRYX Panorama Manager"); + } + QString status() const { + return QStringLiteral("Active"); + } + quint32 windowId() const { + return 0; + } + QString iconName() const { + return QStringLiteral("tryx-panorama"); + } + linuxtray::IconPixmapList iconPixmap() const { + return {}; + } + QString overlayIconName() const { + return {}; + } + linuxtray::IconPixmapList overlayIconPixmap() const { + return {}; + } + QString attentionIconName() const { + return {}; + } + linuxtray::IconPixmapList attentionIconPixmap() const { + return {}; + } + QString attentionMovieName() const { + return {}; + } + linuxtray::ToolTip toolTip() const { + return toolTip_; + } + bool itemIsMenu() const { + return false; + } + QDBusObjectPath menu() const { + return QDBusObjectPath(kMenuPath); + } + + void setToolTip(const QString &title, + const QString &description) { + linuxtray::ToolTip next = toolTip_; + next.title = title.trimmed().isEmpty() + ? QStringLiteral("TRYX Panorama Manager") + : title.trimmed(); + next.description = description.trimmed(); + if (next.title == toolTip_.title && + next.description == toolTip_.description) { + return; + } + toolTip_ = next; + emit NewToolTip(); + } + +public slots: + void ContextMenu(int x, int y) { + Q_UNUSED(x); + Q_UNUSED(y); + } + + void Activate(int x, int y) { + Q_UNUSED(x); + Q_UNUSED(y); + emit showRequested(); + } + + void SecondaryActivate(int x, int y) { + Q_UNUSED(x); + Q_UNUSED(y); + emit showRequested(); + } + + void Scroll(int delta, const QString &orientation) { + Q_UNUSED(delta); + Q_UNUSED(orientation); + } + +signals: + void NewTitle(); + void NewIcon(); + void NewAttentionIcon(); + void NewOverlayIcon(); + void NewToolTip(); + void NewStatus(const QString &status); + + void showRequested(); + +private: + linuxtray::ToolTip toolTip_; +}; + +class LinuxTrayDBusMenu final : public QObject { + Q_OBJECT + Q_CLASSINFO( + "D-Bus Interface", + "com.canonical.dbusmenu") + Q_PROPERTY(uint Version READ version) + Q_PROPERTY(QString TextDirection READ textDirection) + Q_PROPERTY(QString Status READ status) + Q_PROPERTY(QStringList IconThemePath READ iconThemePath) + +public: + explicit LinuxTrayDBusMenu(QObject *parent = nullptr) + : QObject(parent) {} + + uint version() const { + return 3; + } + QString textDirection() const { + return QStringLiteral("ltr"); + } + QString status() const { + return QStringLiteral("normal"); + } + QStringList iconThemePath() const { + return {}; + } + + void setLabels(const QString &openLabel, + const QString &quitLabel) { + const quint32 oldRevision = model_.revision(); + model_.setLabels(openLabel, quitLabel); + if (oldRevision != model_.revision()) { + emit LayoutUpdated( + model_.revision(), + linuxtray::MenuModel::Root); + } + } + +public slots: + uint GetLayout( + int parentId, int recursionDepth, + const QStringList &propertyNames, + linuxtray::MenuLayout &layout) const { + const int requestedParent = + model_.contains(parentId) + ? parentId + : linuxtray::MenuModel::Root; + layout = buildMenuLayout( + model_, requestedParent, + recursionDepth, propertyNames); + return model_.revision(); + } + + linuxtray::MenuItemPropertiesList GetGroupProperties( + const QList &itemIds, + const QStringList &propertyNames) const { + linuxtray::MenuItemPropertiesList result; + for (const int itemId : itemIds) { + if (!model_.contains(itemId)) { + continue; + } + linuxtray::MenuItemProperties entry; + entry.id = itemId; + entry.properties = + model_.properties(itemId, propertyNames); + result.append(entry); + } + return result; + } + + QDBusVariant GetProperty( + int itemId, const QString &name) const { + const QVariantMap properties = + model_.properties(itemId, {name}); + return QDBusVariant(properties.value(name)); + } + + void Event( + int itemId, const QString &eventId, + const QDBusVariant &data, uint timestamp) { + Q_UNUSED(data); + dispatchAction( + model_.actionForEvent(itemId, eventId), + itemId, timestamp); + } + + QList EventGroup( + const linuxtray::MenuEventList &events) { + QList rejected; + for (const linuxtray::MenuEvent &event : events) { + if (!model_.contains(event.id)) { + rejected.append(event.id); + continue; + } + dispatchAction( + model_.actionForEvent( + event.id, event.eventId), + event.id, event.timestamp); + } + return rejected; + } + + bool AboutToShow(int itemId) const { + Q_UNUSED(itemId); + return false; + } + + void AboutToShowGroup( + const QList &itemIds, + QList &updatesNeeded, + QList &idErrors) const { + updatesNeeded.clear(); + idErrors.clear(); + for (const int itemId : itemIds) { + if (!model_.contains(itemId)) { + idErrors.append(itemId); + } + } + } + +signals: + void ItemsPropertiesUpdated( + const linuxtray::MenuItemPropertiesList &updatedProperties, + const linuxtray::MenuItemsPropertiesRemovedList + &removedProperties); + void LayoutUpdated(uint revision, int parentId); + void ItemActivationRequested(int itemId, uint timestamp); + + void showRequested(); + void quitRequested(); + +private: + void dispatchAction( + linuxtray::MenuModel::Action action, + int itemId, uint timestamp) { + switch (action) { + case linuxtray::MenuModel::Action::Show: + emit ItemActivationRequested( + itemId, timestamp); + emit showRequested(); + break; + case linuxtray::MenuModel::Action::Quit: + emit ItemActivationRequested( + itemId, timestamp); + emit quitRequested(); + break; + case linuxtray::MenuModel::Action::None: + break; + } + } + + linuxtray::MenuModel model_; +}; + +LinuxTrayController::LinuxTrayController(QObject *parent) + : QObject(parent), + bus_(QDBusConnection::sessionBus()), + watcher_(new QDBusServiceWatcher( + kWatcherService, bus_, + QDBusServiceWatcher::WatchForRegistration | + QDBusServiceWatcher::WatchForUnregistration, + this)), + item_(new LinuxTrayStatusNotifierItem(this)), + menu_(new LinuxTrayDBusMenu(this)) { + linuxtray::registerDBusTypes(); + + connect( + item_, &LinuxTrayStatusNotifierItem::showRequested, + this, &LinuxTrayController::showRequested); + connect( + menu_, &LinuxTrayDBusMenu::showRequested, + this, &LinuxTrayController::showRequested); + connect( + menu_, &LinuxTrayDBusMenu::quitRequested, + this, &LinuxTrayController::quitRequested); + connect( + watcher_, &QDBusServiceWatcher::serviceRegistered, + this, &LinuxTrayController::handleWatcherRegistered); + connect( + watcher_, &QDBusServiceWatcher::serviceUnregistered, + this, &LinuxTrayController::handleWatcherUnregistered); + + if (!bus_.isConnected()) { + setDiagnostic( + tr("The user D-Bus session is unavailable")); + return; + } + + registerObjects(); + connectWatcherSignals(); + if (!objectsRegistered_) { + return; + } + + const QDBusReply present = + bus_.interface()->isServiceRegistered( + kWatcherService); + if (present.isValid() && present.value()) { + handleWatcherRegistered(kWatcherService); + } else { + setDiagnostic( + tr("No StatusNotifier host is available")); + } +} + +LinuxTrayController::~LinuxTrayController() { + if (!bus_.isConnected()) { + return; + } + bus_.unregisterObject(kMenuPath); + bus_.unregisterObject(kItemPath); +} + +bool LinuxTrayController::available() const { + return available_; +} + +QString LinuxTrayController::diagnostic() const { + return diagnostic_; +} + +void LinuxTrayController::setLabels( + const QString &openLabel, + const QString &quitLabel) { + menu_->setLabels(openLabel, quitLabel); +} + +void LinuxTrayController::setToolTip( + const QString &title, + const QString &description) { + item_->setToolTip(title, description); +} + +void LinuxTrayController::showNotification( + const QString &summary, const QString &body, + int timeoutMs) { + if (!bus_.isConnected()) { + const QString message = + tr("The user D-Bus session is unavailable"); + emit notificationFailed(message); + return; + } + + QDBusInterface notifications( + kNotificationsService, kNotificationsPath, + kNotificationsInterface, bus_); + notifications.setTimeout(2000); + QVariantMap hints; + const QVariantList arguments = { + QStringLiteral("TRYX Panorama Manager"), + notificationId_, + QStringLiteral("tryx-panorama"), + summary, + body, + QStringList{}, + hints, + qBound(0, timeoutMs, 60000), + }; + auto *pending = new QDBusPendingCallWatcher( + notifications.asyncCallWithArgumentList( + QStringLiteral("Notify"), arguments), + this); + connect( + pending, &QDBusPendingCallWatcher::finished, + this, [this, pending]() { + const QDBusPendingReply reply = *pending; + pending->deleteLater(); + if (!reply.isValid()) { + emit notificationFailed( + reply.error().message()); + return; + } + notificationId_ = reply.value(); + }); +} + +void LinuxTrayController::handleWatcherRegistered( + const QString &service) { + if (service != kWatcherService || + !objectsRegistered_) { + return; + } + ++watcherEpoch_; + watcherPresent_ = true; + hostPresent_ = false; + registrationAccepted_ = false; + registrationPending_ = false; + updateAvailable(); + connectWatcherSignals(); + queryHostAvailability(); + registerWithWatcher(); +} + +void LinuxTrayController::handleWatcherUnregistered( + const QString &service) { + if (service != kWatcherService) { + return; + } + ++watcherEpoch_; + watcherPresent_ = false; + hostPresent_ = false; + registrationAccepted_ = false; + registrationPending_ = false; + updateAvailable(); + setDiagnostic( + tr("No StatusNotifier host is available")); +} + +void LinuxTrayController::handleHostRegistered() { + if (!watcherPresent_) { + return; + } + hostPresent_ = true; + if (!registrationAccepted_ && + !registrationPending_) { + registerWithWatcher(); + } + updateAvailable(); +} + +void LinuxTrayController::handleHostUnregistered() { + hostPresent_ = false; + updateAvailable(); + setDiagnostic( + tr("No StatusNotifier host is available")); +} + +void LinuxTrayController::registerObjects() { + const auto flags = + QDBusConnection::ExportAllProperties | + QDBusConnection::ExportAllSlots | + QDBusConnection::ExportAllSignals; + const bool itemRegistered = + bus_.registerObject(kItemPath, item_, flags); + const bool menuRegistered = + bus_.registerObject(kMenuPath, menu_, flags); + objectsRegistered_ = + itemRegistered && menuRegistered; + if (objectsRegistered_) { + return; + } + if (itemRegistered) { + bus_.unregisterObject(kItemPath); + } + if (menuRegistered) { + bus_.unregisterObject(kMenuPath); + } + setDiagnostic( + tr("Could not export the StatusNotifier D-Bus objects: %1") + .arg(bus_.lastError().message())); +} + +void LinuxTrayController::connectWatcherSignals() { + bus_.disconnect( + kWatcherService, kWatcherPath, + kWatcherInterface, + QStringLiteral("StatusNotifierHostRegistered"), + this, SLOT(handleHostRegistered())); + bus_.disconnect( + kWatcherService, kWatcherPath, + kWatcherInterface, + QStringLiteral("StatusNotifierHostUnregistered"), + this, SLOT(handleHostUnregistered())); + bus_.connect( + kWatcherService, kWatcherPath, + kWatcherInterface, + QStringLiteral("StatusNotifierHostRegistered"), + this, SLOT(handleHostRegistered())); + bus_.connect( + kWatcherService, kWatcherPath, + kWatcherInterface, + QStringLiteral("StatusNotifierHostUnregistered"), + this, SLOT(handleHostUnregistered())); +} + +void LinuxTrayController::registerWithWatcher() { + if (!watcherPresent_ || registrationPending_ || + !objectsRegistered_) { + return; + } + registrationPending_ = true; + const quint64 epoch = watcherEpoch_; + QDBusInterface watcher( + kWatcherService, kWatcherPath, + kWatcherInterface, bus_); + watcher.setTimeout(2000); + auto *pending = new QDBusPendingCallWatcher( + watcher.asyncCall( + QStringLiteral("RegisterStatusNotifierItem"), + kItemPath), + this); + connect( + pending, &QDBusPendingCallWatcher::finished, + this, [this, pending, epoch]() { + handleRegistrationReply(pending, epoch); + }); +} + +void LinuxTrayController::queryHostAvailability() { + if (!watcherPresent_) { + return; + } + const quint64 epoch = watcherEpoch_; + QDBusInterface properties( + kWatcherService, kWatcherPath, + kPropertiesInterface, bus_); + properties.setTimeout(2000); + auto *pending = new QDBusPendingCallWatcher( + properties.asyncCall( + QStringLiteral("Get"), + kWatcherInterface, + QStringLiteral( + "IsStatusNotifierHostRegistered")), + this); + connect( + pending, &QDBusPendingCallWatcher::finished, + this, [this, pending, epoch]() { + handleHostQueryReply(pending, epoch); + }); +} + +void LinuxTrayController::updateAvailable() { + const bool next = + objectsRegistered_ && watcherPresent_ && + hostPresent_ && registrationAccepted_; + if (available_ == next) { + return; + } + available_ = next; + emit availableChanged(); + if (available_) { + setDiagnostic({}); + } +} + +void LinuxTrayController::setDiagnostic( + const QString &message) { + if (diagnostic_ == message) { + return; + } + diagnostic_ = message; + emit diagnosticChanged(); +} + +void LinuxTrayController::handleRegistrationReply( + QDBusPendingCallWatcher *watcher, + quint64 epoch) { + const QDBusPendingReply<> reply = *watcher; + watcher->deleteLater(); + if (epoch != watcherEpoch_ || + !watcherPresent_) { + return; + } + registrationPending_ = false; + if (!reply.isValid()) { + registrationAccepted_ = false; + updateAvailable(); + setDiagnostic( + tr("StatusNotifier registration failed: %1") + .arg(reply.error().message())); + return; + } + registrationAccepted_ = true; + updateAvailable(); + queryHostAvailability(); +} + +void LinuxTrayController::handleHostQueryReply( + QDBusPendingCallWatcher *watcher, + quint64 epoch) { + const QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != watcherEpoch_ || + !watcherPresent_) { + return; + } + if (!reply.isValid()) { + hostPresent_ = false; + updateAvailable(); + setDiagnostic( + tr("Could not query the StatusNotifier host: %1") + .arg(reply.error().message())); + return; + } + hostPresent_ = + reply.value().variant().toBool(); + updateAvailable(); + if (!hostPresent_) { + setDiagnostic( + tr("No StatusNotifier host is available")); + } +} + +#include "linuxtraycontroller.moc" diff --git a/src/quick/linuxtraycontroller.h b/src/quick/linuxtraycontroller.h new file mode 100644 index 0000000..960f820 --- /dev/null +++ b/src/quick/linuxtraycontroller.h @@ -0,0 +1,211 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QDBusPendingCallWatcher; +class QDBusServiceWatcher; + +namespace linuxtray { + +struct IconPixmap { + int width = 0; + int height = 0; + QByteArray data; +}; + +using IconPixmapList = QList; + +struct ToolTip { + QString iconName; + IconPixmapList iconPixmap; + QString title; + QString description; +}; + +struct MenuLayout { + int id = 0; + QVariantMap properties; + QList children; +}; + +struct MenuItemProperties { + int id = 0; + QVariantMap properties; +}; + +using MenuItemPropertiesList = QList; + +struct MenuItemsPropertiesRemoved { + int id = 0; + QStringList properties; +}; + +using MenuItemsPropertiesRemovedList = + QList; + +struct MenuEvent { + int id = 0; + QString eventId; + QDBusVariant data; + quint32 timestamp = 0; +}; + +using MenuEventList = QList; + +QDBusArgument &operator<<(QDBusArgument &argument, + const IconPixmap &pixmap); +const QDBusArgument &operator>>(const QDBusArgument &argument, + IconPixmap &pixmap); +QDBusArgument &operator<<(QDBusArgument &argument, + const ToolTip &toolTip); +const QDBusArgument &operator>>(const QDBusArgument &argument, + ToolTip &toolTip); +QDBusArgument &operator<<(QDBusArgument &argument, + const MenuLayout &layout); +const QDBusArgument &operator>>(const QDBusArgument &argument, + MenuLayout &layout); +QDBusArgument &operator<<(QDBusArgument &argument, + const MenuItemProperties &properties); +const QDBusArgument &operator>>( + const QDBusArgument &argument, + MenuItemProperties &properties); +QDBusArgument &operator<<( + QDBusArgument &argument, + const MenuItemsPropertiesRemoved &properties); +const QDBusArgument &operator>>( + const QDBusArgument &argument, + MenuItemsPropertiesRemoved &properties); +QDBusArgument &operator<<(QDBusArgument &argument, + const MenuEvent &event); +const QDBusArgument &operator>>(const QDBusArgument &argument, + MenuEvent &event); + +void registerDBusTypes(); + +class MenuModel final { +public: + enum ItemId { + Root = 0, + Open = 1, + Separator = 2, + Quit = 3, + }; + + enum class Action { + None, + Show, + Quit, + }; + + MenuModel(); + + quint32 revision() const; + QList children(int parentId) const; + QVariantMap properties( + int itemId, + const QStringList &requestedNames = {}) const; + bool contains(int itemId) const; + Action actionForEvent(int itemId, + const QString &eventId) const; + void setLabels(const QString &openLabel, + const QString &quitLabel); + +private: + static QVariantMap filtered( + const QVariantMap &source, + const QStringList &requestedNames); + + QString openLabel_; + QString quitLabel_; + quint32 revision_ = 1; +}; + +} // namespace linuxtray + +Q_DECLARE_METATYPE(linuxtray::IconPixmap) +Q_DECLARE_METATYPE(linuxtray::IconPixmapList) +Q_DECLARE_METATYPE(linuxtray::ToolTip) +Q_DECLARE_METATYPE(linuxtray::MenuLayout) +Q_DECLARE_METATYPE(linuxtray::MenuItemProperties) +Q_DECLARE_METATYPE(linuxtray::MenuItemPropertiesList) +Q_DECLARE_METATYPE(linuxtray::MenuItemsPropertiesRemoved) +Q_DECLARE_METATYPE(linuxtray::MenuItemsPropertiesRemovedList) +Q_DECLARE_METATYPE(linuxtray::MenuEvent) +Q_DECLARE_METATYPE(linuxtray::MenuEventList) + +class LinuxTrayStatusNotifierItem; +class LinuxTrayDBusMenu; + +class LinuxTrayController final : public QObject { + Q_OBJECT + Q_PROPERTY(bool available READ available + NOTIFY availableChanged) + Q_PROPERTY(QString diagnostic READ diagnostic + NOTIFY diagnosticChanged) + +public: + explicit LinuxTrayController(QObject *parent = nullptr); + ~LinuxTrayController() override; + + bool available() const; + QString diagnostic() const; + + void setLabels(const QString &openLabel, + const QString &quitLabel); + void setToolTip(const QString &title, + const QString &description); + + Q_INVOKABLE void showNotification( + const QString &summary, + const QString &body, + int timeoutMs = 3000); + +signals: + void availableChanged(); + void diagnosticChanged(); + void showRequested(); + void quitRequested(); + void notificationFailed(const QString &message); + +private slots: + void handleWatcherRegistered(const QString &service); + void handleWatcherUnregistered(const QString &service); + void handleHostRegistered(); + void handleHostUnregistered(); + +private: + void registerObjects(); + void connectWatcherSignals(); + void registerWithWatcher(); + void queryHostAvailability(); + void updateAvailable(); + void setDiagnostic(const QString &message); + void handleRegistrationReply( + QDBusPendingCallWatcher *watcher, + quint64 epoch); + void handleHostQueryReply( + QDBusPendingCallWatcher *watcher, + quint64 epoch); + + QDBusConnection bus_; + QDBusServiceWatcher *watcher_ = nullptr; + LinuxTrayStatusNotifierItem *item_ = nullptr; + LinuxTrayDBusMenu *menu_ = nullptr; + bool objectsRegistered_ = false; + bool watcherPresent_ = false; + bool hostPresent_ = false; + bool registrationAccepted_ = false; + bool registrationPending_ = false; + bool available_ = false; + QString diagnostic_; + quint64 watcherEpoch_ = 0; + quint32 notificationId_ = 0; +}; diff --git a/src/quick/main.cpp b/src/quick/main.cpp new file mode 100644 index 0000000..9cbf867 --- /dev/null +++ b/src/quick/main.cpp @@ -0,0 +1,253 @@ +#include "appsettingscontroller.h" +#include "devicemediaworkflowcontroller.h" +#include "firmwarecontroller.h" +#include "linuxtraycontroller.h" +#include "mediaeditorcontroller.h" +#include "mediapreviewcontroller.h" +#include "runtimebootstrap.h" +#include "runtimeclient.h" +#include "systemmetricsmodel.h" +#include "windowchromecontroller.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace { + +void configureApplicationIdentity(QCoreApplication &app) { + app.setApplicationName(QStringLiteral("TRYX Panorama Manager")); + app.setApplicationVersion(QStringLiteral(TRYX_APP_VERSION)); + app.setOrganizationName(QStringLiteral("DXVSI")); +} + +bool hasArgument(const QStringList &arguments, + const QString &value) { + return arguments.contains(value); +} + +QString configuredLanguage() { + const auto config = panorama::ConfigManager::load_config(); + return config + ? QString::fromStdString(config->language) + : QStringLiteral("en"); +} + +void applyLanguage(QCoreApplication &app, QTranslator &translator, + const QString &language) { + app.removeTranslator(&translator); + if (language == QStringLiteral("en")) { + return; + } + const QLocale locale = + language == QStringLiteral("system") + ? QLocale::system() + : QLocale(language); + if (locale.language() == QLocale::English) { + return; + } + if (translator.load( + locale, QStringLiteral("tryx-panorama"), + QStringLiteral("_"), QStringLiteral(":/i18n"))) { + app.installTranslator(&translator); + } +} + +bool rawArgumentPresent(int argc, char *argv[], + const char *expected) { + for (int index = 1; index < argc; ++index) { + if (qstrcmp(argv[index], expected) == 0) { + return true; + } + } + return false; +} + +} // namespace + +int main(int argc, char *argv[]) { + if (argc > 1 && + qstrcmp(argv[1], "--internal-stage-copy") == 0) { + QStringList helperArguments; + for (int index = 2; index < argc; ++index) { + helperArguments.append( + QString::fromLocal8Bit(argv[index])); + } + return MediaPreviewController::runStageCopyHelper( + helperArguments); + } + if (argc > 1 && + qstrcmp( + argv[1], + "--internal-export-device-media") == 0) { + QStringList helperArguments; + for (int index = 2; index < argc; ++index) { + helperArguments.append( + QString::fromLocal8Bit(argv[index])); + } + return DeviceMediaWorkflowController::runExportHelper( + helperArguments); + } + if (rawArgumentPresent(argc, argv, "--version")) { + std::fputs( + "tryx-panorama-manager " TRYX_APP_VERSION "\n", + stdout); + return 0; + } + + QQuickStyle::setStyle(QStringLiteral("Material")); + QGuiApplication app(argc, argv); + configureApplicationIdentity(app); + app.setWindowIcon( + QIcon(QStringLiteral(":/icons/tryx-panorama.png"))); + app.setDesktopFileName( + QStringLiteral("tryx-panorama-manager")); + + const QStringList arguments = app.arguments(); + const bool smokeTest = + hasArgument(arguments, QStringLiteral("--smoke-test")); + + QTranslator translator; + applyLanguage(app, translator, configuredLanguage()); + + QLocalServer instanceServer; + if (!smokeTest) { + const QString socketPath = + quickbootstrap::instanceSocketPath(); + if (quickbootstrap::notifyRunningInstance(socketPath)) { + return 0; + } + QString socketError; + if (!quickbootstrap::listenForSingleInstance( + &instanceServer, socketPath, &socketError)) { + qWarning().noquote() + << "Could not create the desktop client single-instance socket:" + << socketError; + } + + QString runtimeError; + if (!quickbootstrap::ensureRuntimeService(&runtimeError)) { + qCritical().noquote() + << "Could not start the TRYX runtime:" + << runtimeError; + return 1; + } + } + + RuntimeClient runtime(smokeTest); + MediaEditorController mediaEditor(&runtime); + DeviceMediaWorkflowController deviceMedia( + &runtime, &mediaEditor); + FirmwareController firmware; + SystemMetricsModel systemMetrics; + AppSettingsController settings(smokeTest); + WindowChromeController windowChrome; + LinuxTrayController tray; + + const auto updateTrayPresentation = + [&tray, &runtime]() { + tray.setLabels( + LinuxTrayController::tr("Open"), + LinuxTrayController::tr("Quit")); + tray.setToolTip( + QStringLiteral("TRYX Panorama Manager"), + runtime.connectionStatus()); + }; + updateTrayPresentation(); + windowChrome.setTrayAvailable(tray.available()); + QObject::connect( + &tray, &LinuxTrayController::availableChanged, + &windowChrome, [&tray, &windowChrome]() { + windowChrome.setTrayAvailable( + tray.available()); + }); + QObject::connect( + &tray, &LinuxTrayController::showRequested, + &windowChrome, + &WindowChromeController::showWindow); + // QGuiApplication::quit() first closes every top-level window. The QML + // close handler deliberately rejects that close while the tray is + // available, so an explicit tray Quit must leave the event loop directly. + // Queue exit() because it must run on the application thread. + QObject::connect( + &tray, &LinuxTrayController::quitRequested, + &app, + []() { QCoreApplication::exit(0); }, + Qt::QueuedConnection); + QObject::connect( + &runtime, &RuntimeClient::connectionChanged, + &tray, updateTrayPresentation); + + QQmlApplicationEngine engine; + QObject::connect( + &settings, &AppSettingsController::languageChanged, + &engine, + [&app, &translator, &settings, &engine, &runtime, &firmware, + &updateTrayPresentation]() { + applyLanguage( + app, translator, settings.language()); + engine.retranslate(); + runtime.retranslate(); + firmware.retranslate(); + updateTrayPresentation(); + }); + engine.setInitialProperties({ + {QStringLiteral("runtime"), + QVariant::fromValue(static_cast(&runtime))}, + {QStringLiteral("mediaEditor"), + QVariant::fromValue(static_cast(&mediaEditor))}, + {QStringLiteral("deviceMedia"), + QVariant::fromValue(static_cast(&deviceMedia))}, + {QStringLiteral("firmware"), + QVariant::fromValue(static_cast(&firmware))}, + {QStringLiteral("systemMetrics"), + QVariant::fromValue( + static_cast(&systemMetrics))}, + {QStringLiteral("settings"), + QVariant::fromValue(static_cast(&settings))}, + {QStringLiteral("windowChrome"), + QVariant::fromValue( + static_cast(&windowChrome))}, + {QStringLiteral("quickSmokeTest"), smokeTest}, + }); + + const QUrl entry(QStringLiteral("qrc:/qml/Main.qml")); + QObject::connect( + &engine, &QQmlApplicationEngine::objectCreationFailed, + &app, []() { QCoreApplication::exit(2); }, + Qt::QueuedConnection); + engine.load(entry); + if (engine.rootObjects().isEmpty()) { + return 2; + } + auto *rootWindow = + qobject_cast(engine.rootObjects().constFirst()); + windowChrome.setWindow(rootWindow); + QObject::connect( + &instanceServer, &QLocalServer::newConnection, &app, + [&instanceServer, &windowChrome]() { + while (QLocalSocket *connection = + instanceServer.nextPendingConnection()) { + connection->deleteLater(); + } + windowChrome.showWindow(); + }); + if (smokeTest) { + QTimer::singleShot(0, &app, [&app]() { app.exit(0); }); + } + return app.exec(); +} diff --git a/src/quick/mediacatalogmodel.cpp b/src/quick/mediacatalogmodel.cpp new file mode 100644 index 0000000..2623f5e --- /dev/null +++ b/src/quick/mediacatalogmodel.cpp @@ -0,0 +1,221 @@ +#include "mediacatalogmodel.h" +#include "applicationpaths.h" + +#include +#include +#include +#include +#include + +MediaCatalogModel::MediaCatalogModel(QObject *parent) + : MediaCatalogModel( + panorama::sharedApplicationDataLocation(), + parent) {} + +MediaCatalogModel::MediaCatalogModel( + const QString &applicationDataRoot, + QObject *parent) + : QAbstractListModel(parent), + applicationDataRoot_( + QDir(applicationDataRoot).absolutePath()) {} + +int MediaCatalogModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : entries_.size(); +} + +QVariant MediaCatalogModel::data(const QModelIndex &index, int role) const { + if (!index.isValid() || index.row() < 0 || + index.row() >= entries_.size()) { + return {}; + } + const TryxRuntimeMediaEntry &entry = entries_.at(index.row()); + switch (role) { + case Qt::DisplayRole: + case NameRole: + return entry.name; + case SizeRole: + return QVariant::fromValue(entry.size); + case SourceRole: + return entry.source; + case ReadOnlyRole: + return entry.readOnly; + case ThumbnailUrlRole: + return thumbnailUrl(entry); + case ManagedOriginRole: + return entry.managedOrigin; + case DeleteAllowedRole: + return entry.deleteAllowed; + case DeleteBlockReasonRole: + return entry.deleteBlockReason; + case MediaIdRole: + return entry.mediaId; + case DeviceCopyAllowedRole: + return deviceCopyAllowed(entry); + case DeviceCopyBlockReasonRole: + return deviceCopyBlockReasonForEntry(entry); + default: + return {}; + } +} + +QHash MediaCatalogModel::roleNames() const { + return { + {NameRole, "mediaName"}, + {SizeRole, "mediaSize"}, + {SourceRole, "mediaSource"}, + {ReadOnlyRole, "readOnly"}, + {ThumbnailUrlRole, "thumbnailUrl"}, + {ManagedOriginRole, "managedOrigin"}, + {DeleteAllowedRole, "deleteAllowed"}, + {DeleteBlockReasonRole, "deleteBlockReason"}, + {MediaIdRole, "mediaId"}, + {DeviceCopyAllowedRole, "deviceCopyAllowed"}, + {DeviceCopyBlockReasonRole, "deviceCopyBlockReason"}, + }; +} + +quint64 MediaCatalogModel::revision() const { + return revision_; +} + +QString MediaCatalogModel::deviceIdentity() const { + return deviceIdentity_; +} + +bool MediaCatalogModel::canDelete( + const QString &mediaName) const { + for (const TryxRuntimeMediaEntry &entry : entries_) { + if (entry.name == mediaName) { + return entry.deleteAllowed; + } + } + return false; +} + +QString MediaCatalogModel::deleteBlockReason( + const QString &mediaName) const { + for (const TryxRuntimeMediaEntry &entry : entries_) { + if (entry.name == mediaName) { + return entry.deleteBlockReason; + } + } + return tr("Media is not present in the current catalog"); +} + +bool MediaCatalogModel::canStageDeviceCopy( + const QString &mediaId) const { + if (mediaId.isEmpty()) { + return false; + } + for (const TryxRuntimeMediaEntry &entry : entries_) { + if (entry.mediaId == mediaId) { + return deviceCopyAllowed(entry); + } + } + return false; +} + +QString MediaCatalogModel::deviceCopyBlockReason( + const QString &mediaId) const { + if (!mediaId.isEmpty()) { + for (const TryxRuntimeMediaEntry &entry : entries_) { + if (entry.mediaId == mediaId) { + return deviceCopyBlockReasonForEntry(entry); + } + } + } + return tr("Media is not present in the current catalog"); +} + +void MediaCatalogModel::applySnapshot( + const TryxRuntimeMediaCatalogSnapshot &snapshot) { + if (snapshot.revision <= revision_ && revision_ != 0) { + return; + } + const bool identityChanged = + snapshot.deviceIdentity != deviceIdentity_; + beginResetModel(); + revision_ = snapshot.revision; + deviceIdentity_ = snapshot.deviceIdentity; + entries_ = snapshot.entries; + endResetModel(); + emit revisionChanged(); + if (identityChanged) { + emit deviceIdentityChanged(); + } +} + +void MediaCatalogModel::applyLegacyFiles( + const QStringList &files, quint64 revision, + const QString &deviceIdentity) { + TryxRuntimeMediaCatalogSnapshot snapshot; + snapshot.revision = revision; + snapshot.deviceIdentity = deviceIdentity; + + QSet seen; + for (const QString &value : files) { + const QString name = value.trimmed(); + if (name.isEmpty() || seen.contains(name)) { + continue; + } + seen.insert(name); + TryxRuntimeMediaEntry entry; + entry.name = name; + entry.source = 1U; + entry.deleteAllowed = true; + // Manager1 has no FilePull identity. Keep mediaId empty so Quick + // cannot expose PASE-only export/edit actions for legacy files. + snapshot.entries.append(entry); + } + applySnapshot(snapshot); +} + +void MediaCatalogModel::clear() { + if (entries_.isEmpty() && deviceIdentity_.isEmpty() && revision_ == 0) { + return; + } + beginResetModel(); + entries_.clear(); + deviceIdentity_.clear(); + revision_ = 0; + endResetModel(); + emit revisionChanged(); + emit deviceIdentityChanged(); +} + +QUrl MediaCatalogModel::thumbnailUrl( + const TryxRuntimeMediaEntry &entry) const { + static const QRegularExpression sha256( + QStringLiteral("^[0-9a-f]{64}$")); + if (!sha256.match(entry.thumbnailKey).hasMatch()) { + return {}; + } + const QString path = QDir(applicationDataRoot_).filePath( + QStringLiteral("media-catalog/thumbnails/%1.jpg") + .arg(entry.thumbnailKey)); + const QFileInfo info(path); + if (!info.exists() || !info.isFile() || info.isSymLink()) { + return {}; + } + return QUrl::fromLocalFile(info.absoluteFilePath()); +} + +bool MediaCatalogModel::deviceCopyAllowed( + const TryxRuntimeMediaEntry &entry) { + return !entry.mediaId.isEmpty() && entry.source == 1U && + !entry.readOnly; +} + +QString MediaCatalogModel::deviceCopyBlockReasonForEntry( + const TryxRuntimeMediaEntry &entry) { + if (entry.mediaId.isEmpty()) { + return tr("The current catalog cannot identify this media"); + } + if (entry.source != 1U) { + return tr("Built-in and preset media cannot be exported or edited"); + } + if (entry.readOnly) { + return tr("Read-only media cannot be exported or edited"); + } + return {}; +} diff --git a/src/quick/mediacatalogmodel.h b/src/quick/mediacatalogmodel.h new file mode 100644 index 0000000..ca53991 --- /dev/null +++ b/src/quick/mediacatalogmodel.h @@ -0,0 +1,69 @@ +#pragma once + +#include "runtimecontract.h" + +#include + +class MediaCatalogModel final : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(quint64 revision READ revision NOTIFY revisionChanged) + Q_PROPERTY(QString deviceIdentity READ deviceIdentity + NOTIFY deviceIdentityChanged) + +public: + enum Role { + NameRole = Qt::UserRole + 1, + SizeRole, + SourceRole, + ReadOnlyRole, + ThumbnailUrlRole, + ManagedOriginRole, + DeleteAllowedRole, + DeleteBlockReasonRole, + MediaIdRole, + DeviceCopyAllowedRole, + DeviceCopyBlockReasonRole + }; + Q_ENUM(Role) + + explicit MediaCatalogModel(QObject *parent = nullptr); + explicit MediaCatalogModel( + const QString &applicationDataRoot, + QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + quint64 revision() const; + QString deviceIdentity() const; + Q_INVOKABLE bool canDelete(const QString &mediaName) const; + Q_INVOKABLE QString deleteBlockReason( + const QString &mediaName) const; + Q_INVOKABLE bool canStageDeviceCopy( + const QString &mediaId) const; + Q_INVOKABLE QString deviceCopyBlockReason( + const QString &mediaId) const; + void applySnapshot(const TryxRuntimeMediaCatalogSnapshot &snapshot); + void applyLegacyFiles(const QStringList &files, + quint64 revision, + const QString &deviceIdentity); + void clear(); + +signals: + void revisionChanged(); + void deviceIdentityChanged(); + +private: + QUrl thumbnailUrl(const TryxRuntimeMediaEntry &entry) const; + static bool deviceCopyAllowed( + const TryxRuntimeMediaEntry &entry); + static QString deviceCopyBlockReasonForEntry( + const TryxRuntimeMediaEntry &entry); + + quint64 revision_ = 0; + QString deviceIdentity_; + QList entries_; + QString applicationDataRoot_; +}; diff --git a/src/quick/mediaeditorcontroller.cpp b/src/quick/mediaeditorcontroller.cpp new file mode 100644 index 0000000..214d60e --- /dev/null +++ b/src/quick/mediaeditorcontroller.cpp @@ -0,0 +1,483 @@ +#include "mediaeditorcontroller.h" + +#include "runtimeclient.h" + +#include +#include +#include +#include +#include + +MediaEditorController::MediaEditorController( + RuntimeClient *runtime, QObject *parent) + : QObject(parent), runtime_(runtime), preview_(this) { + connect(&preview_, &MediaPreviewController::stateChanged, + this, [this]() { + if (preview_.ready()) { + localPath_ = preview_.sourcePath(); + editorError_.clear(); + } else if (!preview_.error().isEmpty()) { + editorError_ = preview_.error(); + } + emit previewChanged(); + }); + connect(this, &MediaEditorController::transformChanged, + this, [this]() { + preview_.setTransform(transform()); + }); + connect(runtime_, &RuntimeClient::operationRequestAccepted, + this, [this](const QString &operationId, + const QString &kind) { + if (kind != QStringLiteral("Upload") || + pendingOperationId_ != operationId) { + return; + } + pendingOperationId_.clear(); + preview_.releaseStagedSource(); + localPath_.clear(); + sourceName_.clear(); + editorError_.clear(); + open_ = false; + emit openChanged(); + emit previewChanged(); + emit submitted(); + }); + connect(runtime_, &RuntimeClient::operationRequestRejected, + this, [this](const QString &operationId, + const QString &kind, + const QString &message) { + if (kind != QStringLiteral("Upload") || + pendingOperationId_ != operationId) { + return; + } + pendingOperationId_.clear(); + preview_.restoreStagedSourceOwnership(); + editorError_ = message; + emit previewChanged(); + }); +} + +bool MediaEditorController::isOpen() const { + return open_; +} + +bool MediaEditorController::busy() const { + return preview_.busy() || !pendingOperationId_.isEmpty(); +} + +bool MediaEditorController::submissionPending() const { + return !pendingOperationId_.isEmpty(); +} + +bool MediaEditorController::ready() const { + return preview_.ready() && !localPath_.isEmpty(); +} + +QString MediaEditorController::sourceName() const { + return sourceName_; +} + +QString MediaEditorController::sourceKind() const { + if (!recoveredArtifact_.artifactId.isEmpty()) { + return QStringLiteral("RecoveredDeviceCopy"); + } + return sourceName_.isEmpty() + ? QString() + : QStringLiteral("LocalMedia"); +} + +bool MediaEditorController::recoveredDeviceCopy() const { + return !recoveredArtifact_.artifactId.isEmpty(); +} + +QString MediaEditorController::originalMediaName() const { + return recoveredArtifact_.remoteName; +} + +bool MediaEditorController::replaceAllowed() const { + return recoveredDeviceCopy() && + !recoveredArtifact_.mediaId.isEmpty(); +} + +QString MediaEditorController::replaceBlockReason() const { + if (!recoveredDeviceCopy() || replaceAllowed()) { + return {}; + } + return tr("The original media identity is unavailable"); +} + +QString MediaEditorController::submissionAction() const { + return recoveredSubmissionAction_; +} + +QUrl MediaEditorController::previewUrl() const { + return preview_.previewUrl(); +} + +QString MediaEditorController::error() const { + return editorError_.isEmpty() ? preview_.error() : editorError_; +} + +QString MediaEditorController::mode() const { + return mode_; +} + +int MediaEditorController::zoomPercent() const { + return zoomPercent_; +} + +int MediaEditorController::focusX() const { + return focusX_; +} + +int MediaEditorController::focusY() const { + return focusY_; +} + +int MediaEditorController::rotation() const { + return rotation_; +} + +QString MediaEditorController::backgroundColor() const { + return backgroundColor_; +} + +QUrl MediaEditorController::homeFolder() const { + return QUrl::fromLocalFile(QDir::homePath()); +} + +TryxRuntimeMediaTransform MediaEditorController::transform() const { + TryxRuntimeMediaTransform result; + result.schemaVersion = 1; + result.mode = mode_; + result.rotationQuarterTurns = + static_cast(rotation_ / 90); + result.zoomPermille = + mode_ == QStringLiteral("Crop") + ? static_cast(zoomPercent_ * 10) + : 1000U; + result.focusX = mode_ == QStringLiteral("Crop") + ? static_cast(focusX_) + : 5000U; + result.focusY = mode_ == QStringLiteral("Crop") + ? static_cast(focusY_) + : 5000U; + result.backgroundRgb = mode_ == QStringLiteral("Fit") + ? static_cast( + QColor(backgroundColor_).rgb() & 0x00ffffff) + : 0U; + return result; +} + +void MediaEditorController::beginRecoveredVideo( + const TryxRuntimeDeviceMediaArtifact &artifact) { + if (!pendingOperationId_.isEmpty()) { + editorError_ = tr( + "Wait for the current media operation to finish"); + emit previewChanged(); + return; + } + preview_.cancel(); + reset(); + recoveredArtifact_ = artifact; + recoveredSubmissionAction_.clear(); + editorError_.clear(); + sourceName_ = artifact.remoteName; + localPath_.clear(); + if (!open_) { + open_ = true; + emit openChanged(); + } + emit previewChanged(); + preview_.loadRecoveredVideo(artifact); +} + +void MediaEditorController::beginRecoveredSubmission( + const QString &operationId, const QString &action) { + if (!recoveredDeviceCopy() || operationId.isEmpty() || + !pendingOperationId_.isEmpty()) { + return; + } + pendingOperationId_ = operationId; + recoveredSubmissionAction_ = action; + editorError_.clear(); + emit previewChanged(); +} + +void MediaEditorController::finishRecoveredSubmission( + const QString &operationId, bool success, + const QString &message) { + if (pendingOperationId_ != operationId || + !recoveredDeviceCopy()) { + return; + } + pendingOperationId_.clear(); + recoveredSubmissionAction_.clear(); + if (!success) { + editorError_ = message.isEmpty() + ? tr("The recovered media operation failed") + : message; + emit previewChanged(); + return; + } + + preview_.cancel(); + localPath_.clear(); + sourceName_.clear(); + recoveredArtifact_ = {}; + editorError_.clear(); + if (open_) { + open_ = false; + emit openChanged(); + } + emit previewChanged(); + emit submitted(); +} + +void MediaEditorController::begin(const QUrl &source) { + if (!pendingOperationId_.isEmpty()) { + editorError_ = tr( + "Wait for the runtime to acknowledge the current upload request"); + emit previewChanged(); + return; + } + preview_.cancel(); + reset(); + recoveredArtifact_ = {}; + recoveredSubmissionAction_.clear(); + editorError_.clear(); + sourceName_ = source.isLocalFile() + ? QFileInfo(source.toLocalFile()).fileName() + : QString(); + localPath_.clear(); + if (!open_) { + open_ = true; + emit openChanged(); + } + emit previewChanged(); + preview_.load(source); +} + +void MediaEditorController::beginDropped( + const QVariantList &sources) { + if (!pendingOperationId_.isEmpty()) { + editorError_ = tr( + "Wait for the runtime to acknowledge the current upload request"); + emit previewChanged(); + return; + } + if (sources.size() != 1) { + preview_.cancel(); + reset(); + localPath_.clear(); + sourceName_.clear(); + editorError_ = tr( + "Drop exactly one local media file into the editor"); + if (!open_) { + open_ = true; + emit openChanged(); + } + emit previewChanged(); + return; + } + begin(sources.constFirst().toUrl()); +} + +void MediaEditorController::cancel() { + if (!pendingOperationId_.isEmpty()) { + editorError_ = tr( + "Wait for the runtime to accept or reject the upload request"); + emit previewChanged(); + return; + } + const bool closingRecovered = recoveredDeviceCopy(); + preview_.cancel(); + localPath_.clear(); + sourceName_.clear(); + recoveredArtifact_ = {}; + recoveredSubmissionAction_.clear(); + editorError_.clear(); + if (open_) { + open_ = false; + emit openChanged(); + } + emit previewChanged(); + if (closingRecovered) { + emit recoveredClosed(); + } + emit cancelled(); +} + +void MediaEditorController::reset() { + if (!pendingOperationId_.isEmpty()) { + return; + } + const bool changed = + mode_ != QStringLiteral("Fit") || zoomPercent_ != 100 || + focusX_ != 5000 || focusY_ != 5000 || rotation_ != 0 || + backgroundColor_ != QStringLiteral("#000000"); + mode_ = QStringLiteral("Fit"); + zoomPercent_ = 100; + focusX_ = 5000; + focusY_ = 5000; + rotation_ = 0; + backgroundColor_ = QStringLiteral("#000000"); + if (changed) { + emit transformChanged(); + } +} + +void MediaEditorController::submit() { + if (!pendingOperationId_.isEmpty()) { + return; + } + if (!ready()) { + editorError_ = tr( + "Wait until a valid preview has been decoded"); + emit previewChanged(); + return; + } + if (recoveredDeviceCopy()) { + editorError_ = tr( + "Choose Save as new or Replace for a recovered device copy"); + emit previewChanged(); + return; + } + const TryxRuntimeMediaTransform selectedTransform = + transform(); + if (!preview_.protectStagedSource()) { + editorError_ = tr( + "The private media snapshot is no longer available"); + emit previewChanged(); + return; + } + + pendingOperationId_ = + runtime_->queueUploadWithTransform( + localPath_, selectedTransform); + if (pendingOperationId_.isEmpty()) { + preview_.restoreStagedSourceOwnership(); + editorError_ = runtime_->diagnostic(); + emit previewChanged(); + return; + } + emit previewChanged(); +} + +void MediaEditorController::submitSaveAsNew() { + if (!recoveredDeviceCopy() || + !pendingOperationId_.isEmpty()) { + return; + } + if (!ready()) { + editorError_ = tr( + "Wait until the recovered video preview is ready"); + emit previewChanged(); + return; + } + emit recoveredSaveAsNewRequested(transform()); +} + +void MediaEditorController::submitReplace() { + if (!recoveredDeviceCopy() || + !pendingOperationId_.isEmpty()) { + return; + } + if (!replaceAllowed()) { + editorError_ = replaceBlockReason(); + emit previewChanged(); + return; + } + if (!ready()) { + editorError_ = tr( + "Wait until the recovered video preview is ready"); + emit previewChanged(); + return; + } + emit recoveredReplaceRequested(transform()); +} + +void MediaEditorController::setMode(const QString &mode) { + static const QSet modes{ + QStringLiteral("Fit"), QStringLiteral("Fill"), + QStringLiteral("Crop"), QStringLiteral("Stretch")}; + if (!pendingOperationId_.isEmpty() || + !modes.contains(mode) || mode_ == mode) { + return; + } + mode_ = mode; + if (mode_ != QStringLiteral("Crop")) { + zoomPercent_ = 100; + focusX_ = 5000; + focusY_ = 5000; + } + emit transformChanged(); +} + +void MediaEditorController::setZoomPercent(int value) { + const int bounded = qBound(100, value, 400); + if (!pendingOperationId_.isEmpty() || + zoomPercent_ == bounded || mode_ != QStringLiteral("Crop")) { + return; + } + zoomPercent_ = bounded; + emit transformChanged(); +} + +void MediaEditorController::setFocusX(int value) { + const int bounded = qBound(0, value, 10000); + if (!pendingOperationId_.isEmpty() || + focusX_ == bounded || mode_ != QStringLiteral("Crop")) { + return; + } + focusX_ = bounded; + emit transformChanged(); +} + +void MediaEditorController::setFocusY(int value) { + const int bounded = qBound(0, value, 10000); + if (!pendingOperationId_.isEmpty() || + focusY_ == bounded || mode_ != QStringLiteral("Crop")) { + return; + } + focusY_ = bounded; + emit transformChanged(); +} + +void MediaEditorController::setRotation(int value) { + if (!pendingOperationId_.isEmpty()) { + return; + } + int normalized = value % 360; + if (normalized < 0) { + normalized += 360; + } + normalized = (normalized / 90) * 90; + if (rotation_ == normalized) { + return; + } + rotation_ = normalized; + if (mode_ == QStringLiteral("Crop")) { + zoomPercent_ = 100; + focusX_ = 5000; + focusY_ = 5000; + } + emit transformChanged(); +} + +void MediaEditorController::setBackgroundColor( + const QString &value) { + if (!pendingOperationId_.isEmpty()) { + return; + } + const QColor color(value); + if (!color.isValid()) { + return; + } + const QString normalized = color.name(QColor::HexRgb); + if (backgroundColor_ == normalized) { + return; + } + backgroundColor_ = normalized; + emit transformChanged(); +} diff --git a/src/quick/mediaeditorcontroller.h b/src/quick/mediaeditorcontroller.h new file mode 100644 index 0000000..280538f --- /dev/null +++ b/src/quick/mediaeditorcontroller.h @@ -0,0 +1,123 @@ +#pragma once + +#include "mediapreviewcontroller.h" +#include "runtimecontract.h" + +#include +#include +#include + +class RuntimeClient; + +class MediaEditorController final : public QObject { + Q_OBJECT + Q_PROPERTY(bool open READ isOpen NOTIFY openChanged) + Q_PROPERTY(bool busy READ busy NOTIFY previewChanged) + Q_PROPERTY(bool submissionPending READ submissionPending + NOTIFY previewChanged) + Q_PROPERTY(bool ready READ ready NOTIFY previewChanged) + Q_PROPERTY(QString sourceName READ sourceName NOTIFY previewChanged) + Q_PROPERTY(QString sourceKind READ sourceKind NOTIFY previewChanged) + Q_PROPERTY(bool recoveredDeviceCopy READ recoveredDeviceCopy + NOTIFY previewChanged) + Q_PROPERTY(QString originalMediaName READ originalMediaName + NOTIFY previewChanged) + Q_PROPERTY(bool replaceAllowed READ replaceAllowed + NOTIFY previewChanged) + Q_PROPERTY(QString replaceBlockReason READ replaceBlockReason + NOTIFY previewChanged) + Q_PROPERTY(QString submissionAction READ submissionAction + NOTIFY previewChanged) + Q_PROPERTY(QUrl previewUrl READ previewUrl NOTIFY previewChanged) + Q_PROPERTY(QString error READ error NOTIFY previewChanged) + Q_PROPERTY(QString mode READ mode WRITE setMode NOTIFY transformChanged) + Q_PROPERTY(int zoomPercent READ zoomPercent WRITE setZoomPercent + NOTIFY transformChanged) + Q_PROPERTY(int focusX READ focusX WRITE setFocusX + NOTIFY transformChanged) + Q_PROPERTY(int focusY READ focusY WRITE setFocusY + NOTIFY transformChanged) + Q_PROPERTY(int rotation READ rotation WRITE setRotation + NOTIFY transformChanged) + Q_PROPERTY(QString backgroundColor READ backgroundColor + WRITE setBackgroundColor NOTIFY transformChanged) + Q_PROPERTY(QUrl homeFolder READ homeFolder CONSTANT) + +public: + explicit MediaEditorController(RuntimeClient *runtime, + QObject *parent = nullptr); + + bool isOpen() const; + bool busy() const; + bool submissionPending() const; + bool ready() const; + QString sourceName() const; + QString sourceKind() const; + bool recoveredDeviceCopy() const; + QString originalMediaName() const; + bool replaceAllowed() const; + QString replaceBlockReason() const; + QString submissionAction() const; + QUrl previewUrl() const; + QString error() const; + QString mode() const; + int zoomPercent() const; + int focusX() const; + int focusY() const; + int rotation() const; + QString backgroundColor() const; + QUrl homeFolder() const; + TryxRuntimeMediaTransform transform() const; + void beginRecoveredVideo( + const TryxRuntimeDeviceMediaArtifact &artifact); + void beginRecoveredSubmission( + const QString &operationId, const QString &action); + void finishRecoveredSubmission( + const QString &operationId, bool success, + const QString &message); + + Q_INVOKABLE void begin(const QUrl &source); + Q_INVOKABLE void beginDropped(const QVariantList &sources); + Q_INVOKABLE void cancel(); + Q_INVOKABLE void reset(); + Q_INVOKABLE void submit(); + Q_INVOKABLE void submitSaveAsNew(); + Q_INVOKABLE void submitReplace(); + +public slots: + void setMode(const QString &mode); + void setZoomPercent(int value); + void setFocusX(int value); + void setFocusY(int value); + void setRotation(int value); + void setBackgroundColor(const QString &value); + +signals: + void openChanged(); + void previewChanged(); + void transformChanged(); + void submitted(); + void cancelled(); + void recoveredSaveAsNewRequested( + const TryxRuntimeMediaTransform &transform); + void recoveredReplaceRequested( + const TryxRuntimeMediaTransform &transform); + void recoveredClosed(); + +private: + RuntimeClient *runtime_; + MediaPreviewController preview_; + bool open_ = false; + QString sourceName_; + QString localPath_; + QString editorError_; + QString pendingOperationId_; + TryxRuntimeDeviceMediaArtifact recoveredArtifact_; + QString recoveredSubmissionAction_; + QString mode_ = QStringLiteral("Fit"); + int zoomPercent_ = 100; + int focusX_ = 5000; + int focusY_ = 5000; + int rotation_ = 0; + QString backgroundColor_ = QStringLiteral("#000000"); +}; diff --git a/src/quick/mediapreviewcontroller.cpp b/src/quick/mediapreviewcontroller.cpp new file mode 100644 index 0000000..bf4ca82 --- /dev/null +++ b/src/quick/mediapreviewcontroller.cpp @@ -0,0 +1,1296 @@ +#include "mediapreviewcontroller.h" + +#include "mediatransform.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr qint64 kMaxPreviewSourceBytes = + qint64(8) * 1024 * 1024 * 1024; +constexpr qsizetype kMaxDiagnosticBytes = 16384; +constexpr int kPreviewDeadlineMs = 15000; +constexpr int kStagingDeadlineMs = 10 * 60 * 1000; +constexpr int kRenderDebounceMs = 160; +constexpr int kCopyBufferBytes = 1024 * 1024; +constexpr int kMaxStaleArtifactsPerSweep = 64; +constexpr qint64 kStaleInboxAgeMs = + qint64(7) * 24 * 60 * 60 * 1000; +constexpr qint64 kStalePreviewAgeMs = + qint64(24) * 60 * 60 * 1000; + +struct RegularFileIdentity { + dev_t device = 0; + ino_t inode = 0; + bool valid = false; +}; + +QPointer drainingStageProcess; + +const QRegularExpression kInboxFileName( + QStringLiteral( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-" + "[0-9a-f]{4}-[0-9a-f]{12}\\." + "(mp4|webm|mkv|avi|mov|gif|jpg|jpeg|png|bmp|webp)" + "(\\.part)?$")); +const QRegularExpression kPreviewFileName( + QStringLiteral( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-" + "[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+\\.png$")); + +QString previewDirectoryPath() { + const QString runtimePath = QStandardPaths::writableLocation( + QStandardPaths::RuntimeLocation); + if (runtimePath.isEmpty()) { + return {}; + } + return QDir(runtimePath).filePath( + QStringLiteral("tryx-panorama-manager/previews")); +} + +bool writeAll(QFile *file, const char *data, qint64 size) { + qint64 written = 0; + while (written < size) { + const qint64 result = + file->write(data + written, size - written); + if (result <= 0) { + return false; + } + written += result; + } + return true; +} + +bool privateDirectoryPathIsSafe(const QString &path) { + const QByteArray encoded = QFile::encodeName(path); + struct stat status {}; + return ::lstat(encoded.constData(), &status) == 0 && + S_ISDIR(status.st_mode) && + status.st_uid == ::geteuid() && + (status.st_mode & 07777) == S_IRWXU; +} + +bool privateRegularFilePathIsSafe(const QString &path) { + const QByteArray encoded = QFile::encodeName(path); + struct stat status {}; + return ::lstat(encoded.constData(), &status) == 0 && + S_ISREG(status.st_mode) && + status.st_uid == ::geteuid() && + (status.st_mode & 07777) == + (S_IRUSR | S_IWUSR) && + status.st_nlink == 1 && status.st_size > 0; +} + +bool regularFileIdentityMatches( + const QString &path, + const RegularFileIdentity &identity) { + if (!identity.valid) { + return false; + } + const QByteArray encoded = QFile::encodeName(path); + struct stat status {}; + return ::lstat(encoded.constData(), &status) == 0 && + S_ISREG(status.st_mode) && + status.st_dev == identity.device && + status.st_ino == identity.inode; +} + +void removeRegularFileIfIdentityMatches( + const QString &path, + const RegularFileIdentity &identity) { + if (!regularFileIdentityMatches(path, identity)) { + return; + } + const QByteArray encoded = QFile::encodeName(path); + ::unlink(encoded.constData()); +} + +void cleanupOldFiles(const QString &directoryPath, + const QRegularExpression &namePattern, + qint64 staleAgeMs) { + if (!privateDirectoryPathIsSafe(directoryPath)) { + return; + } + + const QDateTime cutoff = + QDateTime::currentDateTimeUtc().addMSecs(-staleAgeMs); + const QFileInfoList entries = + QDir(directoryPath).entryInfoList( + QDir::Files | QDir::NoDotAndDotDot, + QDir::Time | QDir::Reversed); + int inspected = 0; + for (const QFileInfo &entry : entries) { + if (++inspected > kMaxStaleArtifactsPerSweep) { + break; + } + if (entry.isSymLink() || + !namePattern.match(entry.fileName()).hasMatch() || + entry.lastModified().toUTC() >= cutoff) { + continue; + } + QFile::remove(entry.absoluteFilePath()); + } +} + +} // namespace + +MediaPreviewController::MediaPreviewController(QObject *parent) + : QObject(parent) { + cleanupStaleArtifacts(); + process_.setProcessChannelMode(QProcess::MergedChannels); + processDeadline_.setSingleShot(true); + stagingDeadline_.setSingleShot(true); + stagingDeadline_.setInterval(kStagingDeadlineMs); + renderDebounce_.setSingleShot(true); + renderDebounce_.setInterval(kRenderDebounceMs); + + connect(&renderDebounce_, &QTimer::timeout, + this, &MediaPreviewController::startPreview); + connect(&processDeadline_, &QTimer::timeout, this, [this]() { + if (activeRenderGeneration_ == 0) { + return; + } + error_ = tr("Preview generation timed out"); + process_.kill(); + }); + connect(&stagingDeadline_, &QTimer::timeout, this, [this]() { + if (!staging_) { + return; + } + staging_ = false; + activeStageGeneration_ = ++stageGeneration_; + abortStagingProcess(); + error_ = tr("Creating the private media snapshot timed out"); + emit stateChanged(); + }); + connect(&process_, &QProcess::readyRead, this, [this]() { + diagnostic_.append(process_.readAll()); + if (diagnostic_.size() > kMaxDiagnosticBytes) { + diagnostic_ = diagnostic_.right(kMaxDiagnosticBytes); + } + }); + connect( + &process_, + qOverload(&QProcess::finished), + this, &MediaPreviewController::finishProcess); + connect( + &process_, &QProcess::errorOccurred, this, + [this](QProcess::ProcessError processError) { + if (activeRenderGeneration_ != 0 && + processError == QProcess::FailedToStart) { + fail(tr("Could not start ffmpeg")); + } + }); +} + +MediaPreviewController::~MediaPreviewController() { + abortStagingProcess(); + stopPreviewWork(); + removePreviewArtifact(currentOutputPath_); + if (!sourceProtected_) { + removeOwnedStagedSource(); + } +} + +bool MediaPreviewController::busy() const { + return staging_ || renderDebounce_.isActive() || + activeRenderGeneration_ != 0 || recoveredValidation_; +} + +bool MediaPreviewController::ready() const { + return ready_ && !stagedPath_.isEmpty(); +} + +QUrl MediaPreviewController::previewUrl() const { + return previewUrl_; +} + +QString MediaPreviewController::sourcePath() const { + return stagedPath_; +} + +QString MediaPreviewController::error() const { + return error_; +} + +void MediaPreviewController::load(const QUrl &source) { + if (sourceProtected_) { + error_ = tr( + "Wait for the runtime to acknowledge the current upload request"); + emit stateChanged(); + return; + } + cancel(); + resetVisibleState(); + + if (!source.isLocalFile()) { + fail(tr("Only one local media file can be previewed")); + return; + } + const QFileInfo info(source.toLocalFile()); + if (!info.exists() || !info.isFile() || info.isSymLink()) { + fail(tr("The selected media source is not a regular file")); + return; + } + if (info.size() <= 0 || info.size() > kMaxPreviewSourceBytes) { + fail(tr("The selected media source has an unsupported size")); + return; + } + const QString suffix = info.suffix().trimmed().toLower(); + if (!isSupportedSuffix(suffix)) { + fail(tr("Unsupported media type")); + return; + } + if (QStandardPaths::findExecutable( + QStringLiteral("ffmpeg")).isEmpty()) { + fail(tr("ffmpeg was not found")); + return; + } + + const QString sourcePath = info.canonicalFilePath(); + if (sourcePath.isEmpty()) { + fail(tr("The selected media source could not be resolved")); + return; + } + startStaging( + sourcePath, suffix, info.size(), info.lastModified()); +} + +void MediaPreviewController::loadRecoveredVideo( + const TryxRuntimeDeviceMediaArtifact &artifact) { + if (sourceProtected_) { + error_ = tr( + "Wait for the runtime to acknowledge the current upload request"); + emit stateChanged(); + return; + } + cancel(); + resetVisibleState(); + recoveredValidation_ = true; + const quint64 generation = + ++recoveredValidationGeneration_; + emit stateChanged(); + + auto *watcher = + new QFutureWatcher(this); + connect( + watcher, + &QFutureWatcher::finished, + this, [this, watcher, generation]() { + const RecoveredValidationResult result = + watcher->result(); + watcher->deleteLater(); + if (generation != recoveredValidationGeneration_) { + return; + } + recoveredValidation_ = false; + if (!result.error.isEmpty()) { + fail(result.error); + return; + } + stagedPath_ = result.sourcePath; + sourceKind_ = SourceKind::RecoveredVideo; + sourceProtected_ = false; + ++requestedRenderGeneration_; + schedulePreview(); + }); + watcher->setFuture(QtConcurrent::run( + [artifact]() { + return validateRecoveredArtifact(artifact); + })); +} + +void MediaPreviewController::setTransform( + const TryxRuntimeMediaTransform &transform) { + if (!tryxMediaTransformIsValid(transform) || + tryxMediaTransformCanonicalValue(transform_) == + tryxMediaTransformCanonicalValue(transform)) { + return; + } + transform_ = transform; + ++requestedRenderGeneration_; + if (!stagedPath_.isEmpty() && !staging_ && + !sourceProtected_) { + schedulePreview(); + } +} + +void MediaPreviewController::cancel() { + if (sourceProtected_) { + error_ = tr( + "Wait for the runtime to acknowledge the current upload request"); + emit stateChanged(); + return; + } + + activeStageGeneration_ = ++stageGeneration_; + ++recoveredValidationGeneration_; + recoveredValidation_ = false; + staging_ = false; + abortStagingProcess(); + stopPreviewWork(); + removePreviewArtifact(currentOutputPath_); + currentOutputPath_.clear(); + removeOwnedStagedSource(); + stagedPath_.clear(); + sourceKind_ = SourceKind::None; + resetVisibleState(); + emit stateChanged(); +} + +bool MediaPreviewController::protectStagedSource() { + if (!ready() || sourceKind_ != SourceKind::InboxSnapshot || + !isManagedInboxPath(stagedPath_)) { + return false; + } + const QFileInfo info(stagedPath_); + if (!info.exists() || !info.isFile() || info.isSymLink()) { + return false; + } + if (!privateRegularFilePathIsSafe(stagedPath_)) { + return false; + } + sourceProtected_ = true; + stopPreviewWork(); + emit stateChanged(); + return true; +} + +void MediaPreviewController::restoreStagedSourceOwnership() { + if (!sourceProtected_) { + return; + } + sourceProtected_ = false; + if (!QFileInfo::exists(stagedPath_)) { + ready_ = false; + previewUrl_.clear(); + error_ = tr( + "The runtime rejected the upload after the staged source disappeared"); + } + emit stateChanged(); +} + +void MediaPreviewController::releaseStagedSource() { + stopPreviewWork(); + sourceProtected_ = false; + stagedPath_.clear(); + sourceKind_ = SourceKind::None; + removePreviewArtifact(currentOutputPath_); + currentOutputPath_.clear(); + resetVisibleState(); + emit stateChanged(); +} + +QString MediaPreviewController::previewFilter( + const TryxRuntimeMediaTransform &transform) { + const QString canonical = + tryxMediaTransformFfmpegFilter( + transform, kTryxMediaTargetWidth, + kTryxMediaTargetHeight); + if (canonical.isEmpty()) { + return {}; + } + return canonical + + QStringLiteral(",scale=1120:540:flags=lanczos"); +} + +int MediaPreviewController::runStageCopyHelper( + const QStringList &arguments) { + if (arguments.size() != 4) { + std::fprintf( + stderr, + "internal stage helper received invalid arguments\n"); + return 2; + } + + bool sizeValid = false; + bool modifiedValid = false; + const qint64 expectedSize = + arguments.at(2).toLongLong(&sizeValid); + const qint64 modifiedMs = + arguments.at(3).toLongLong(&modifiedValid); + const QString finalPath = arguments.at(1); + if (!sizeValid || !modifiedValid || expectedSize <= 0 || + expectedSize > kMaxPreviewSourceBytes || + !isManagedInboxPath(finalPath)) { + std::fprintf( + stderr, + "internal stage helper rejected the snapshot contract\n"); + return 2; + } + + const StageResult result = copySourceSnapshot( + arguments.at(0), finalPath, expectedSize, + QDateTime::fromMSecsSinceEpoch(modifiedMs)); + if (!result.error.isEmpty()) { + const QByteArray message = result.error.toLocal8Bit(); + std::fprintf(stderr, "%s\n", message.constData()); + return 1; + } + return 0; +} + +MediaPreviewController::StageResult +MediaPreviewController::copySourceSnapshot( + const QString &sourcePath, + const QString &finalPath, + qint64 expectedSize, + const QDateTime &expectedModified) { + StageResult result; + result.stagedPath = finalPath; + const QString partPath = finalPath + QStringLiteral(".part"); + + QFile source(sourcePath); + QFile target(partPath); + RegularFileIdentity createdIdentity; + if (!source.open(QIODevice::ReadOnly)) { + result.error = + QObject::tr("Could not open the selected media source"); + return result; + } + if (!target.open(QIODevice::WriteOnly | QIODevice::NewOnly)) { + result.error = + QObject::tr("Could not create the private media snapshot"); + return result; + } + struct stat createdStatus {}; + if (::fstat(target.handle(), &createdStatus) != 0 || + !S_ISREG(createdStatus.st_mode) || + createdStatus.st_uid != ::geteuid() || + createdStatus.st_nlink != 1) { + result.error = + QObject::tr("The private media snapshot is invalid"); + } else { + createdIdentity.device = createdStatus.st_dev; + createdIdentity.inode = createdStatus.st_ino; + createdIdentity.valid = true; + } + + QByteArray buffer(kCopyBufferBytes, Qt::Uninitialized); + qint64 copied = 0; + while (result.error.isEmpty() && copied < expectedSize) { + const qint64 remaining = expectedSize - copied; + const qint64 count = + source.read( + buffer.data(), + qMin(buffer.size(), remaining)); + if (count < 0) { + result.error = + QObject::tr("Could not read the selected media source"); + break; + } + if (count == 0) { + break; + } + if (!writeAll(&target, buffer.constData(), count)) { + result.error = + QObject::tr("Could not write the private media snapshot"); + break; + } + copied += count; + } + if (result.error.isEmpty() && !target.flush()) { + result.error = + QObject::tr("Could not flush the private media snapshot"); + } + if (result.error.isEmpty() && + ::fchmod( + target.handle(), S_IRUSR | S_IWUSR) != 0) { + result.error = + QObject::tr( + "Could not protect the private media snapshot"); + } + struct stat completedStatus {}; + if (result.error.isEmpty() && + (::fstat(target.handle(), &completedStatus) != 0 || + !S_ISREG(completedStatus.st_mode) || + completedStatus.st_uid != ::geteuid() || + (completedStatus.st_mode & 07777) != + (S_IRUSR | S_IWUSR) || + completedStatus.st_nlink != 1 || + completedStatus.st_size != expectedSize || + completedStatus.st_dev != createdIdentity.device || + completedStatus.st_ino != createdIdentity.inode)) { + result.error = + QObject::tr("The private media snapshot is invalid"); + } + target.close(); + source.close(); + + const QFileInfo currentSource(sourcePath); + if (result.error.isEmpty() && + (copied != expectedSize || !currentSource.exists() || + !currentSource.isFile() || currentSource.isSymLink() || + currentSource.size() != expectedSize || + currentSource.lastModified() != expectedModified)) { + result.error = + QObject::tr( + "The media source changed while it was being copied"); + } + + if (result.error.isEmpty() && + (!regularFileIdentityMatches( + partPath, createdIdentity) || + QFileInfo(partPath).size() != expectedSize || + !privateRegularFilePathIsSafe(partPath))) { + result.error = + QObject::tr("The private media snapshot is invalid"); + } + bool finalCreated = false; + if (result.error.isEmpty() && + !QFile::rename(partPath, finalPath)) { + result.error = + QObject::tr( + "Could not finalize the private media snapshot"); + } else if (result.error.isEmpty()) { + finalCreated = true; + } + if (result.error.isEmpty() && + (!regularFileIdentityMatches( + finalPath, createdIdentity) || + QFileInfo(finalPath).size() != expectedSize || + !privateRegularFilePathIsSafe(finalPath))) { + result.error = + QObject::tr("The private media snapshot is invalid"); + } + if (!result.error.isEmpty()) { + removeRegularFileIfIdentityMatches( + partPath, createdIdentity); + if (finalCreated) { + removeRegularFileIfIdentityMatches( + finalPath, createdIdentity); + } + } + return result; +} + +bool MediaPreviewController::ensurePrivateDirectory( + const QString &path, bool create, + QString *errorMessage) { + if (path.isEmpty()) { + if (errorMessage) { + *errorMessage = + tr("The private runtime directory path is empty"); + } + return false; + } + + const QByteArray encoded = QFile::encodeName(path); + struct stat status {}; + if (::lstat(encoded.constData(), &status) != 0) { + if (errno != ENOENT || !create || + (::mkdir(encoded.constData(), S_IRWXU) != 0 && + errno != EEXIST) || + ::lstat(encoded.constData(), &status) != 0) { + if (errorMessage) { + *errorMessage = + tr("Could not create the private runtime directory: %1") + .arg(QString::fromLocal8Bit( + std::strerror(errno))); + } + return false; + } + } + + if (create && S_ISDIR(status.st_mode) && + status.st_uid == ::geteuid() && + (status.st_mode & 07777) != S_IRWXU) { + if (::chmod(encoded.constData(), S_IRWXU) != 0 || + ::lstat(encoded.constData(), &status) != 0) { + if (errorMessage) { + *errorMessage = + tr("Could not protect the private runtime directory: %1") + .arg(QString::fromLocal8Bit( + std::strerror(errno))); + } + return false; + } + } + + if (!S_ISDIR(status.st_mode) || + status.st_uid != ::geteuid() || + (status.st_mode & 07777) != S_IRWXU) { + if (errorMessage) { + *errorMessage = + tr( + "The private runtime directory must be owned by this user with mode 0700"); + } + return false; + } + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +bool MediaPreviewController::ensurePrivateDirectoryTree( + const QString &leafPath, QString *errorMessage) { + const QString runtimeRoot = + QStandardPaths::writableLocation( + QStandardPaths::RuntimeLocation); + const QString applicationRoot = + QFileInfo(leafPath).absolutePath(); + if (runtimeRoot.isEmpty() || leafPath.isEmpty() || + QDir::cleanPath( + QFileInfo(applicationRoot).absolutePath()) != + QDir::cleanPath(runtimeRoot)) { + if (errorMessage) { + *errorMessage = + tr("The private runtime directory layout is invalid"); + } + return false; + } + return ensurePrivateDirectory( + runtimeRoot, false, errorMessage) && + ensurePrivateDirectory( + applicationRoot, true, errorMessage) && + ensurePrivateDirectory( + leafPath, true, errorMessage); +} + +bool MediaPreviewController::privateDirectoryTreeIsSafe( + const QString &leafPath) { + const QString runtimeRoot = + QStandardPaths::writableLocation( + QStandardPaths::RuntimeLocation); + const QString applicationRoot = + QFileInfo(leafPath).absolutePath(); + return !runtimeRoot.isEmpty() && !leafPath.isEmpty() && + QDir::cleanPath( + QFileInfo(applicationRoot).absolutePath()) == + QDir::cleanPath(runtimeRoot) && + privateDirectoryPathIsSafe(runtimeRoot) && + privateDirectoryPathIsSafe(applicationRoot) && + privateDirectoryPathIsSafe(leafPath); +} + +bool MediaPreviewController::isSupportedSuffix( + const QString &suffix) { + static const QSet supported{ + QStringLiteral("mp4"), QStringLiteral("webm"), + QStringLiteral("mkv"), QStringLiteral("avi"), + QStringLiteral("mov"), QStringLiteral("gif"), + QStringLiteral("jpg"), QStringLiteral("jpeg"), + QStringLiteral("png"), QStringLiteral("bmp"), + QStringLiteral("webp"), + }; + return supported.contains(suffix.trimmed().toLower()); +} + +bool MediaPreviewController::isManagedInboxPath( + const QString &path) { + const QString inboxPath = tryxRuntimeMediaInboxPath(); + if (path.isEmpty() || inboxPath.isEmpty()) { + return false; + } + if (!privateDirectoryTreeIsSafe(inboxPath)) { + return false; + } + if (!QDir::isAbsolutePath(path) || + QDir::cleanPath(path) != path) { + return false; + } + const QFileInfo info(path); + const QString normalizedInbox = + QDir::cleanPath( + QFileInfo(inboxPath).absoluteFilePath()); + const QString expectedPath = + QDir(normalizedInbox).filePath(info.fileName()); + if (path != expectedPath || + !kInboxFileName.match(info.fileName()).hasMatch()) { + return false; + } + return !info.exists() || + (info.isFile() && !info.isSymLink()); +} + +bool MediaPreviewController::isManagedRecoveredArtifactPath( + const QString &path) { + const QString outboxPath = + tryxRuntimeDeviceMediaOutboxPath(); + if (path.isEmpty() || outboxPath.isEmpty() || + !privateDirectoryTreeIsSafe(outboxPath) || + !QDir::isAbsolutePath(path) || + QDir::cleanPath(path) != path) { + return false; + } + const QFileInfo info(path); + const QString normalizedOutbox = + QDir::cleanPath( + QFileInfo(outboxPath).absoluteFilePath()); + const QString expectedPath = + QDir(normalizedOutbox).filePath(info.fileName()); + return path == expectedPath && + info.suffix().compare( + QStringLiteral("h264"), + Qt::CaseInsensitive) == 0 && + info.fileName() != QStringLiteral(".h264"); +} + +MediaPreviewController::RecoveredValidationResult +MediaPreviewController::validateRecoveredArtifact( + const TryxRuntimeDeviceMediaArtifact &artifact) { + RecoveredValidationResult result; + static const QRegularExpression sha256( + QStringLiteral("^[0-9a-f]{64}$")); + if (artifact.schemaVersion != 1U || + artifact.operationId.isEmpty() || + artifact.artifactId.isEmpty() || + artifact.mediaId.isEmpty() || + artifact.deviceIdentity.isEmpty() || + artifact.remoteName.isEmpty() || + artifact.size == 0 || + artifact.size > + static_cast(kMaxPreviewSourceBytes) || + !sha256.match(artifact.decodedSha256).hasMatch() || + artifact.logicalType != QStringLiteral("Video") || + artifact.leaseId.isEmpty() || + artifact.leaseExpiresUtcMs <= + QDateTime::currentMSecsSinceEpoch() || + !isManagedRecoveredArtifactPath(artifact.localPath)) { + result.error = tr( + "The claimed device media artifact is invalid"); + return result; + } + + const QByteArray encoded = + QFile::encodeName(artifact.localPath); + struct stat before {}; + if (::lstat(encoded.constData(), &before) != 0 || + !S_ISREG(before.st_mode) || + before.st_uid != ::geteuid() || + (before.st_mode & 07777) != + (S_IRUSR | S_IWUSR) || + before.st_nlink != 1 || + before.st_size <= 0 || + static_cast(before.st_size) != artifact.size) { + result.error = tr( + "The claimed device media artifact is not a private regular file"); + return result; + } + + QFile source(artifact.localPath); + if (!source.open(QIODevice::ReadOnly)) { + result.error = tr( + "The claimed device media artifact cannot be opened"); + return result; + } + QCryptographicHash hash(QCryptographicHash::Sha256); + QByteArray buffer(kCopyBufferBytes, Qt::Uninitialized); + quint64 readBytes = 0; + while (readBytes < artifact.size) { + const qint64 count = source.read( + buffer.data(), + qMin( + static_cast(buffer.size()), + artifact.size - readBytes)); + if (count <= 0) { + result.error = tr( + "The claimed device media artifact could not be verified"); + return result; + } + hash.addData( + QByteArrayView(buffer.constData(), count)); + readBytes += static_cast(count); + } + if (!source.atEnd()) { + result.error = tr( + "The claimed device media artifact changed during verification"); + return result; + } + source.close(); + + struct stat after {}; + if (::lstat(encoded.constData(), &after) != 0 || + after.st_dev != before.st_dev || + after.st_ino != before.st_ino || + after.st_size != before.st_size || + after.st_nlink != 1 || + (after.st_mode & 07777) != + (S_IRUSR | S_IWUSR) || + QString::fromLatin1(hash.result().toHex()) != + artifact.decodedSha256) { + result.error = tr( + "The claimed device media artifact failed integrity verification"); + return result; + } + const QString canonical = + QFileInfo(artifact.localPath).canonicalFilePath(); + if (canonical != artifact.localPath) { + result.error = tr( + "The claimed device media artifact path is not canonical"); + return result; + } + result.sourcePath = artifact.localPath; + return result; +} + +bool MediaPreviewController::stageHelperDrainInProgress() { + if (drainingStageProcess.isNull()) { + return false; + } + if (drainingStageProcess->state() != + QProcess::NotRunning) { + return true; + } + drainingStageProcess->deleteLater(); + drainingStageProcess.clear(); + return false; +} + +void MediaPreviewController::cleanupStaleArtifacts() { + const QString inboxPath = tryxRuntimeMediaInboxPath(); + if (privateDirectoryTreeIsSafe(inboxPath)) { + cleanupOldFiles( + inboxPath, kInboxFileName, kStaleInboxAgeMs); + } + const QString previews = previewDirectoryPath(); + if (privateDirectoryTreeIsSafe(previews)) { + cleanupOldFiles( + previews, kPreviewFileName, kStalePreviewAgeMs); + } +} + +void MediaPreviewController::startStaging( + const QString &sourcePath, + const QString &suffix, + qint64 expectedSize, + const QDateTime &expectedModified) { + if (stageHelperDrainInProgress()) { + fail(tr( + "The previous media snapshot helper is still stopping")); + return; + } + const QString inboxPath = tryxRuntimeMediaInboxPath(); + QString directoryError; + if (inboxPath.isEmpty()) { + fail(tr("The per-user runtime directory is unavailable")); + return; + } + if (!ensurePrivateDirectoryTree( + inboxPath, &directoryError)) { + fail(directoryError); + return; + } + + const QString fileName = + QStringLiteral("%1.%2") + .arg(QUuid::createUuid().toString( + QUuid::WithoutBraces), + suffix); + const QString finalPath = + QDir(inboxPath).filePath(fileName); + pendingStagePath_ = finalPath; + const quint64 generation = ++stageGeneration_; + activeStageGeneration_ = generation; + const QString helperProgram = stageCopyProgram_.isEmpty() + ? QCoreApplication::applicationFilePath() + : stageCopyProgram_; + if (helperProgram.isEmpty()) { + removePendingStageArtifacts(); + fail(tr("The media snapshot helper is unavailable")); + return; + } + + auto *process = new QProcess(this); + stageProcess_ = process; + stagingDiagnostic_.clear(); + process->setProcessChannelMode(QProcess::MergedChannels); + staging_ = true; + emit stateChanged(); + stagingDeadline_.start(); + + connect(process, &QProcess::readyRead, this, + [this, process]() { + if (process != stageProcess_) { + return; + } + stagingDiagnostic_.append(process->readAll()); + if (stagingDiagnostic_.size() > + kMaxDiagnosticBytes) { + stagingDiagnostic_ = + stagingDiagnostic_.right( + kMaxDiagnosticBytes); + } + }); + connect( + process, &QProcess::errorOccurred, this, + [this, process, generation]( + QProcess::ProcessError processError) { + if (processError == QProcess::FailedToStart) { + failStagingProcess( + process, generation, + tr("Could not start the media snapshot helper: %1") + .arg(process->errorString())); + } + }); + connect( + process, + qOverload(&QProcess::finished), + this, + [this, process, generation, sourcePath, finalPath, + expectedSize, expectedModified]( + int exitCode, QProcess::ExitStatus status) { + finishStagingProcess( + process, generation, sourcePath, finalPath, + expectedSize, expectedModified, + exitCode, status); + }); + process->start( + helperProgram, + { + QStringLiteral("--internal-stage-copy"), + sourcePath, + finalPath, + QString::number(expectedSize), + QString::number( + expectedModified.toMSecsSinceEpoch()), + }); +} + +void MediaPreviewController::finishStagingProcess( + QProcess *process, quint64 generation, + const QString &sourcePath, + const QString &finalPath, + qint64 expectedSize, + const QDateTime &expectedModified, + int exitCode, QProcess::ExitStatus status) { + if (process != stageProcess_ || + generation != activeStageGeneration_) { + process->deleteLater(); + return; + } + + stagingDeadline_.stop(); + stageProcess_ = nullptr; + staging_ = false; + stagingDiagnostic_.append(process->readAll()); + if (stagingDiagnostic_.size() > kMaxDiagnosticBytes) { + stagingDiagnostic_ = + stagingDiagnostic_.right(kMaxDiagnosticBytes); + } + process->disconnect(this); + process->deleteLater(); + + if (status != QProcess::NormalExit || exitCode != 0) { + QString detail = + QString::fromLocal8Bit( + stagingDiagnostic_).trimmed(); + if (detail.size() > 1000) { + detail = detail.right(1000); + } + removePendingStageArtifacts(); + fail( + detail.isEmpty() + ? tr("The media snapshot helper failed") + : tr("The media snapshot helper failed: %1") + .arg(detail)); + return; + } + + const QFileInfo source(sourcePath); + const QFileInfo staged(finalPath); + if (!source.exists() || !source.isFile() || + source.isSymLink() || source.size() != expectedSize || + source.lastModified() != expectedModified || + !staged.exists() || !staged.isFile() || + staged.isSymLink() || staged.size() != expectedSize || + !privateRegularFilePathIsSafe(finalPath)) { + removePendingStageArtifacts(); + fail(tr("The private media snapshot is invalid")); + return; + } + + pendingStagePath_.clear(); + stagedPath_ = finalPath; + sourceKind_ = SourceKind::InboxSnapshot; + sourceProtected_ = false; + ++requestedRenderGeneration_; + schedulePreview(); +} + +void MediaPreviewController::failStagingProcess( + QProcess *process, quint64 generation, + const QString &message) { + if (process != stageProcess_ || + generation != activeStageGeneration_) { + process->deleteLater(); + return; + } + stagingDeadline_.stop(); + stageProcess_ = nullptr; + staging_ = false; + process->disconnect(this); + process->deleteLater(); + removePendingStageArtifacts(); + fail(message); +} + +void MediaPreviewController::abortStagingProcess() { + stagingDeadline_.stop(); + QProcess *process = stageProcess_; + stageProcess_ = nullptr; + if (process) { + process->disconnect(this); + if (process->state() != QProcess::NotRunning) { + process->kill(); + } + if (process->state() == QProcess::NotRunning) { + process->deleteLater(); + } else { + // A source filesystem can leave a child blocked in kernel I/O. + // Detaching the already-killed helper keeps GUI teardown bounded; + // the OS reaps it when the syscall returns or the GUI exits. + process->setParent(nullptr); + drainingStageProcess = process; + connect( + process, + qOverload( + &QProcess::finished), + process, + [process](int, QProcess::ExitStatus) { + if (drainingStageProcess == process) { + drainingStageProcess.clear(); + } + process->deleteLater(); + }); + connect( + process, &QObject::destroyed, + [](QObject *object) { + if (drainingStageProcess.data() == object) { + drainingStageProcess.clear(); + } + }); + } + } + removePendingStageArtifacts(); +} + +void MediaPreviewController::schedulePreview() { + stopProcess(); + removePreviewArtifact(pendingOutputPath_); + pendingOutputPath_.clear(); + renderDebounce_.start(); + error_.clear(); + emit stateChanged(); +} + +void MediaPreviewController::startPreview() { + if (stagedPath_.isEmpty() || sourceProtected_) { + return; + } + const QString ffmpeg = QStandardPaths::findExecutable( + QStringLiteral("ffmpeg")); + if (ffmpeg.isEmpty()) { + fail(tr("ffmpeg was not found")); + return; + } + const QString filter = previewFilter(transform_); + if (filter.isEmpty()) { + fail(tr("The selected media transform is invalid")); + return; + } + + const QString previews = previewDirectoryPath(); + QString directoryError; + if (previews.isEmpty()) { + fail(tr("The per-user runtime directory is unavailable")); + return; + } + if (!ensurePrivateDirectoryTree( + previews, &directoryError)) { + fail(directoryError); + return; + } + + activeRenderGeneration_ = requestedRenderGeneration_; + pendingOutputPath_ = + QDir(previews).filePath( + QStringLiteral("%1-%2.png") + .arg(QUuid::createUuid().toString( + QUuid::WithoutBraces)) + .arg(activeRenderGeneration_)); + diagnostic_.clear(); + error_.clear(); + process_.setProgram(ffmpeg); + process_.setArguments({ + QStringLiteral("-nostdin"), + QStringLiteral("-hide_banner"), + QStringLiteral("-loglevel"), + QStringLiteral("error"), + QStringLiteral("-y"), + QStringLiteral("-i"), + stagedPath_, + QStringLiteral("-map"), + QStringLiteral("0:v:0"), + QStringLiteral("-frames:v"), + QStringLiteral("1"), + QStringLiteral("-vf"), + filter, + pendingOutputPath_, + }); + process_.start(); + processDeadline_.start(kPreviewDeadlineMs); + emit stateChanged(); +} + +void MediaPreviewController::finishProcess( + int exitCode, QProcess::ExitStatus status) { + if (activeRenderGeneration_ == 0) { + return; + } + processDeadline_.stop(); + diagnostic_.append(process_.readAll()); + if (diagnostic_.size() > kMaxDiagnosticBytes) { + diagnostic_ = diagnostic_.right(kMaxDiagnosticBytes); + } + const quint64 finishedGeneration = + activeRenderGeneration_; + activeRenderGeneration_ = 0; + + if (finishedGeneration != requestedRenderGeneration_) { + removePreviewArtifact(pendingOutputPath_); + pendingOutputPath_.clear(); + emit stateChanged(); + return; + } + if (status != QProcess::NormalExit || exitCode != 0) { + QString detail = + QString::fromLocal8Bit(diagnostic_).trimmed(); + if (detail.size() > 1000) { + detail = detail.right(1000); + } + const QString fallback = + tr("ffmpeg could not decode the transformed preview"); + fail( + error_.isEmpty() + ? (detail.isEmpty() + ? fallback + : tr("%1: %2").arg(fallback, detail)) + : error_); + return; + } + finishPreview(); +} + +void MediaPreviewController::finishPreview() { + QFileInfo output(pendingOutputPath_); + const QFileDevice::Permissions privateFilePermissions = + QFileDevice::ReadOwner | QFileDevice::WriteOwner; + if (!output.exists() || !output.isFile() || + output.isSymLink() || output.size() <= 0 || + !QFile::setPermissions( + output.absoluteFilePath(), privateFilePermissions)) { + fail(tr("The transformed preview frame is invalid")); + return; + } + output.refresh(); + if (!privateRegularFilePathIsSafe( + output.absoluteFilePath())) { + fail(tr("The transformed preview frame is invalid")); + return; + } + + const QString previousOutput = currentOutputPath_; + currentOutputPath_ = pendingOutputPath_; + pendingOutputPath_.clear(); + ready_ = true; + error_.clear(); + previewUrl_ = QUrl::fromLocalFile(currentOutputPath_); + previewUrl_.setQuery( + QStringLiteral("generation=%1") + .arg(requestedRenderGeneration_)); + emit stateChanged(); + removePreviewArtifact(previousOutput); +} + +void MediaPreviewController::fail(const QString &message) { + stagingDeadline_.stop(); + processDeadline_.stop(); + renderDebounce_.stop(); + stopProcess(); + removePreviewArtifact(pendingOutputPath_); + pendingOutputPath_.clear(); + removePreviewArtifact(currentOutputPath_); + currentOutputPath_.clear(); + ready_ = false; + previewUrl_.clear(); + error_ = message; + emit stateChanged(); +} + +void MediaPreviewController::stopProcess() { + processDeadline_.stop(); + activeRenderGeneration_ = 0; + if (process_.state() != QProcess::NotRunning) { + process_.kill(); + process_.waitForFinished(1000); + } +} + +void MediaPreviewController::stopPreviewWork() { + renderDebounce_.stop(); + stopProcess(); + removePreviewArtifact(pendingOutputPath_); + pendingOutputPath_.clear(); +} + +void MediaPreviewController::resetVisibleState() { + ready_ = false; + previewUrl_.clear(); + error_.clear(); + diagnostic_.clear(); +} + +void MediaPreviewController::removeOwnedStagedSource() { + if (sourceProtected_ || + sourceKind_ != SourceKind::InboxSnapshot || + !isManagedInboxPath(stagedPath_)) { + return; + } + QFile::remove(stagedPath_); + QFile::remove(stagedPath_ + QStringLiteral(".part")); +} + +void MediaPreviewController::removePendingStageArtifacts() { + // The helper removes only the inode it created. The parent deliberately + // does not unlink by path because a failed no-replace rename may mean the + // final name belongs to somebody else. A helper killed mid-copy can leave + // a UUID .part file, which the bounded stale-artifact sweep removes later. + pendingStagePath_.clear(); +} + +void MediaPreviewController::removePreviewArtifact( + const QString &path) { + const QString previews = previewDirectoryPath(); + if (path.isEmpty() || previews.isEmpty() || + !privateDirectoryTreeIsSafe(previews)) { + return; + } + const QFileInfo info(path); + if (QDir::cleanPath(info.absolutePath()) != + QDir::cleanPath(previews) || + !kPreviewFileName.match(info.fileName()).hasMatch() || + (info.exists() && + (!info.isFile() || info.isSymLink()))) { + return; + } + QFile::remove(path); +} diff --git a/src/quick/mediapreviewcontroller.h b/src/quick/mediapreviewcontroller.h new file mode 100644 index 0000000..62e965f --- /dev/null +++ b/src/quick/mediapreviewcontroller.h @@ -0,0 +1,144 @@ +#pragma once + +#include "runtimecontract.h" + +#include +#include +#include +#include +#include + +class QuickClientTests; + +class MediaPreviewController final : public QObject { + Q_OBJECT + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(bool ready READ ready NOTIFY stateChanged) + Q_PROPERTY(QUrl previewUrl READ previewUrl NOTIFY stateChanged) + Q_PROPERTY(QString sourcePath READ sourcePath NOTIFY stateChanged) + Q_PROPERTY(QString error READ error NOTIFY stateChanged) + +public: + enum class SourceKind { + None, + InboxSnapshot, + RecoveredVideo + }; + Q_ENUM(SourceKind) + + explicit MediaPreviewController(QObject *parent = nullptr); + ~MediaPreviewController() override; + + bool busy() const; + bool ready() const; + QUrl previewUrl() const; + QString sourcePath() const; + QString error() const; + + void load(const QUrl &source); + void loadRecoveredVideo( + const TryxRuntimeDeviceMediaArtifact &artifact); + void setTransform(const TryxRuntimeMediaTransform &transform); + Q_INVOKABLE void cancel(); + + // The runtime atomically claims the inbox file before acknowledging an + // upload. While a request is in flight, the GUI must leave the source in + // place even if it exits. + bool protectStagedSource(); + void restoreStagedSourceOwnership(); + void releaseStagedSource(); + + static QString previewFilter( + const TryxRuntimeMediaTransform &transform); + // Internal process boundary used by the GUI executable and its tests. + static int runStageCopyHelper(const QStringList &arguments); + +signals: + void stateChanged(); + +private: + friend class QuickClientTests; + + struct StageResult { + QString stagedPath; + QString error; + }; + struct RecoveredValidationResult { + QString sourcePath; + QString error; + }; + + static StageResult copySourceSnapshot( + const QString &sourcePath, + const QString &finalPath, + qint64 expectedSize, + const QDateTime &expectedModified); + static bool ensurePrivateDirectory( + const QString &path, bool create, + QString *errorMessage); + static bool ensurePrivateDirectoryTree( + const QString &leafPath, QString *errorMessage); + static bool privateDirectoryTreeIsSafe( + const QString &leafPath); + static bool isSupportedSuffix(const QString &suffix); + static bool isManagedInboxPath(const QString &path); + static bool isManagedRecoveredArtifactPath( + const QString &path); + static RecoveredValidationResult validateRecoveredArtifact( + const TryxRuntimeDeviceMediaArtifact &artifact); + static bool stageHelperDrainInProgress(); + static void cleanupStaleArtifacts(); + + void startStaging(const QString &sourcePath, + const QString &suffix, + qint64 expectedSize, + const QDateTime &expectedModified); + void finishStagingProcess( + QProcess *process, quint64 generation, + const QString &sourcePath, + const QString &finalPath, + qint64 expectedSize, + const QDateTime &expectedModified, + int exitCode, QProcess::ExitStatus status); + void failStagingProcess( + QProcess *process, quint64 generation, + const QString &message); + void abortStagingProcess(); + void schedulePreview(); + void startPreview(); + void finishProcess(int exitCode, QProcess::ExitStatus status); + void finishPreview(); + void fail(const QString &message); + void stopProcess(); + void stopPreviewWork(); + void resetVisibleState(); + void removeOwnedStagedSource(); + void removePendingStageArtifacts(); + void removePreviewArtifact(const QString &path); + + QProcess process_; + QTimer processDeadline_; + QTimer stagingDeadline_; + QTimer renderDebounce_; + QByteArray diagnostic_; + QString pendingStagePath_; + QString stagedPath_; + QString pendingOutputPath_; + QString currentOutputPath_; + QUrl previewUrl_; + QString error_; + TryxRuntimeMediaTransform transform_; + QProcess *stageProcess_ = nullptr; + QString stageCopyProgram_; + QByteArray stagingDiagnostic_; + quint64 stageGeneration_ = 0; + quint64 activeStageGeneration_ = 0; + quint64 requestedRenderGeneration_ = 0; + quint64 activeRenderGeneration_ = 0; + bool staging_ = false; + bool recoveredValidation_ = false; + bool ready_ = false; + bool sourceProtected_ = false; + quint64 recoveredValidationGeneration_ = 0; + SourceKind sourceKind_ = SourceKind::None; +}; diff --git a/src/quick/operationlistmodel.cpp b/src/quick/operationlistmodel.cpp new file mode 100644 index 0000000..47e76ae --- /dev/null +++ b/src/quick/operationlistmodel.cpp @@ -0,0 +1,157 @@ +#include "operationlistmodel.h" + +#include + +OperationListModel::OperationListModel(QObject *parent) + : QAbstractListModel(parent) {} + +int OperationListModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : operations_.size(); +} + +QVariant OperationListModel::data(const QModelIndex &index, int role) const { + if (!index.isValid() || index.row() < 0 || + index.row() >= operations_.size()) { + return {}; + } + const TryxRuntimeOperationInfo &info = operations_.at(index.row()); + switch (role) { + case Qt::DisplayRole: + case SubjectRole: + return info.subject; + case IdRole: + return info.id; + case ParentIdRole: + return info.parentId; + case KindRole: + return info.kind; + case StateRole: + return info.state; + case StageRole: + return info.stage; + case ErrorCategoryRole: + return info.errorCategory; + case MessageRole: + return info.message; + case CompletedRole: + return info.completed; + case TotalRole: + return info.total; + case ProgressRole: + return info.total > 0 + ? qBound(0.0, + static_cast(info.completed) / + static_cast(info.total), + 1.0) + : 0.0; + case RetryModeRole: + return info.retryMode; + case CanRetryRole: + return info.state == QStringLiteral("RetryAvailable"); + case TerminalRole: + return isTerminal(info); + default: + return {}; + } +} + +QHash OperationListModel::roleNames() const { + return { + {IdRole, "operationId"}, + {ParentIdRole, "parentId"}, + {KindRole, "kind"}, + {StateRole, "operationState"}, + {StageRole, "stage"}, + {ErrorCategoryRole, "errorCategory"}, + {SubjectRole, "subject"}, + {MessageRole, "message"}, + {CompletedRole, "completed"}, + {TotalRole, "total"}, + {ProgressRole, "progress"}, + {RetryModeRole, "retryMode"}, + {CanRetryRole, "canRetry"}, + {TerminalRole, "terminal"}, + }; +} + +quint64 OperationListModel::revision() const { + return revision_; +} + +bool OperationListModel::applySnapshot( + const TryxRuntimeOperationsSnapshot &snapshot) { + if (snapshot.revision <= revision_ && revision_ != 0) { + return false; + } + beginResetModel(); + revision_ = snapshot.revision; + operations_ = snapshot.operations; + endResetModel(); + emit revisionChanged(); + return true; +} + +bool OperationListModel::upsert(const TryxRuntimeOperationInfo &info, + quint64 revision) { + if (revision <= revision_ && revision_ != 0) { + return false; + } + revision_ = revision; + const int existing = indexOf(info.id); + if (existing < 0) { + beginInsertRows(QModelIndex(), operations_.size(), + operations_.size()); + operations_.append(info); + endInsertRows(); + } else { + operations_[existing] = info; + emit dataChanged(index(existing), index(existing)); + } + emit revisionChanged(); + return true; +} + +bool OperationListModel::remove(const QString &operationId, + quint64 revision) { + if (revision <= revision_ && revision_ != 0) { + return false; + } + revision_ = revision; + const int existing = indexOf(operationId); + if (existing >= 0) { + beginRemoveRows(QModelIndex(), existing, existing); + operations_.removeAt(existing); + endRemoveRows(); + } + emit revisionChanged(); + return true; +} + +void OperationListModel::clear() { + if (operations_.isEmpty() && revision_ == 0) { + return; + } + beginResetModel(); + operations_.clear(); + revision_ = 0; + endResetModel(); + emit revisionChanged(); +} + +bool OperationListModel::isTerminal( + const TryxRuntimeOperationInfo &info) { + return info.state == QStringLiteral("Succeeded") || + info.state == QStringLiteral("Completed") || + info.state == QStringLiteral("Failed") || + info.state == QStringLiteral("Cancelled") || + info.state == QStringLiteral("RetryAvailable"); +} + +int OperationListModel::indexOf(const QString &operationId) const { + for (int index = 0; index < operations_.size(); ++index) { + if (operations_.at(index).id == operationId) { + return index; + } + } + return -1; +} diff --git a/src/quick/operationlistmodel.h b/src/quick/operationlistmodel.h new file mode 100644 index 0000000..284e4a4 --- /dev/null +++ b/src/quick/operationlistmodel.h @@ -0,0 +1,53 @@ +#pragma once + +#include "runtimecontract.h" + +#include + +class OperationListModel final : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(quint64 revision READ revision NOTIFY revisionChanged) + +public: + enum Role { + IdRole = Qt::UserRole + 1, + ParentIdRole, + KindRole, + StateRole, + StageRole, + ErrorCategoryRole, + SubjectRole, + MessageRole, + CompletedRole, + TotalRole, + ProgressRole, + RetryModeRole, + CanRetryRole, + TerminalRole + }; + Q_ENUM(Role) + + explicit OperationListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + quint64 revision() const; + bool applySnapshot(const TryxRuntimeOperationsSnapshot &snapshot); + bool upsert(const TryxRuntimeOperationInfo &info, quint64 revision); + bool remove(const QString &operationId, quint64 revision); + void clear(); + + static bool isTerminal(const TryxRuntimeOperationInfo &info); + +signals: + void revisionChanged(); + +private: + int indexOf(const QString &operationId) const; + + quint64 revision_ = 0; + QList operations_; +}; diff --git a/src/quick/runtimebootstrap.cpp b/src/quick/runtimebootstrap.cpp new file mode 100644 index 0000000..7903dbf --- /dev/null +++ b/src/quick/runtimebootstrap.cpp @@ -0,0 +1,346 @@ +#include "runtimebootstrap.h" + +#include "runtimecontract.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quickbootstrap { +namespace { + +constexpr int kDbusCallTimeoutMs = 2000; +constexpr int kRuntimeStartupTimeoutMs = 8000; + +bool runtimeServiceIsRegistered() { + const QDBusConnection bus = QDBusConnection::sessionBus(); + return bus.isConnected() && bus.interface() && + bus.interface()->isServiceRegistered( + tryxRuntimeServiceName()); +} + +bool runtimeServiceApiCompatible(QString *errorMessage) { + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), + QDBusConnection::sessionBus()); + runtime.setTimeout(kDbusCallTimeoutMs); + const QDBusReply reply = + runtime.call(QStringLiteral("GetRuntimeApiVersion")); + if (!reply.isValid()) { + if (errorMessage) { + *errorMessage = reply.error().message(); + } + return false; + } + if (reply.value() != tryxRuntimeApiVersion()) { + if (errorMessage) { + *errorMessage = QObject::tr( + "The running TRYX runtime uses API %1, but this client requires API %2") + .arg(reply.value()) + .arg(tryxRuntimeApiVersion()); + } + return false; + } + return true; +} + +bool runtimeHasActiveOperation(bool *active, + QString *errorMessage) { + if (active) { + *active = false; + } + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), + QDBusConnection::sessionBus()); + runtime.setTimeout(kDbusCallTimeoutMs); + const QDBusReply reply = + runtime.call(QStringLiteral("GetActiveOperation")); + if (!reply.isValid()) { + if (errorMessage) { + *errorMessage = reply.error().message(); + } + return false; + } + if (active) { + *active = !reply.value().id.isEmpty(); + } + return true; +} + +bool waitForRuntimeService(int timeoutMs, + QString *errorMessage) { + if (runtimeServiceIsRegistered()) { + return true; + } + const QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + if (errorMessage) { + *errorMessage = + QObject::tr("The user D-Bus session is unavailable"); + } + return false; + } + + QEventLoop loop; + QTimer deadline; + deadline.setSingleShot(true); + QDBusServiceWatcher watcher( + tryxRuntimeServiceName(), bus, + QDBusServiceWatcher::WatchForRegistration); + QObject::connect( + &watcher, &QDBusServiceWatcher::serviceRegistered, + &loop, &QEventLoop::quit); + QObject::connect(&deadline, &QTimer::timeout, + &loop, &QEventLoop::quit); + deadline.start(qMax(1, timeoutMs)); + loop.exec(); + if (runtimeServiceIsRegistered()) { + return true; + } + if (errorMessage) { + *errorMessage = QObject::tr( + "TRYX background runtime did not acquire its D-Bus name before the startup deadline"); + } + return false; +} + +bool controlRuntimeThroughSystemd( + const QString &action, bool *unitMissing, + QString *errorMessage) { + if (unitMissing) { + *unitMissing = false; + } + QProcess process; + process.setProcessChannelMode(QProcess::MergedChannels); + process.start( + QStringLiteral("systemctl"), + {QStringLiteral("--user"), action, + QStringLiteral("tryx-panorama.service")}); + if (!process.waitForStarted(2000)) { + if (unitMissing) { + *unitMissing = true; + } + if (errorMessage) { + *errorMessage = QObject::tr( + "Failed to start systemctl: %1") + .arg(process.errorString()); + } + return false; + } + if (!process.waitForFinished(kRuntimeStartupTimeoutMs)) { + process.kill(); + process.waitForFinished(1000); + if (errorMessage) { + *errorMessage = QObject::tr( + "systemctl did not finish the TRYX runtime action before the deadline"); + } + return false; + } + const QString output = + QString::fromLocal8Bit(process.readAll()).trimmed(); + if (process.exitStatus() == QProcess::NormalExit && + process.exitCode() == 0) { + return true; + } + const bool missing = + output.contains(QStringLiteral("not found"), + Qt::CaseInsensitive) || + output.contains(QStringLiteral("not be found"), + Qt::CaseInsensitive) || + output.contains(QStringLiteral("not loaded"), + Qt::CaseInsensitive); + if (unitMissing) { + *unitMissing = missing; + } + if (errorMessage) { + *errorMessage = output.isEmpty() + ? QObject::tr("systemctl failed with exit code %1") + .arg(process.exitCode()) + : output; + } + return false; +} + +bool startDevelopmentRuntime(QString *errorMessage) { + const QDir quickBinaryDirectory( + QCoreApplication::applicationDirPath()); + const QString buildSibling = + QDir(quickBinaryDirectory.absolutePath()) + .absoluteFilePath( + QStringLiteral("../runtime/tryx-panorama-runtime")); + const QString installedRuntime = + QStringLiteral( + "/usr/lib/tryx-panorama-manager/tryx-panorama-runtime"); + QString executable; + for (const QString &candidate : + {buildSibling, installedRuntime}) { + const QFileInfo info(candidate); + if (info.exists() && info.isFile() && + !info.isSymLink() && info.isExecutable()) { + executable = info.canonicalFilePath(); + break; + } + } + if (executable.isEmpty()) { + executable = QStandardPaths::findExecutable( + QStringLiteral("tryx-panorama-runtime")); + } + if (executable.isEmpty() || + !QProcess::startDetached(executable, {})) { + if (errorMessage) { + *errorMessage = QObject::tr( + "The systemd unit is not installed and the development runtime could not be started"); + } + return false; + } + return true; +} + +} // namespace + +QString instanceSocketPath() { + const QString runtimeDir = + QStandardPaths::writableLocation( + QStandardPaths::RuntimeLocation); + const QString baseDir = runtimeDir.isEmpty() + ? QDir::tempPath() + : runtimeDir; + return QDir(baseDir).filePath( + QStringLiteral("tryx-panorama-manager.instance")); +} + +bool notifyRunningInstance(const QString &path) { + QLocalSocket probe; + probe.connectToServer(path); + if (!probe.waitForConnected(300)) { + return false; + } + probe.write("show"); + probe.flush(); + probe.waitForBytesWritten(300); + return true; +} + +bool listenForSingleInstance(QLocalServer *server, + const QString &path, + QString *errorMessage) { + if (!server) { + return false; + } + QLocalServer::removeServer(path); + if (server->listen(path)) { + return true; + } + if (errorMessage) { + *errorMessage = server->errorString(); + } + return false; +} + +bool ensureRuntimeService(QString *errorMessage) { + registerTryxRuntimeMetaTypes(); + if (runtimeServiceIsRegistered()) { + QString compatibilityError; + if (runtimeServiceApiCompatible(&compatibilityError)) { + return true; + } + + bool active = false; + QString operationError; + if (!runtimeHasActiveOperation(&active, &operationError)) { + if (errorMessage) { + *errorMessage = QObject::tr( + "An incompatible TRYX runtime is already running and its operation state could not be verified: %1") + .arg(operationError); + } + return false; + } + if (active) { + if (errorMessage) { + *errorMessage = QObject::tr( + "The installed TRYX runtime must be restarted, but a media operation is still active"); + } + return false; + } + + bool unitMissing = false; + QString restartError; + if (!controlRuntimeThroughSystemd( + QStringLiteral("restart"), &unitMissing, + &restartError)) { + if (errorMessage) { + *errorMessage = unitMissing + ? QObject::tr( + "The running TRYX runtime is incompatible and the systemd user unit is not installed") + : restartError; + } + return false; + } + QString waitError; + if (!waitForRuntimeService( + kRuntimeStartupTimeoutMs, &waitError)) { + if (errorMessage) { + *errorMessage = waitError; + } + return false; + } + if (!runtimeServiceApiCompatible(&compatibilityError)) { + if (errorMessage) { + *errorMessage = QObject::tr( + "The TRYX runtime remained incompatible after restart: %1") + .arg(compatibilityError); + } + return false; + } + return true; + } + + bool unitMissing = false; + QString systemdError; + const bool systemdStarted = controlRuntimeThroughSystemd( + QStringLiteral("start"), &unitMissing, &systemdError); + if (!systemdStarted && !unitMissing) { + if (errorMessage) { + *errorMessage = systemdError; + } + return false; + } + if (!systemdStarted && + !startDevelopmentRuntime(errorMessage)) { + return false; + } + + QString waitError; + if (!waitForRuntimeService( + kRuntimeStartupTimeoutMs, &waitError)) { + if (errorMessage) { + *errorMessage = waitError; + } + return false; + } + QString compatibilityError; + if (!runtimeServiceApiCompatible(&compatibilityError)) { + if (errorMessage) { + *errorMessage = QObject::tr( + "The TRYX runtime started, but its API is incompatible: %1") + .arg(compatibilityError); + } + return false; + } + return true; +} + +} // namespace quickbootstrap diff --git a/src/quick/runtimebootstrap.h b/src/quick/runtimebootstrap.h new file mode 100644 index 0000000..30c64ae --- /dev/null +++ b/src/quick/runtimebootstrap.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +class QLocalServer; + +namespace quickbootstrap { + +QString instanceSocketPath(); +bool notifyRunningInstance(const QString &path); +bool listenForSingleInstance(QLocalServer *server, + const QString &path, + QString *errorMessage); +bool ensureRuntimeService(QString *errorMessage); + +} // namespace quickbootstrap diff --git a/src/quick/runtimeclient.cpp b/src/quick/runtimeclient.cpp new file mode 100644 index 0000000..a7ae30c --- /dev/null +++ b/src/quick/runtimeclient.cpp @@ -0,0 +1,2155 @@ +#include "runtimeclient.h" + +#include "mediatransform.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr int kRuntimeCallTimeoutMs = 5000; +constexpr int kLegacyUploadTimeoutMs = 15 * 60 * 1000; + +bool isPlayMode(const QString &value, bool split) { + return value == QStringLiteral("Single") || + (!split && + (value == QStringLiteral("Loop") || + value == QStringLiteral("Shuffle"))); +} + +QString normalizedColor(const QString &value, + const QString &fallback) { + const QColor color(value); + return color.isValid() + ? color.name(QColor::HexRgb) + : fallback; +} + +} // namespace + +RuntimeClient::RuntimeClient(bool offline, QObject *parent) + : QObject(parent), + bus_(offline + ? QDBusConnection( + QStringLiteral( + "tryx-panorama-manager-offline")) + : QDBusConnection::sessionBus()), + serviceWatcher_( + tryxRuntimeServiceName(), bus_, + QDBusServiceWatcher::WatchForRegistration | + QDBusServiceWatcher::WatchForUnregistration, + this), + mediaModel_(this), + operationModel_(this), + offline_(offline) { + legacyUploadDeadline_.setSingleShot(true); + legacyUploadDeadline_.setInterval(kLegacyUploadTimeoutMs); + connect(&legacyUploadDeadline_, &QTimer::timeout, + this, &RuntimeClient::onLegacyUploadTimeout); + if (offline) { + return; + } + registerTryxRuntimeMetaTypes(); + connect(&serviceWatcher_, &QDBusServiceWatcher::serviceRegistered, + this, &RuntimeClient::onServiceRegistered); + connect(&serviceWatcher_, &QDBusServiceWatcher::serviceUnregistered, + this, &RuntimeClient::onServiceUnregistered); + subscribeSignals(); + + if (!bus_.isConnected() || !bus_.interface()) { + setDiagnostic(tr("The D-Bus session bus is unavailable")); + return; + } + serviceAvailable_ = + bus_.interface() + ->isServiceRegistered(tryxRuntimeServiceName()); + if (serviceAvailable_) { + startHandshake(); + } +} + +bool RuntimeClient::serviceAvailable() const { + return serviceAvailable_; +} + +bool RuntimeClient::compatible() const { + return compatible_; +} + +bool RuntimeClient::connected() const { + return connection_.connected; +} + +bool RuntimeClient::ready() const { + return serviceAvailable_ && compatible_ && + (legacyConnected() || + (connection_.printerClassDevicePresent && + connection_.displaySessionActive)); +} + +bool RuntimeClient::legacyConnected() const { + return connection_.connected && + !connection_.printerClassConnected && + !connection_.printerClassDevicePresent; +} + +bool RuntimeClient::printerClassDevicePresent() const { + return connection_.printerClassDevicePresent; +} + +bool RuntimeClient::displaySessionActive() const { + return connection_.displaySessionActive; +} + +QString RuntimeClient::connectionStatus() const { + if (!serviceAvailable_) { + return tr("Runtime service is not running"); + } + if (!compatible_) { + return tr("Runtime API is incompatible"); + } + if (legacyConnected()) { + return tr("Legacy serial/ADB device is connected"); + } + if (!connection_.printerClassDevicePresent) { + return tr("PASE printer-class device is not present"); + } + if (!connection_.displaySessionActive) { + return tr("PASE is present, but the display session is not ready"); + } + return tr("PASE display session is active"); +} + +QString RuntimeClient::diagnostic() const { + return diagnostic_; +} + +quint32 RuntimeClient::apiVersion() const { + return apiVersion_; +} + +quint32 RuntimeClient::expectedApiVersion() const { + return tryxRuntimeApiVersion(); +} + +MediaCatalogModel *RuntimeClient::mediaModel() { + return &mediaModel_; +} + +OperationListModel *RuntimeClient::operationModel() { + return &operationModel_; +} + +bool RuntimeClient::operationBusy() const { + return !activeOperationId_.isEmpty() || + legacyUpload_.active(); +} + +QString RuntimeClient::activeOperationId() const { + return legacyUpload_.active() + ? legacyUpload_.operationId + : activeOperationId_; +} + +QString RuntimeClient::operationSummary() const { + if (legacyUpload_.active()) { + return legacyUpload_.rejectionEmitted + ? tr("Legacy upload timed out; waiting for the device worker to release the protected source") + : tr("Uploading media to the legacy device"); + } + if (activeOperation_.id.isEmpty()) { + return {}; + } + QString title = activeOperation_.subject.trimmed(); + if (title.isEmpty()) { + title = activeOperation_.kind; + } + QString detail = activeOperation_.message.trimmed(); + if (detail.isEmpty()) { + detail = activeOperation_.stage; + } + return detail.isEmpty() ? title + : tr("%1: %2").arg(title, detail); +} + +double RuntimeClient::operationProgress() const { + if (legacyUpload_.active()) { + return 0.0; + } + if (activeOperation_.total <= 0) { + return 0.0; + } + return qBound( + 0.0, + static_cast(activeOperation_.completed) / + static_cast(activeOperation_.total), + 1.0); +} + +QStringList RuntimeClient::availableMetrics() const { + return metrics_.availableMetrics; +} + +bool RuntimeClient::metricsEnabled() const { + return metrics_.enabled; +} + +bool RuntimeClient::samplingActive() const { + return metrics_.samplingActive; +} + +QStringList RuntimeClient::activeMetrics() const { + return metrics_.metrics; +} + +QString RuntimeClient::metricsAlignment() const { + return metrics_.alignment; +} + +QString RuntimeClient::metricsColor() const { + return QStringLiteral("#%1") + .arg(metrics_.textColor & 0x00ffffff, 6, 16, + QLatin1Char('0')); +} + +bool RuntimeClient::displayStateValid() const { + return display_.valid; +} + +int RuntimeClient::brightness() const { + return display_.brightness; +} + +bool RuntimeClient::backlightEnabled() const { + return display_.backlightEnabled; +} + +bool RuntimeClient::mirrorMode() const { + return display_.mirrorMode; +} + +bool RuntimeClient::waterfallMode() const { + return display_.waterfallMode; +} + +QString RuntimeClient::currentScreenMode() const { + return display_.screenMode; +} + +QString RuntimeClient::currentPlayMode() const { + return display_.playMode; +} + +QStringList RuntimeClient::displayedMedia() const { + return display_.media; +} + +QStringList RuntimeClient::displayLeftMetrics() const { + return display_.sysinfoLabels; +} + +QStringList RuntimeClient::displayRightMetrics() const { + return display_.sysinfoLabels2; +} + +QStringList RuntimeClient::displayLeftBadges() const { + return display_.settingsBadges; +} + +QStringList RuntimeClient::displayRightBadges() const { + return display_.settingsBadges2; +} + +QString RuntimeClient::queueUploadWithTransform( + const QString &localPath, + const TryxRuntimeMediaTransform &transform) { + if (!mutationReady(tr("Upload"))) { + return {}; + } + if (localPath.trimmed().isEmpty()) { + setDiagnostic(tr("The upload source path is empty")); + emit userMessage(diagnostic_, true); + return {}; + } + const QString operationId = nextOperationId(); + if (legacyConnected()) { + if (!tryxMediaTransformIsLegacyFit(transform)) { + setDiagnostic(tr( + "Legacy serial/ADB upload supports only the default Fit transform. Reset sizing, rotation, zoom, position and background before uploading.")); + emit userMessage(diagnostic_, true); + return {}; + } + QString claimError; + if (!claimLegacyUploadSource( + operationId, localPath, &claimError)) { + setDiagnostic(claimError); + emit userMessage(diagnostic_, true); + return {}; + } + beginLegacyUpload(operationId); + return operationId; + } + sendOperation( + QStringLiteral("QueueUploadWithTransform"), + {operationId, localPath, QVariant::fromValue(transform)}, + operationId, QStringLiteral("Upload")); + return operationId; +} + +QString RuntimeClient::queueStageDeviceMedia( + const QString &mediaId) { + if (!mutationReady(tr("Export or edit"))) { + return {}; + } + if (mediaId.trimmed().isEmpty() || + !mediaModel_.canStageDeviceCopy(mediaId)) { + const QString reason = + mediaModel_.deviceCopyBlockReason(mediaId); + setDiagnostic( + reason.isEmpty() + ? tr("The selected media cannot be staged") + : reason); + emit userMessage(diagnostic_, true); + return {}; + } + const QString operationId = nextOperationId(); + sendOperation( + QStringLiteral("QueueStageDeviceMedia"), + {operationId, mediaId}, operationId, + QStringLiteral("StageDeviceMedia")); + return operationId; +} + +void RuntimeClient::claimDeviceMediaArtifact( + const QString &operationId, const QString &artifactId) { + if (!serviceAvailable_ || !compatible_ || + operationId.trimmed().isEmpty() || + artifactId.trimmed().isEmpty()) { + const QString error = + tr("The staged device media artifact cannot be claimed"); + emit artifactClaimFailed( + operationId, artifactId, error); + return; + } + if (offline_) { + offlineRequests_.append({ + QStringLiteral("ClaimDeviceMediaArtifact"), + {operationId, artifactId}, + operationId, + QStringLiteral("ClaimDeviceMediaArtifact"), + }); + return; + } + + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall( + QStringLiteral("ClaimDeviceMediaArtifact"), + operationId, artifactId), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch, operationId, artifactId]() { + QDBusPendingReply reply = + *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + emit artifactClaimFailed( + operationId, artifactId, + tr("The runtime changed before the artifact was claimed")); + return; + } + if (!reply.isValid()) { + emit artifactClaimFailed( + operationId, artifactId, + reply.error().message()); + return; + } + const TryxRuntimeDeviceMediaArtifact artifact = + reply.value(); + if (artifact.schemaVersion != 1U || + artifact.operationId != operationId || + artifact.artifactId != artifactId || + artifact.leaseId.isEmpty() || + artifact.localPath.isEmpty()) { + emit artifactClaimFailed( + operationId, artifactId, + tr("The runtime returned an invalid artifact claim")); + return; + } + emit artifactClaimed(operationId, artifact); + }); +} + +void RuntimeClient::renewDeviceMediaArtifactLease( + const QString &artifactId, const QString &leaseId) { + if (!serviceAvailable_ || !compatible_ || + artifactId.isEmpty() || leaseId.isEmpty()) { + emit artifactLeaseRenewFailed( + artifactId, leaseId, + tr("The artifact lease cannot be renewed")); + return; + } + if (offline_) { + offlineRequests_.append({ + QStringLiteral("RenewDeviceMediaArtifactLease"), + {artifactId, leaseId}, + {}, + QStringLiteral("RenewDeviceMediaArtifactLease"), + }); + return; + } + + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall( + QStringLiteral("RenewDeviceMediaArtifactLease"), + artifactId, leaseId), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch, artifactId, leaseId]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + emit artifactLeaseRenewFailed( + artifactId, leaseId, + tr("The runtime changed before the lease was renewed")); + return; + } + if (!reply.isValid() || !reply.value()) { + emit artifactLeaseRenewFailed( + artifactId, leaseId, + reply.isValid() + ? tr("The runtime rejected the artifact lease renewal") + : reply.error().message()); + return; + } + emit artifactLeaseRenewed(artifactId, leaseId); + }); +} + +void RuntimeClient::releaseDeviceMediaArtifact( + const QString &artifactId, const QString &leaseId) { + if (artifactId.isEmpty() || leaseId.isEmpty()) { + return; + } + if (!serviceAvailable_ || !compatible_) { + emit artifactReleaseFailed( + artifactId, leaseId, + tr("The runtime is unavailable")); + return; + } + if (offline_) { + offlineRequests_.append({ + QStringLiteral("ReleaseDeviceMediaArtifact"), + {artifactId, leaseId}, + {}, + QStringLiteral("ReleaseDeviceMediaArtifact"), + }); + return; + } + + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall( + QStringLiteral("ReleaseDeviceMediaArtifact"), + artifactId, leaseId), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch, artifactId, leaseId]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + emit artifactReleaseFailed( + artifactId, leaseId, + tr("The runtime changed before the artifact was released")); + return; + } + if (!reply.isValid() || !reply.value()) { + emit artifactReleaseFailed( + artifactId, leaseId, + reply.isValid() + ? tr("The runtime rejected the artifact release") + : reply.error().message()); + return; + } + emit artifactReleased(artifactId, leaseId); + }); +} + +QString RuntimeClient::queueRecoveredMediaUploadWithTransform( + const QString &artifactId, const QString &leaseId, + const TryxRuntimeMediaTransform &transform) { + if (!mutationReady(tr("Save as new"))) { + return {}; + } + if (artifactId.isEmpty() || leaseId.isEmpty()) { + setDiagnostic(tr("The recovered media artifact is unavailable")); + emit userMessage(diagnostic_, true); + return {}; + } + const QString operationId = nextOperationId(); + sendOperation( + QStringLiteral("QueueRecoveredMediaUploadWithTransform"), + {operationId, artifactId, leaseId, + QVariant::fromValue(transform)}, + operationId, QStringLiteral("RecoveredMediaUpload")); + return operationId; +} + +QString RuntimeClient::queueReplaceDeviceMedia( + const QString &artifactId, const QString &leaseId, + const QString &originalMediaId, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform) { + if (!mutationReady(tr("Replace"))) { + return {}; + } + if (artifactId.isEmpty() || leaseId.isEmpty() || + originalMediaId.isEmpty()) { + setDiagnostic(tr("The recovered media replacement is unavailable")); + emit userMessage(diagnostic_, true); + return {}; + } + const QString operationId = nextOperationId(); + sendOperation( + QStringLiteral("QueueReplaceDeviceMedia"), + {operationId, artifactId, leaseId, originalMediaId, + QVariant::fromValue(request), + QVariant::fromValue(transform)}, + operationId, QStringLiteral("ReplaceDeviceMedia")); + return operationId; +} + +TryxRuntimeApplyRequest +RuntimeClient::currentDisplayApplyRequest() const { + TryxRuntimeApplyRequest request = baseApplyRequest(); + request.media = display_.media; + request.screenMode = display_.screenMode.isEmpty() + ? QStringLiteral("Full Screen") + : display_.screenMode; + request.playMode = display_.playMode.isEmpty() + ? QStringLiteral("Single") + : display_.playMode; + request.sysinfoLabels = display_.sysinfoLabels; + request.settingsBadges = display_.settingsBadges; + request.sysinfoLabels2 = display_.sysinfoLabels2; + request.settingsBadges2 = display_.settingsBadges2; + request.settingsPosition2 = + display_.settingsPosition2.isEmpty() + ? QStringLiteral("Top") + : display_.settingsPosition2; + request.settingsColor2 = normalizedColor( + display_.settingsColor2, QStringLiteral("#dcdcdc")); + request.settingsAlign2 = display_.settingsAlign2.isEmpty() + ? QStringLiteral("Right") + : display_.settingsAlign2; + request.replaceOverlay = true; + return request; +} + +void RuntimeClient::refreshAll() { + if (!serviceAvailable_) { + setDiagnostic(tr("Runtime service is not running")); + return; + } + if (!compatible_) { + startHandshake(); + return; + } + refreshConnection(); + refreshOperations(); + refreshMedia(); + refreshMetrics(); + refreshDisplay(); +} + +void RuntimeClient::refreshMedia() { + if (!compatible_) { + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("RefreshMediaList")); + if (legacyConnected()) { + return; + } + + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall(QStringLiteral("GetMediaCatalog")), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = + *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + setDiagnostic(reply.error().message()); + return; + } + mediaModel_.applySnapshot(reply.value()); + }); +} + +void RuntimeClient::connectDevice(const QString &port) { + if (!manager1Ready(tr("Connect"), false)) { + return; + } + const QString normalized = port.trimmed(); + if (!normalized.isEmpty() && + (!normalized.startsWith(QStringLiteral("/dev/ttyACM")) || + normalized.contains(QStringLiteral("/../")))) { + setDiagnostic( + tr("Connect accepts Auto or a /dev/ttyACM device")); + emit userMessage(diagnostic_, true); + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("ConnectDevice"), + {normalized}); +} + +void RuntimeClient::disconnectDevice() { + if (!manager1Ready(tr("Disconnect"))) { + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("DisconnectDevice")); +} + +void RuntimeClient::requestDeviceInfo() { + if (!manager1Ready(tr("Device information"))) { + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("RequestDeviceInfo")); +} + +void RuntimeClient::setRotation(int degrees) { + if (!manager1Ready(tr("Rotation")) || + !legacyConnected()) { + if (ready() && !legacyConnected()) { + setDiagnostic(tr( + "The legacy rotation command is unavailable for PASE")); + emit userMessage(diagnostic_, true); + } + return; + } + int normalized = degrees % 360; + if (normalized < 0) { + normalized += 360; + } + if ((normalized % 90) != 0) { + setDiagnostic(tr( + "Legacy rotation must be 0, 90, 180 or 270 degrees")); + emit userMessage(diagnostic_, true); + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("SetRotation"), + {normalized}); +} + +void RuntimeClient::rebootDevice() { + if (!manager1Ready(tr("Reboot")) || + !legacyConnected()) { + if (ready() && !legacyConnected()) { + setDiagnostic(tr( + "The legacy reboot command is unavailable for PASE")); + emit userMessage(diagnostic_, true); + } + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("RebootDevice")); +} + +void RuntimeClient::startKeepalive(int intervalSec) { + if (!manager1Ready(tr("Keepalive")) || + !legacyConnected()) { + if (ready() && !legacyConnected()) { + setDiagnostic(tr( + "Legacy keepalive is unavailable for PASE")); + emit userMessage(diagnostic_, true); + } + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("StartKeepalive"), + {qBound(5, intervalSec, 60)}); +} + +void RuntimeClient::stopKeepalive() { + if (!manager1Ready(tr("Keepalive")) || + !legacyConnected()) { + if (ready() && !legacyConnected()) { + setDiagnostic(tr( + "Legacy keepalive is unavailable for PASE")); + emit userMessage(diagnostic_, true); + } + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("StopKeepalive")); +} + +void RuntimeClient::applyFullScreen( + const QStringList &media, const QString &playMode, + const QStringList &metrics, const QStringList &badges) { + if (!mutationReady(tr("Apply"))) { + return; + } + QString metricsError; + QString badgesError; + if (media.size() != 1 || + !isPlayMode(playMode, false) || + !metricsSelectionValid( + metrics, true, &metricsError) || + !badgesSelectionValid( + badges, &badgesError)) { + const QString selectionError = + !metricsError.isEmpty() + ? metricsError + : badgesError; + setDiagnostic(tr( + "Full-screen mode requires one media file, a supported play mode and valid metric and badge selections%1") + .arg(selectionError.isEmpty() + ? QString() + : QStringLiteral(": ") + + selectionError)); + emit userMessage(diagnostic_, true); + return; + } + const TryxRuntimeApplyRequest request = + fullScreenApplyRequest( + media, playMode, metrics, badges); + if (legacyConnected()) { + sendLegacyScreenConfig(request); + return; + } + const QString operationId = nextOperationId(); + sendOperation(QStringLiteral("QueueApplyWithMetrics"), + {operationId, QVariant::fromValue(request)}, + operationId, QStringLiteral("Apply")); +} + +void RuntimeClient::applySplitScreen( + const QString &leftMedia, const QString &rightMedia, + const QString &playMode, const QStringList &leftMetrics, + const QStringList &rightMetrics, + const QStringList &leftBadges, + const QStringList &rightBadges) { + if (!mutationReady(tr("Apply"))) { + return; + } + QString leftMetricsError; + QString rightMetricsError; + QString leftBadgesError; + QString rightBadgesError; + if (leftMedia.isEmpty() || rightMedia.isEmpty() || + leftMedia == rightMedia || + !metricsSelectionValid( + leftMetrics, true, &leftMetricsError) || + !metricsSelectionValid( + rightMetrics, true, &rightMetricsError) || + !badgesSelectionValid( + leftBadges, &leftBadgesError) || + !badgesSelectionValid( + rightBadges, &rightBadgesError) || + !isPlayMode(playMode, true)) { + const QString selectionError = + !leftMetricsError.isEmpty() + ? leftMetricsError + : !rightMetricsError.isEmpty() + ? rightMetricsError + : !leftBadgesError.isEmpty() + ? leftBadgesError + : rightBadgesError; + setDiagnostic(tr( + "Split-screen mode requires two different media files, Single play mode and valid metric and badge selections per side%1") + .arg(selectionError.isEmpty() + ? QString() + : QStringLiteral(": ") + + selectionError)); + emit userMessage(diagnostic_, true); + return; + } + const TryxRuntimeApplyRequest request = + splitScreenApplyRequest( + leftMedia, rightMedia, leftMetrics, rightMetrics, + leftBadges, rightBadges); + if (legacyConnected()) { + sendLegacyScreenConfig(request); + return; + } + const QString operationId = nextOperationId(); + sendOperation(QStringLiteral("QueueApplyWithMetrics"), + {operationId, QVariant::fromValue(request)}, + operationId, QStringLiteral("Apply")); +} + +void RuntimeClient::deleteMedia(const QStringList &media) { + if (!mutationReady(tr("Delete"))) { + return; + } + if (media.size() != 1) { + setDiagnostic(tr("Select exactly one deletable media file")); + emit userMessage(diagnostic_, true); + return; + } + if (legacyConnected()) { + if (!mediaModel_.canDelete(media.constFirst())) { + setDiagnostic( + mediaModel_.deleteBlockReason( + media.constFirst())); + emit userMessage(diagnostic_, true); + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("DeleteMedia"), + {media}); + return; + } + const QString operationId = nextOperationId(); + sendOperation(QStringLiteral("QueueDeleteMedia"), + {operationId, media}, operationId, + QStringLiteral("Delete")); +} + +void RuntimeClient::cancelActiveOperation() { + if (legacyUpload_.active()) { + setDiagnostic(tr( + "A legacy upload cannot be cancelled safely while the device worker owns the protected source")); + emit userMessage(diagnostic_, true); + return; + } + if (!compatible_ || activeOperationId_.isEmpty()) { + return; + } + sendVoidCall(tryxRuntimeOperationsInterfaceName(), + QStringLiteral("CancelOperation"), + {activeOperationId_}); +} + +void RuntimeClient::retryOperation( + const QString &sourceOperationId) { + if (!mutationReady(tr("Retry"))) { + return; + } + if (sourceOperationId.trimmed().isEmpty()) { + setDiagnostic(tr("The source operation identity is empty")); + emit userMessage(diagnostic_, true); + return; + } + const QString operationId = nextOperationId(); + sendOperation(QStringLiteral("RetryOperation"), + {sourceOperationId, operationId}, operationId, + QStringLiteral("Retry")); +} + +void RuntimeClient::configureMetrics( + bool enabled, const QStringList &metrics, + const QString &alignment, const QString &color) { + if (!mutationReady(tr("Metrics"))) { + return; + } + if (legacyConnected()) { + setDiagnostic(tr( + "Legacy overlay metrics are applied together with the display layout")); + emit userMessage(diagnostic_, true); + return; + } + TryxRuntimeMetricsConfigRequest request; + QString validationError; + if (!metricsConfigRequest( + enabled, metrics, alignment, color, + &request, &validationError)) { + setDiagnostic(validationError); + emit userMessage(diagnostic_, true); + return; + } + const QString operationId = nextOperationId(); + sendOperation(QStringLiteral("QueueMetricsConfig"), + {operationId, QVariant::fromValue(request)}, + operationId, QStringLiteral("Metrics")); +} + +void RuntimeClient::setBrightness(int value) { + if (legacyConnected()) { + if (!mutationReady(tr("Brightness"))) { + return; + } + sendVoidCall(tryxRuntimeInterfaceName(), + QStringLiteral("SetBrightness"), + {qBound(0, value, 100)}); + return; + } + TryxRuntimeDisplayMutation mutation; + mutation.brightnessPresent = true; + mutation.brightness = qBound(0, value, 100); + applyDisplayMutation(mutation); +} + +void RuntimeClient::setBacklight(bool enabled) { + if (legacyConnected()) { + Q_UNUSED(enabled); + setDiagnostic(tr( + "Display backlight control is unavailable on the legacy protocol")); + emit userMessage(diagnostic_, true); + return; + } + TryxRuntimeDisplayMutation mutation; + mutation.backlightPresent = true; + mutation.backlightEnabled = enabled; + applyDisplayMutation(mutation); +} + +void RuntimeClient::setOrientation(bool mirror, bool waterfall) { + if (legacyConnected()) { + Q_UNUSED(mirror); + Q_UNUSED(waterfall); + setDiagnostic(tr( + "Use the legacy rotation control for a serial/ADB device")); + emit userMessage(diagnostic_, true); + return; + } + TryxRuntimeDisplayMutation mutation; + mutation.orientationPresent = true; + mutation.mirrorMode = mirror; + mutation.waterfallMode = waterfall; + applyDisplayMutation(mutation); +} + +void RuntimeClient::retranslate() { + emit connectionChanged(); + emit diagnosticChanged(); + emit operationChanged(); + emit metricsChanged(); + emit displayChanged(); +} + +void RuntimeClient::onServiceRegistered(const QString &) { + ++serviceEpoch_; + serviceAvailable_ = true; + emit connectionChanged(); + startHandshake(); +} + +void RuntimeClient::onServiceUnregistered(const QString &) { + ++serviceEpoch_; + if (legacyUpload_.active()) { + rejectLegacyUpload( + tr("Runtime service stopped during the legacy upload"), + true); + } + emit runtimeInvalidated(); + clearRuntimeState(); + serviceAvailable_ = false; + setDiagnostic(tr("Runtime service stopped")); + emit connectionChanged(); +} + +void RuntimeClient::onOperationChanged( + TryxRuntimeOperationInfo info, quint64 revision) { + if (!compatible_) { + return; + } + if (!operationModel_.upsert(info, revision)) { + return; + } + emit operationUpdated(info); + updateActiveOperation(info); +} + +void RuntimeClient::onOperationRemoved( + QString operationId, quint64 revision) { + if (!compatible_) { + return; + } + if (!operationModel_.remove(operationId, revision)) { + return; + } + if (activeOperationId_ == operationId) { + activeOperationId_.clear(); + activeOperation_ = {}; + emit operationChanged(); + } +} + +void RuntimeClient::onMediaCatalogUpdated( + TryxRuntimeMediaCatalogSnapshot snapshot) { + if (!compatible_) { + return; + } + mediaModel_.applySnapshot(snapshot); +} + +void RuntimeClient::onMetricsStateUpdated( + TryxRuntimeMetricsState state) { + if (!compatible_) { + return; + } + applyMetricsState(state); +} + +void RuntimeClient::onDisplayStateUpdated( + TryxRuntimeDisplayState state) { + if (!compatible_) { + return; + } + applyDisplayState(state); +} + +void RuntimeClient::onDeviceConnected( + QString, QString, QString, QString, bool, bool, + quint64) { + if (!compatible_) { + return; + } + refreshConnection(); + refreshDisplay(); + refreshMetrics(); +} + +void RuntimeClient::onDeviceDisconnected(quint64) { + if (!compatible_) { + return; + } + if (legacyUpload_.active()) { + rejectLegacyUpload( + tr("The legacy device disconnected during upload"), + true); + } + refreshConnection(); +} + +void RuntimeClient::onDeviceError(QString message, quint64) { + if (!compatible_) { + return; + } + if (legacyUpload_.active()) { + rejectLegacyUpload(message, true); + } + setDiagnostic(message); + refreshConnection(); +} + +void RuntimeClient::onLegacyBrightnessChanged( + int value, quint64 revision) { + if (!compatible_ || !legacyConnected()) { + return; + } + display_.revision = + qMax(display_.revision + 1, revision); + display_.valid = true; + display_.brightness = qBound(0, value, 100); + emit displayChanged(); +} + +void RuntimeClient::onLegacyScreenConfigChanged( + quint64 revision) { + if (!compatible_ || !legacyConnected() || + !pendingLegacyScreenConfigValid_) { + return; + } + const TryxRuntimeApplyRequest request = + pendingLegacyScreenConfig_; + pendingLegacyScreenConfigValid_ = false; + display_.revision = + qMax(display_.revision + 1, revision); + display_.valid = true; + display_.screenMode = request.screenMode; + display_.playMode = request.playMode; + display_.media = request.media; + display_.sysinfoLabels = request.sysinfoLabels; + display_.settingsBadges = request.settingsBadges; + display_.settingsPosition = request.settingsPosition; + display_.settingsColor = request.settingsColor; + display_.settingsAlign = request.settingsAlign; + display_.sysinfoLabels2 = request.sysinfoLabels2; + display_.settingsBadges2 = request.settingsBadges2; + display_.waterfallMode = request.waterfallMode; + emit displayChanged(); +} + +void RuntimeClient::onLegacyMediaUploaded( + QString filename, quint64) { + if (!compatible_ || !legacyUpload_.active()) { + return; + } + finishLegacyUpload(filename); + refreshMedia(); +} + +void RuntimeClient::onLegacyMediaDeleted(quint64) { + if (compatible_ && legacyConnected()) { + refreshMedia(); + } +} + +void RuntimeClient::onLegacyMediaListUpdated( + QStringList files, quint64 revision) { + if (!compatible_ || !legacyConnected()) { + return; + } + mediaModel_.applyLegacyFiles( + files, revision, legacyDeviceIdentity()); +} + +void RuntimeClient::onLegacyUploadStatus( + QString status, quint64) { + if (!compatible_ || status.trimmed().isEmpty()) { + return; + } + setDiagnostic(status); + if (legacyUpload_.active()) { + emit operationChanged(); + } +} + +void RuntimeClient::onLegacyUploadTimeout() { + if (!legacyUpload_.active() || + legacyUpload_.rejectionEmitted) { + return; + } + legacyUpload_.rejectionEmitted = true; + const QString operationId = + legacyUpload_.operationId; + const QString error = tr( + "Legacy upload timed out. The protected source is retained until the device worker reports a terminal result."); + setDiagnostic(error); + emit operationRequestRejected( + operationId, QStringLiteral("Upload"), error); + emit userMessage(error, true); + emit operationChanged(); +} + +void RuntimeClient::onPrinterPresenceChanged( + bool, bool, quint64) { + if (!compatible_) { + return; + } + refreshConnection(); +} + +void RuntimeClient::onDisplaySessionChanged( + bool, quint64) { + if (!compatible_) { + return; + } + refreshConnection(); + refreshDisplay(); +} + +void RuntimeClient::subscribeSignals() { + if (signalsSubscribed_ || !bus_.isConnected()) { + return; + } + const QString service = tryxRuntimeServiceName(); + const QString path = tryxRuntimeObjectPath(); + const QString manager1 = tryxRuntimeInterfaceName(); + const QString manager2 = + tryxRuntimeOperationsInterfaceName(); + + bool ok = true; + ok &= bus_.connect( + service, path, manager2, QStringLiteral("OperationChanged"), + this, + SLOT(onOperationChanged(TryxRuntimeOperationInfo,quint64))); + ok &= bus_.connect( + service, path, manager2, QStringLiteral("OperationRemoved"), + this, SLOT(onOperationRemoved(QString,quint64))); + ok &= bus_.connect( + service, path, manager2, + QStringLiteral("MediaCatalogUpdated"), this, + SLOT(onMediaCatalogUpdated(TryxRuntimeMediaCatalogSnapshot))); + ok &= bus_.connect( + service, path, manager2, + QStringLiteral("MetricsStateUpdated"), this, + SLOT(onMetricsStateUpdated(TryxRuntimeMetricsState))); + ok &= bus_.connect( + service, path, manager2, + QStringLiteral("DisplayStateUpdated"), this, + SLOT(onDisplayStateUpdated(TryxRuntimeDisplayState))); + ok &= bus_.connect( + service, path, manager1, QStringLiteral("DeviceConnected"), + this, + SLOT(onDeviceConnected(QString,QString,QString,QString,bool,bool,quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("DeviceDisconnected"), this, + SLOT(onDeviceDisconnected(quint64))); + ok &= bus_.connect( + service, path, manager1, QStringLiteral("DeviceError"), + this, SLOT(onDeviceError(QString,quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("BrightnessChanged"), this, + SLOT(onLegacyBrightnessChanged(int,quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("ScreenConfigChanged"), this, + SLOT(onLegacyScreenConfigChanged(quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("MediaUploaded"), this, + SLOT(onLegacyMediaUploaded(QString,quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("MediaDeleted"), this, + SLOT(onLegacyMediaDeleted(quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("MediaListUpdated"), this, + SLOT(onLegacyMediaListUpdated(QStringList,quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("UploadStatus"), this, + SLOT(onLegacyUploadStatus(QString,quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("PrinterPresenceChanged"), this, + SLOT(onPrinterPresenceChanged(bool,bool,quint64))); + ok &= bus_.connect( + service, path, manager1, + QStringLiteral("DisplaySessionChanged"), this, + SLOT(onDisplaySessionChanged(bool,quint64))); + signalsSubscribed_ = ok; + if (!ok) { + setDiagnostic(tr("Could not subscribe to all runtime signals")); + } +} + +void RuntimeClient::startHandshake() { + if (!serviceAvailable_) { + return; + } + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall( + QStringLiteral("GetRuntimeApiVersion")), + this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + compatible_ = false; + setDiagnostic(reply.error().message()); + emit connectionChanged(); + return; + } + apiVersion_ = reply.value(); + compatible_ = + apiVersion_ == tryxRuntimeApiVersion(); + if (!compatible_) { + setDiagnostic(tr( + "Runtime API %1 is active, but this client requires API %2") + .arg(apiVersion_) + .arg(tryxRuntimeApiVersion())); + } else { + setDiagnostic({}); + } + emit connectionChanged(); + if (compatible_) { + refreshAll(); + } + }); +} + +void RuntimeClient::clearRuntimeState() { + compatible_ = false; + apiVersion_ = 0; + connection_ = {}; + metrics_ = {}; + display_ = {}; + activeOperationId_.clear(); + activeOperation_ = {}; + pendingLegacyScreenConfig_ = {}; + pendingLegacyScreenConfigValid_ = false; + mediaModel_.clear(); + operationModel_.clear(); + emit connectionChanged(); + emit operationChanged(); + emit metricsChanged(); + emit displayChanged(); +} + +void RuntimeClient::refreshConnection() { + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall( + QStringLiteral("GetConnectionSnapshot")), + this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = + *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + setDiagnostic(reply.error().message()); + return; + } + applyConnectionSnapshot(reply.value()); + }); +} + +void RuntimeClient::refreshOperations() { + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall(QStringLiteral("GetOperations")), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = + *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + setDiagnostic(reply.error().message()); + return; + } + applyOperationsSnapshot(reply.value()); + }); +} + +void RuntimeClient::refreshMetrics() { + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall(QStringLiteral("GetMetricsState")), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = + *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + setDiagnostic(reply.error().message()); + return; + } + applyMetricsState(reply.value()); + }); +} + +void RuntimeClient::refreshDisplay() { + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall(QStringLiteral("GetDisplayState")), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply reply = + *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + setDiagnostic(reply.error().message()); + return; + } + applyDisplayState(reply.value()); + }); +} + +void RuntimeClient::setDiagnostic(const QString &message) { + if (diagnostic_ == message) { + return; + } + diagnostic_ = message; + emit diagnosticChanged(); +} + +bool RuntimeClient::mutationReady(const QString &action) { + if (!serviceAvailable_ || !compatible_) { + setDiagnostic(tr("%1 is unavailable because the runtime is not ready") + .arg(action)); + } else if (legacyConnected()) { + if (!operationBusy()) { + return true; + } + setDiagnostic(tr("%1 is blocked while another operation is active") + .arg(action)); + } else if (!connection_.printerClassDevicePresent) { + setDiagnostic(tr("%1 requires a PASE printer-class device") + .arg(action)); + } else if (!connection_.displaySessionActive) { + setDiagnostic(tr( + "%1 is blocked until the PASE display session is active") + .arg(action)); + } else if (operationBusy()) { + setDiagnostic(tr("%1 is blocked while another operation is active") + .arg(action)); + } else { + return true; + } + emit userMessage(diagnostic_, true); + return false; +} + +bool RuntimeClient::manager1Ready( + const QString &action, bool requireConnected) { + if (!serviceAvailable_ || !compatible_) { + setDiagnostic( + tr("%1 is unavailable because the runtime is not ready") + .arg(action)); + } else if (requireConnected && + !connection_.connected) { + setDiagnostic( + tr("%1 requires a connected TRYX device") + .arg(action)); + } else { + return true; + } + emit userMessage(diagnostic_, true); + return false; +} + +QString RuntimeClient::legacyDeviceIdentity() const { + const QString identity = + !connection_.serial.trimmed().isEmpty() + ? connection_.serial.trimmed() + : connection_.productId.trimmed(); + return identity.isEmpty() + ? QStringLiteral("legacy") + : QStringLiteral("legacy:%1").arg(identity); +} + +QString RuntimeClient::nextOperationId() const { + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +void RuntimeClient::sendOperation( + const QString &method, const QVariantList &arguments, + const QString &operationId, const QString &kind) { + if (offline_) { + offlineRequests_.append( + {method, arguments, operationId, kind}); + return; + } + QDBusMessage message = QDBusMessage::createMethodCall( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), method); + message.setArguments(arguments); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + bus_.asyncCall(message, kRuntimeCallTimeoutMs), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, operationId, kind, epoch]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + const QString error = tr( + "The runtime changed before the operation request was acknowledged"); + emit operationRequestRejected( + operationId, kind, error); + return; + } + if (!reply.isValid() || + !operationAcknowledgementMatches( + operationId, reply.value(), {})) { + const QString error = reply.isValid() + ? tr("The runtime returned an unexpected operation identity") + : reply.error().message(); + qWarning().noquote() + << "TRYX operation acknowledgement requires reconciliation:" + << "kind=" << kind + << "expected=" << operationId + << "returned=" << reply.value() + << "error=" << error; + reconcileOperationAcknowledgement( + operationId, kind, error, epoch); + return; + } + setDiagnostic({}); + emit operationRequestAccepted( + operationId, kind); + emit userMessage( + tr("%1 operation accepted").arg(kind), false); + QTimer::singleShot( + 0, this, &RuntimeClient::refreshOperations); + }); +} + +void RuntimeClient::reconcileOperationAcknowledgement( + const QString &operationId, const QString &kind, + const QString &initialError, quint64 epoch) { + QDBusInterface runtime( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeOperationsInterfaceName(), bus_); + runtime.setTimeout(kRuntimeCallTimeoutMs); + auto *watcher = new QDBusPendingCallWatcher( + runtime.asyncCall( + QStringLiteral("GetOperation"), operationId), + this); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, operationId, kind, initialError, epoch]() { + const QDBusPendingReply reply = + *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + const QString error = tr( + "The runtime changed before the operation request could be reconciled"); + setDiagnostic(error); + emit operationRequestRejected( + operationId, kind, error); + emit userMessage(error, true); + return; + } + if (reply.isValid() && + operationAcknowledgementMatches( + operationId, QString(), reply.value())) { + qInfo().noquote() + << "TRYX operation acknowledgement reconciled:" + << "kind=" << kind + << "operation=" << operationId + << "state=" << reply.value().state; + setDiagnostic({}); + emit operationRequestAccepted( + operationId, kind); + emit userMessage( + tr("%1 operation accepted").arg(kind), + false); + QTimer::singleShot( + 0, this, &RuntimeClient::refreshOperations); + return; + } + const QString reconciliationError = + reply.isValid() + ? tr("The expected operation is absent from the runtime") + : reply.error().message(); + const QString error = initialError.isEmpty() + ? reconciliationError + : initialError; + qWarning().noquote() + << "TRYX operation acknowledgement reconciliation failed:" + << "kind=" << kind + << "operation=" << operationId + << "error=" << reconciliationError; + setDiagnostic(error); + emit operationRequestRejected( + operationId, kind, error); + emit userMessage(error, true); + }); +} + +bool RuntimeClient::operationAcknowledgementMatches( + const QString &expectedOperationId, + const QString &returnedOperationId, + const TryxRuntimeOperationInfo &observedOperation) { + if (expectedOperationId.isEmpty()) { + return false; + } + return returnedOperationId == expectedOperationId || + observedOperation.id == expectedOperationId; +} + +void RuntimeClient::sendVoidCall( + const QString &interfaceName, const QString &method, + const QVariantList &arguments) { + QDBusMessage message = QDBusMessage::createMethodCall( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + interfaceName, method); + message.setArguments(arguments); + const quint64 epoch = serviceEpoch_; + auto *watcher = new QDBusPendingCallWatcher( + bus_.asyncCall(message, kRuntimeCallTimeoutMs), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, epoch]() { + QDBusPendingReply<> reply = *watcher; + watcher->deleteLater(); + if (epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + setDiagnostic(reply.error().message()); + emit userMessage(diagnostic_, true); + } + }); +} + +void RuntimeClient::sendLegacyScreenConfig( + const TryxRuntimeApplyRequest &request) { + pendingLegacyScreenConfig_ = request; + pendingLegacyScreenConfigValid_ = true; + sendVoidCall( + tryxRuntimeInterfaceName(), + QStringLiteral("SetScreenConfig"), + legacyScreenConfigArguments(request)); +} + +QVariantList RuntimeClient::legacyScreenConfigArguments( + const TryxRuntimeApplyRequest &request) { + return { + request.media, + request.ratio, + request.screenMode, + request.playMode, + request.sysinfoLabels, + request.settingsPosition, + request.settingsColor, + request.settingsAlign, + request.settingsBadges, + request.filterOpacity, + request.presetId, + request.sysinfoLabels2, + request.settingsBadges2, + request.waterfallMode, + }; +} + +bool RuntimeClient::claimLegacyUploadSource( + const QString &operationId, const QString &sourcePath, + QString *errorMessage) { + if (legacyUpload_.active()) { + if (errorMessage) { + *errorMessage = tr( + "Wait for the current legacy upload to finish"); + } + return false; + } + + const QFileInfo info(sourcePath); + const QString absolutePath = info.absoluteFilePath(); + const QByteArray encoded = QFile::encodeName(absolutePath); + struct stat status {}; + if (operationId.isEmpty() || !info.exists() || + !info.isFile() || info.isSymLink() || + info.suffix().isEmpty() || + ::lstat(encoded.constData(), &status) != 0 || + !S_ISREG(status.st_mode) || + status.st_uid != ::geteuid() || + (status.st_mode & 07777) != + (S_IRUSR | S_IWUSR) || + status.st_nlink != 1 || status.st_size <= 0) { + if (errorMessage) { + *errorMessage = tr( + "The private legacy upload source is not a safe regular file"); + } + return false; + } + + const QString claimedName = + QStringLiteral("%1-legacy-%2.%3") + .arg(info.completeBaseName(), operationId, + info.suffix().toLower()); + const QString claimedPath = + info.dir().filePath(claimedName); + const QByteArray encodedClaim = + QFile::encodeName(claimedPath); + if (::link(encoded.constData(), + encodedClaim.constData()) != 0) { + if (errorMessage) { + *errorMessage = tr( + "Could not protect the legacy upload source: %1") + .arg(QString::fromLocal8Bit( + std::strerror(errno))); + } + return false; + } + + struct stat claimedStatus {}; + if (::lstat(encodedClaim.constData(), + &claimedStatus) != 0 || + !S_ISREG(claimedStatus.st_mode) || + claimedStatus.st_dev != status.st_dev || + claimedStatus.st_ino != status.st_ino || + claimedStatus.st_nlink < 2) { + ::unlink(encodedClaim.constData()); + if (errorMessage) { + *errorMessage = tr( + "The protected legacy upload source could not be verified"); + } + return false; + } + + legacyUpload_.operationId = operationId; + legacyUpload_.sourcePath = absolutePath; + legacyUpload_.claimedPath = claimedPath; + legacyUpload_.device = + static_cast(status.st_dev); + legacyUpload_.inode = + static_cast(status.st_ino); + legacyUpload_.epoch = serviceEpoch_; + legacyUpload_.rejectionEmitted = false; + if (errorMessage) { + errorMessage->clear(); + } + return true; +} + +void RuntimeClient::beginLegacyUpload( + const QString &operationId) { + if (!legacyUpload_.active() || + legacyUpload_.operationId != operationId) { + return; + } + + QDBusMessage message = QDBusMessage::createMethodCall( + tryxRuntimeServiceName(), tryxRuntimeObjectPath(), + tryxRuntimeInterfaceName(), + QStringLiteral("UploadMedia")); + message.setArguments({legacyUpload_.claimedPath}); + const quint64 epoch = legacyUpload_.epoch; + auto *watcher = new QDBusPendingCallWatcher( + bus_.asyncCall(message, kRuntimeCallTimeoutMs), this); + legacyUploadDeadline_.start(); + emit operationChanged(); + connect( + watcher, &QDBusPendingCallWatcher::finished, this, + [this, watcher, operationId, epoch]() { + const QDBusPendingReply<> reply = *watcher; + watcher->deleteLater(); + if (!legacyUpload_.active() || + legacyUpload_.operationId != operationId || + legacyUpload_.epoch != epoch || + epoch != serviceEpoch_) { + return; + } + if (!reply.isValid()) { + rejectLegacyUpload( + reply.error().message(), true); + return; + } + setDiagnostic(tr( + "Waiting for the legacy device to finish the upload")); + emit operationChanged(); + }); +} + +void RuntimeClient::finishLegacyUpload( + const QString &filename) { + if (!legacyUpload_.active()) { + return; + } + const QString operationId = + legacyUpload_.operationId; + const bool alreadyRejected = + legacyUpload_.rejectionEmitted; + clearLegacyUpload(true, true); + if (alreadyRejected) { + setDiagnostic(tr( + "The legacy upload completed after the client timeout")); + emit userMessage(diagnostic_, false); + return; + } + setDiagnostic({}); + emit operationRequestAccepted( + operationId, QStringLiteral("Upload")); + emit userMessage( + filename.trimmed().isEmpty() + ? tr("Legacy upload completed") + : tr("Legacy upload completed: %1").arg(filename), + false); +} + +void RuntimeClient::rejectLegacyUpload( + const QString &message, bool restoreSource) { + if (!legacyUpload_.active()) { + return; + } + const QString operationId = + legacyUpload_.operationId; + const bool alreadyRejected = + legacyUpload_.rejectionEmitted; + if (restoreSource && + !fileIdentityMatches( + legacyUpload_.sourcePath, + legacyUpload_.device, + legacyUpload_.inode) && + fileIdentityMatches( + legacyUpload_.claimedPath, + legacyUpload_.device, + legacyUpload_.inode)) { + const QByteArray claimed = + QFile::encodeName(legacyUpload_.claimedPath); + const QByteArray source = + QFile::encodeName(legacyUpload_.sourcePath); + ::link(claimed.constData(), source.constData()); + } + clearLegacyUpload(false, true); + const QString error = message.trimmed().isEmpty() + ? tr("Legacy upload failed") + : message.trimmed(); + setDiagnostic(error); + if (!alreadyRejected) { + emit operationRequestRejected( + operationId, QStringLiteral("Upload"), error); + emit userMessage(error, true); + } +} + +void RuntimeClient::clearLegacyUpload( + bool removeSource, bool removeClaim) { + if (!legacyUpload_.active()) { + return; + } + const LegacyUploadState state = legacyUpload_; + legacyUploadDeadline_.stop(); + legacyUpload_ = {}; + if (removeSource) { + removeFileIfIdentityMatches( + state.sourcePath, state.device, state.inode); + } + if (removeClaim) { + removeFileIfIdentityMatches( + state.claimedPath, state.device, state.inode); + } + emit operationChanged(); +} + +bool RuntimeClient::fileIdentityMatches( + const QString &path, quint64 device, quint64 inode) { + const QByteArray encoded = QFile::encodeName(path); + struct stat status {}; + return ::lstat(encoded.constData(), &status) == 0 && + S_ISREG(status.st_mode) && + static_cast(status.st_dev) == device && + static_cast(status.st_ino) == inode; +} + +void RuntimeClient::removeFileIfIdentityMatches( + const QString &path, quint64 device, quint64 inode) { + if (!fileIdentityMatches(path, device, inode)) { + return; + } + const QByteArray encoded = QFile::encodeName(path); + ::unlink(encoded.constData()); +} + +TryxRuntimeApplyRequest RuntimeClient::baseApplyRequest() const { + TryxRuntimeApplyRequest request; + request.ratio = QStringLiteral("2:1"); + request.playMode = QStringLiteral("Single"); + request.screenMode = QStringLiteral("Full Screen"); + request.settingsPosition = display_.settingsPosition.isEmpty() + ? QStringLiteral("Top") + : display_.settingsPosition; + request.settingsColor = normalizedColor( + display_.settingsColor, QStringLiteral("#dcdcdc")); + request.settingsAlign = display_.settingsAlign.isEmpty() + ? QStringLiteral("Left") + : display_.settingsAlign; + request.settingsBadges = display_.settingsBadges; + request.waterfallMode = display_.waterfallMode; + return request; +} + +TryxRuntimeApplyRequest RuntimeClient::fullScreenApplyRequest( + const QStringList &media, const QString &playMode, + const QStringList &metrics, + const QStringList &badges) const { + TryxRuntimeApplyRequest request = baseApplyRequest(); + request.media = media; + request.screenMode = QStringLiteral("Full Screen"); + request.playMode = playMode; + request.sysinfoLabels = metrics; + request.settingsBadges = badges; + request.replaceOverlay = true; + return request; +} + +TryxRuntimeApplyRequest RuntimeClient::splitScreenApplyRequest( + const QString &leftMedia, const QString &rightMedia, + const QStringList &leftMetrics, + const QStringList &rightMetrics, + const QStringList &leftBadges, + const QStringList &rightBadges) const { + TryxRuntimeApplyRequest request = baseApplyRequest(); + request.media = {leftMedia, rightMedia}; + request.screenMode = QStringLiteral("Screen Splitting"); + request.playMode = QStringLiteral("Single"); + request.sysinfoLabels = leftMetrics; + request.sysinfoLabels2 = rightMetrics; + request.settingsBadges = leftBadges; + request.settingsBadges2 = rightBadges; + request.settingsPosition2 = display_.settingsPosition2.isEmpty() + ? QStringLiteral("Top") + : display_.settingsPosition2; + request.settingsColor2 = normalizedColor( + display_.settingsColor2, QStringLiteral("#dcdcdc")); + request.settingsAlign2 = display_.settingsAlign2.isEmpty() + ? QStringLiteral("Right") + : display_.settingsAlign2; + request.replaceOverlay = true; + return request; +} + +bool RuntimeClient::metricsSelectionValid( + const QStringList &metrics, bool allowEmpty, + QString *errorMessage) const { + if ((!allowEmpty && metrics.isEmpty()) || + metrics.size() > 3) { + if (errorMessage) { + *errorMessage = allowEmpty + ? tr("select at most three metrics") + : tr("select between one and three metrics"); + } + return false; + } + QSet unique; + for (const QString &metric : metrics) { + if (metric.trimmed().isEmpty() || + unique.contains(metric) || + !metrics_.availableMetrics.contains(metric)) { + if (errorMessage) { + *errorMessage = + tr("the metric selection contains an unavailable or duplicate value"); + } + return false; + } + unique.insert(metric); + } + return true; +} + +bool RuntimeClient::badgesSelectionValid( + const QStringList &badges, + QString *errorMessage) const { + if (badges.size() > 2) { + if (errorMessage) { + *errorMessage = tr("select at most two badges"); + } + return false; + } + + QSet unique; + for (const QString &badge : badges) { + if ((badge != QStringLiteral("CPU Badge") && + badge != QStringLiteral("GPU Badge")) || + unique.contains(badge)) { + if (errorMessage) { + *errorMessage = + tr("the badge selection contains an unsupported or duplicate value"); + } + return false; + } + unique.insert(badge); + } + return true; +} + +bool RuntimeClient::metricsConfigRequest( + bool enabled, const QStringList &metrics, + const QString &alignment, const QString &color, + TryxRuntimeMetricsConfigRequest *request, + QString *errorMessage) const { + if (!request) { + if (errorMessage) { + *errorMessage = + tr("The metrics request destination is unavailable"); + } + return false; + } + + TryxRuntimeMetricsConfigRequest candidate; + candidate.enabled = enabled; + if (!enabled) { + candidate.metrics.clear(); + candidate.alignment = + metrics_.alignment.isEmpty() + ? QStringLiteral("Left") + : metrics_.alignment; + candidate.textColor = metrics_.textColor; + *request = candidate; + return true; + } + + if (!metricsSelectionValid( + metrics, false, errorMessage)) { + return false; + } + if (alignment != QStringLiteral("Left") && + alignment != QStringLiteral("Center") && + alignment != QStringLiteral("Right")) { + if (errorMessage) { + *errorMessage = + tr("Select a supported metrics alignment"); + } + return false; + } + const QColor selected(color); + if (!selected.isValid()) { + if (errorMessage) { + *errorMessage = + tr("Enter a valid metrics text color"); + } + return false; + } + candidate.metrics = metrics; + candidate.alignment = alignment; + candidate.textColor = + static_cast( + selected.rgb() & 0x00ffffff); + *request = candidate; + return true; +} + +void RuntimeClient::applyDisplayMutation( + const TryxRuntimeDisplayMutation &mutation) { + if (!mutationReady(tr("Display settings"))) { + return; + } + TryxRuntimeApplyRequest request = baseApplyRequest(); + request.display = mutation; + const QString operationId = nextOperationId(); + sendOperation(QStringLiteral("QueueApply"), + {operationId, QVariant::fromValue(request)}, + operationId, QStringLiteral("Display")); +} + +void RuntimeClient::applyOperationsSnapshot( + const TryxRuntimeOperationsSnapshot &snapshot) { + if (!operationModel_.applySnapshot(snapshot)) { + return; + } + activeOperationId_ = snapshot.activeOperationId; + activeOperation_ = {}; + for (const TryxRuntimeOperationInfo &info : snapshot.operations) { + emit operationUpdated(info); + if (info.id == activeOperationId_) { + activeOperation_ = info; + break; + } + } + emit operationChanged(); +} + +void RuntimeClient::applyConnectionSnapshot( + const TryxRuntimeSnapshot &snapshot) { + if (snapshot.revision <= connection_.revision && + connection_.revision != 0) { + return; + } + const bool wasLegacy = legacyConnected(); + const QString oldIdentity = + wasLegacy ? legacyDeviceIdentity() + : mediaModel_.deviceIdentity(); + connection_ = snapshot; + const bool isLegacy = legacyConnected(); + const QString newIdentity = + isLegacy ? legacyDeviceIdentity() : QString(); + if (wasLegacy != isLegacy || + (isLegacy && oldIdentity != newIdentity) || + !snapshot.connected) { + mediaModel_.clear(); + } + if (isLegacy) { + mediaModel_.applyLegacyFiles( + snapshot.mediaFiles, snapshot.revision, + newIdentity); + } + if (!snapshot.diagnostic.isEmpty()) { + setDiagnostic(snapshot.diagnostic); + } + emit connectionChanged(); +} + +void RuntimeClient::applyMetricsState( + const TryxRuntimeMetricsState &state) { + if (state.revision <= metrics_.revision && + metrics_.revision != 0) { + return; + } + metrics_ = state; + if (!state.diagnostic.isEmpty()) { + setDiagnostic(state.diagnostic); + } + emit metricsChanged(); +} + +void RuntimeClient::applyDisplayState( + const TryxRuntimeDisplayState &state) { + if (state.revision <= display_.revision && + display_.revision != 0) { + return; + } + display_ = state; + if (!state.diagnostic.isEmpty()) { + setDiagnostic(state.diagnostic); + } + emit displayChanged(); +} + +void RuntimeClient::updateActiveOperation( + const TryxRuntimeOperationInfo &info) { + if (OperationListModel::isTerminal(info)) { + if (activeOperationId_ == info.id) { + activeOperationId_.clear(); + activeOperation_ = {}; + } + } else { + activeOperationId_ = info.id; + activeOperation_ = info; + } + if (!info.message.isEmpty() && + info.state == QStringLiteral("Failed")) { + setDiagnostic(info.message); + emit userMessage(info.message, true); + } + emit operationChanged(); +} diff --git a/src/quick/runtimeclient.h b/src/quick/runtimeclient.h new file mode 100644 index 0000000..0e83c4a --- /dev/null +++ b/src/quick/runtimeclient.h @@ -0,0 +1,348 @@ +#pragma once + +#include "mediacatalogmodel.h" +#include "operationlistmodel.h" +#include "runtimecontract.h" + +#include +#include +#include +#include +#include + +class RuntimeClient final : public QObject { + Q_OBJECT + Q_PROPERTY(bool serviceAvailable READ serviceAvailable + NOTIFY connectionChanged) + Q_PROPERTY(bool compatible READ compatible NOTIFY connectionChanged) + Q_PROPERTY(bool connected READ connected NOTIFY connectionChanged) + Q_PROPERTY(bool ready READ ready NOTIFY connectionChanged) + Q_PROPERTY(bool legacyConnected READ legacyConnected + NOTIFY connectionChanged) + Q_PROPERTY(bool printerClassDevicePresent + READ printerClassDevicePresent NOTIFY connectionChanged) + Q_PROPERTY(bool displaySessionActive READ displaySessionActive + NOTIFY connectionChanged) + Q_PROPERTY(QString connectionStatus READ connectionStatus + NOTIFY connectionChanged) + Q_PROPERTY(QString diagnostic READ diagnostic NOTIFY diagnosticChanged) + Q_PROPERTY(quint32 apiVersion READ apiVersion NOTIFY connectionChanged) + Q_PROPERTY(quint32 expectedApiVersion READ expectedApiVersion CONSTANT) + Q_PROPERTY(MediaCatalogModel *mediaModel READ mediaModel CONSTANT) + Q_PROPERTY(OperationListModel *operationModel READ operationModel CONSTANT) + Q_PROPERTY(bool operationBusy READ operationBusy + NOTIFY operationChanged) + Q_PROPERTY(QString activeOperationId READ activeOperationId + NOTIFY operationChanged) + Q_PROPERTY(QString operationSummary READ operationSummary + NOTIFY operationChanged) + Q_PROPERTY(double operationProgress READ operationProgress + NOTIFY operationChanged) + Q_PROPERTY(QStringList availableMetrics READ availableMetrics + NOTIFY metricsChanged) + Q_PROPERTY(bool metricsEnabled READ metricsEnabled + NOTIFY metricsChanged) + Q_PROPERTY(bool samplingActive READ samplingActive + NOTIFY metricsChanged) + Q_PROPERTY(QStringList activeMetrics READ activeMetrics + NOTIFY metricsChanged) + Q_PROPERTY(QString metricsAlignment READ metricsAlignment + NOTIFY metricsChanged) + Q_PROPERTY(QString metricsColor READ metricsColor + NOTIFY metricsChanged) + Q_PROPERTY(bool displayStateValid READ displayStateValid + NOTIFY displayChanged) + Q_PROPERTY(int brightness READ brightness NOTIFY displayChanged) + Q_PROPERTY(bool backlightEnabled READ backlightEnabled + NOTIFY displayChanged) + Q_PROPERTY(bool mirrorMode READ mirrorMode NOTIFY displayChanged) + Q_PROPERTY(bool waterfallMode READ waterfallMode NOTIFY displayChanged) + Q_PROPERTY(QString currentScreenMode READ currentScreenMode + NOTIFY displayChanged) + Q_PROPERTY(QString currentPlayMode READ currentPlayMode + NOTIFY displayChanged) + Q_PROPERTY(QStringList displayedMedia READ displayedMedia + NOTIFY displayChanged) + Q_PROPERTY(QStringList displayLeftMetrics READ displayLeftMetrics + NOTIFY displayChanged) + Q_PROPERTY(QStringList displayRightMetrics READ displayRightMetrics + NOTIFY displayChanged) + Q_PROPERTY(QStringList displayLeftBadges READ displayLeftBadges + NOTIFY displayChanged) + Q_PROPERTY(QStringList displayRightBadges READ displayRightBadges + NOTIFY displayChanged) + +public: + explicit RuntimeClient(bool offline = false, + QObject *parent = nullptr); + + bool serviceAvailable() const; + bool compatible() const; + bool connected() const; + bool ready() const; + bool legacyConnected() const; + bool printerClassDevicePresent() const; + bool displaySessionActive() const; + QString connectionStatus() const; + QString diagnostic() const; + quint32 apiVersion() const; + quint32 expectedApiVersion() const; + MediaCatalogModel *mediaModel(); + OperationListModel *operationModel(); + + bool operationBusy() const; + QString activeOperationId() const; + QString operationSummary() const; + double operationProgress() const; + + QStringList availableMetrics() const; + bool metricsEnabled() const; + bool samplingActive() const; + QStringList activeMetrics() const; + QString metricsAlignment() const; + QString metricsColor() const; + + bool displayStateValid() const; + int brightness() const; + bool backlightEnabled() const; + bool mirrorMode() const; + bool waterfallMode() const; + QString currentScreenMode() const; + QString currentPlayMode() const; + QStringList displayedMedia() const; + QStringList displayLeftMetrics() const; + QStringList displayRightMetrics() const; + QStringList displayLeftBadges() const; + QStringList displayRightBadges() const; + + QString queueUploadWithTransform( + const QString &localPath, + const TryxRuntimeMediaTransform &transform); + QString queueStageDeviceMedia(const QString &mediaId); + void claimDeviceMediaArtifact( + const QString &operationId, const QString &artifactId); + void renewDeviceMediaArtifactLease( + const QString &artifactId, const QString &leaseId); + void releaseDeviceMediaArtifact( + const QString &artifactId, const QString &leaseId); + QString queueRecoveredMediaUploadWithTransform( + const QString &artifactId, const QString &leaseId, + const TryxRuntimeMediaTransform &transform); + QString queueReplaceDeviceMedia( + const QString &artifactId, const QString &leaseId, + const QString &originalMediaId, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform); + TryxRuntimeApplyRequest currentDisplayApplyRequest() const; + + Q_INVOKABLE void refreshAll(); + Q_INVOKABLE void refreshMedia(); + Q_INVOKABLE void connectDevice(const QString &port = QString()); + Q_INVOKABLE void disconnectDevice(); + Q_INVOKABLE void requestDeviceInfo(); + Q_INVOKABLE void setRotation(int degrees); + Q_INVOKABLE void rebootDevice(); + Q_INVOKABLE void startKeepalive(int intervalSec); + Q_INVOKABLE void stopKeepalive(); + Q_INVOKABLE void applyFullScreen( + const QStringList &media, const QString &playMode, + const QStringList &metrics, const QStringList &badges); + Q_INVOKABLE void applySplitScreen( + const QString &leftMedia, const QString &rightMedia, + const QString &playMode, const QStringList &leftMetrics, + const QStringList &rightMetrics, + const QStringList &leftBadges, + const QStringList &rightBadges); + Q_INVOKABLE void deleteMedia(const QStringList &media); + Q_INVOKABLE void cancelActiveOperation(); + Q_INVOKABLE void retryOperation(const QString &sourceOperationId); + Q_INVOKABLE void configureMetrics( + bool enabled, const QStringList &metrics, + const QString &alignment, const QString &color); + Q_INVOKABLE void setBrightness(int value); + Q_INVOKABLE void setBacklight(bool enabled); + Q_INVOKABLE void setOrientation(bool mirror, bool waterfall); + void retranslate(); + +signals: + void connectionChanged(); + void diagnosticChanged(); + void operationChanged(); + void metricsChanged(); + void displayChanged(); + void userMessage(const QString &message, bool error); + void operationRequestAccepted(const QString &operationId, + const QString &kind); + void operationRequestRejected(const QString &operationId, + const QString &kind, + const QString &message); + void operationUpdated(const TryxRuntimeOperationInfo &info); + void artifactClaimed( + const QString &operationId, + const TryxRuntimeDeviceMediaArtifact &artifact); + void artifactClaimFailed(const QString &operationId, + const QString &artifactId, + const QString &message); + void artifactLeaseRenewed(const QString &artifactId, + const QString &leaseId); + void artifactLeaseRenewFailed(const QString &artifactId, + const QString &leaseId, + const QString &message); + void artifactReleased(const QString &artifactId, + const QString &leaseId); + void artifactReleaseFailed(const QString &artifactId, + const QString &leaseId, + const QString &message); + void runtimeInvalidated(); + +private slots: + void onServiceRegistered(const QString &service); + void onServiceUnregistered(const QString &service); + void onOperationChanged(TryxRuntimeOperationInfo info, + quint64 revision); + void onOperationRemoved(QString operationId, quint64 revision); + void onMediaCatalogUpdated( + TryxRuntimeMediaCatalogSnapshot snapshot); + void onMetricsStateUpdated(TryxRuntimeMetricsState state); + void onDisplayStateUpdated(TryxRuntimeDisplayState state); + void onDeviceConnected( + QString productId, QString serial, QString firmware, + QString appVersion, bool printerClassConnected, + bool printerClassDevicePresent, quint64 revision); + void onDeviceDisconnected(quint64 revision); + void onDeviceError(QString message, quint64 revision); + void onLegacyBrightnessChanged(int value, quint64 revision); + void onLegacyScreenConfigChanged(quint64 revision); + void onLegacyMediaUploaded(QString filename, quint64 revision); + void onLegacyMediaDeleted(quint64 revision); + void onLegacyMediaListUpdated(QStringList files, quint64 revision); + void onLegacyUploadStatus(QString status, quint64 revision); + void onPrinterPresenceChanged(bool present, + bool printerClassConnected, + quint64 revision); + void onDisplaySessionChanged(bool active, quint64 revision); + void onLegacyUploadTimeout(); + +private: + friend class QuickClientTests; + + struct OfflineRequest { + QString method; + QVariantList arguments; + QString operationId; + QString kind; + }; + + struct LegacyUploadState { + QString operationId; + QString sourcePath; + QString claimedPath; + quint64 device = 0; + quint64 inode = 0; + quint64 epoch = 0; + bool rejectionEmitted = false; + + bool active() const { + return !operationId.isEmpty(); + } + }; + + void subscribeSignals(); + void startHandshake(); + void clearRuntimeState(); + void refreshConnection(); + void refreshOperations(); + void refreshMetrics(); + void refreshDisplay(); + void setDiagnostic(const QString &message); + bool mutationReady(const QString &action); + bool manager1Ready(const QString &action, + bool requireConnected = true); + QString legacyDeviceIdentity() const; + QString nextOperationId() const; + void sendOperation(const QString &method, + const QVariantList &arguments, + const QString &operationId, + const QString &kind); + void reconcileOperationAcknowledgement( + const QString &operationId, const QString &kind, + const QString &initialError, quint64 epoch); + static bool operationAcknowledgementMatches( + const QString &expectedOperationId, + const QString &returnedOperationId, + const TryxRuntimeOperationInfo &observedOperation); + void sendVoidCall(const QString &interfaceName, + const QString &method, + const QVariantList &arguments = {}); + void sendLegacyScreenConfig( + const TryxRuntimeApplyRequest &request); + static QVariantList legacyScreenConfigArguments( + const TryxRuntimeApplyRequest &request); + bool claimLegacyUploadSource( + const QString &operationId, const QString &sourcePath, + QString *errorMessage); + void beginLegacyUpload(const QString &operationId); + void finishLegacyUpload(const QString &filename); + void rejectLegacyUpload(const QString &message, + bool restoreSource); + void clearLegacyUpload(bool removeSource, + bool removeClaim); + static bool fileIdentityMatches( + const QString &path, quint64 device, quint64 inode); + static void removeFileIfIdentityMatches( + const QString &path, quint64 device, quint64 inode); + TryxRuntimeApplyRequest baseApplyRequest() const; + TryxRuntimeApplyRequest fullScreenApplyRequest( + const QStringList &media, const QString &playMode, + const QStringList &metrics, + const QStringList &badges) const; + TryxRuntimeApplyRequest splitScreenApplyRequest( + const QString &leftMedia, const QString &rightMedia, + const QStringList &leftMetrics, + const QStringList &rightMetrics, + const QStringList &leftBadges, + const QStringList &rightBadges) const; + bool metricsSelectionValid( + const QStringList &metrics, bool allowEmpty, + QString *errorMessage) const; + bool badgesSelectionValid( + const QStringList &badges, + QString *errorMessage) const; + bool metricsConfigRequest( + bool enabled, const QStringList &metrics, + const QString &alignment, const QString &color, + TryxRuntimeMetricsConfigRequest *request, + QString *errorMessage) const; + void applyDisplayMutation( + const TryxRuntimeDisplayMutation &mutation); + void applyOperationsSnapshot( + const TryxRuntimeOperationsSnapshot &snapshot); + void applyConnectionSnapshot( + const TryxRuntimeSnapshot &snapshot); + void applyMetricsState(const TryxRuntimeMetricsState &state); + void applyDisplayState(const TryxRuntimeDisplayState &state); + void updateActiveOperation( + const TryxRuntimeOperationInfo &info); + + QDBusConnection bus_; + QDBusServiceWatcher serviceWatcher_; + bool signalsSubscribed_ = false; + bool serviceAvailable_ = false; + bool compatible_ = false; + quint32 apiVersion_ = 0; + TryxRuntimeSnapshot connection_; + TryxRuntimeMetricsState metrics_; + TryxRuntimeDisplayState display_; + MediaCatalogModel mediaModel_; + OperationListModel operationModel_; + QString activeOperationId_; + TryxRuntimeOperationInfo activeOperation_; + LegacyUploadState legacyUpload_; + QTimer legacyUploadDeadline_; + TryxRuntimeApplyRequest pendingLegacyScreenConfig_; + bool pendingLegacyScreenConfigValid_ = false; + QString diagnostic_; + quint64 serviceEpoch_ = 1; + bool offline_ = false; + QList offlineRequests_; +}; diff --git a/src/quick/systemmetricsmodel.cpp b/src/quick/systemmetricsmodel.cpp new file mode 100644 index 0000000..73a0650 --- /dev/null +++ b/src/quick/systemmetricsmodel.cpp @@ -0,0 +1,176 @@ +#include "systemmetricsmodel.h" + +#include + +namespace { + +constexpr int kMetricsUpdateIntervalMs = 2000; + +} // namespace + +SystemMetricsModel::SystemMetricsModel(QObject *parent) + : SystemMetricsModel(true, parent) {} + +SystemMetricsModel::SystemMetricsModel(bool autoStart, QObject *parent) + : QObject(parent), + monitor_(new SystemMonitor(this)), + updateTimer_(new QTimer(this)), + cpuName_(SystemMonitor::cpuModelName().trimmed()) { + updateTimer_->setInterval(kMetricsUpdateIntervalMs); + connect(updateTimer_, &QTimer::timeout, + monitor_, &SystemMonitor::update); + connect(monitor_, &SystemMonitor::metricsUpdated, + this, &SystemMetricsModel::applyMetrics); + if (autoStart) { + updateTimer_->start(); + QTimer::singleShot(0, monitor_, &SystemMonitor::update); + } +} + +bool SystemMetricsModel::sampled() const { + return sampled_; +} + +QString SystemMetricsModel::cpuName() const { + return cpuName_; +} + +double SystemMetricsModel::cpuUsage() const { + return metrics_.cpu.usagePercent; +} + +bool SystemMetricsModel::cpuUsageAvailable() const { + return metrics_.cpu.usageAvailable; +} + +double SystemMetricsModel::cpuTemperature() const { + return metrics_.cpu.temperature; +} + +bool SystemMetricsModel::cpuTemperatureAvailable() const { + return metrics_.cpu.temperatureAvailable; +} + +double SystemMetricsModel::cpuFrequencyMHz() const { + return metrics_.cpu.frequencyMHz; +} + +bool SystemMetricsModel::cpuFrequencyAvailable() const { + return metrics_.cpu.frequencyAvailable; +} + +bool SystemMetricsModel::gpuPresent() const { + return primaryGpu() != nullptr; +} + +QString SystemMetricsModel::gpuName() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu ? gpu->name : QString(); +} + +double SystemMetricsModel::gpuUsage() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu ? gpu->usagePercent : 0.0; +} + +bool SystemMetricsModel::gpuUsageAvailable() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu && gpu->usageAvailable; +} + +double SystemMetricsModel::gpuTemperature() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu ? gpu->temperature : 0.0; +} + +bool SystemMetricsModel::gpuTemperatureAvailable() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu && gpu->temperatureAvailable; +} + +double SystemMetricsModel::gpuFrequencyMHz() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu ? gpu->frequencyMHz : 0.0; +} + +bool SystemMetricsModel::gpuFrequencyAvailable() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu && gpu->frequencyAvailable; +} + +qint64 SystemMetricsModel::gpuVramUsedMB() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu ? gpu->vramUsedMB : 0; +} + +qint64 SystemMetricsModel::gpuVramTotalMB() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu ? gpu->vramTotalMB : 0; +} + +bool SystemMetricsModel::gpuVramAvailable() const { + const GpuMetrics *gpu = primaryGpu(); + return gpu && gpu->vramTotalMB > 0 && + gpu->vramUsedMB >= 0; +} + +double SystemMetricsModel::ramUsage() const { + return metrics_.ram.usagePercent; +} + +bool SystemMetricsModel::ramUsageAvailable() const { + return metrics_.ram.usageAvailable; +} + +qint64 SystemMetricsModel::ramUsedMB() const { + return metrics_.ram.usedMB; +} + +qint64 SystemMetricsModel::ramTotalMB() const { + return metrics_.ram.totalMB; +} + +double SystemMetricsModel::diskUsage() const { + return metrics_.disk.usagePercent; +} + +bool SystemMetricsModel::diskUsageAvailable() const { + return metrics_.disk.usageAvailable; +} + +qint64 SystemMetricsModel::diskUsedGB() const { + return metrics_.disk.usedGB; +} + +qint64 SystemMetricsModel::diskTotalGB() const { + return metrics_.disk.totalGB; +} + +bool SystemMetricsModel::networkAvailable() const { + return metrics_.net.available; +} + +double SystemMetricsModel::rxSpeedKBs() const { + return metrics_.net.rxSpeedKBs; +} + +double SystemMetricsModel::txSpeedKBs() const { + return metrics_.net.txSpeedKBs; +} + +void SystemMetricsModel::refresh() { + monitor_->update(); +} + +void SystemMetricsModel::applyMetrics( + const SystemMetrics &metrics) { + metrics_ = metrics; + sampled_ = true; + emit metricsChanged(); +} + +const GpuMetrics *SystemMetricsModel::primaryGpu() const { + return metrics_.gpus.isEmpty() + ? nullptr + : &metrics_.gpus.constFirst(); +} diff --git a/src/quick/systemmetricsmodel.h b/src/quick/systemmetricsmodel.h new file mode 100644 index 0000000..7726867 --- /dev/null +++ b/src/quick/systemmetricsmodel.h @@ -0,0 +1,116 @@ +#pragma once + +#include "systemmonitor.h" + +#include +#include + +class QTimer; + +class SystemMetricsModel final : public QObject { + Q_OBJECT + Q_PROPERTY(bool sampled READ sampled NOTIFY metricsChanged) + Q_PROPERTY(QString cpuName READ cpuName CONSTANT) + Q_PROPERTY(double cpuUsage READ cpuUsage NOTIFY metricsChanged) + Q_PROPERTY(bool cpuUsageAvailable READ cpuUsageAvailable + NOTIFY metricsChanged) + Q_PROPERTY(double cpuTemperature READ cpuTemperature + NOTIFY metricsChanged) + Q_PROPERTY(bool cpuTemperatureAvailable + READ cpuTemperatureAvailable NOTIFY metricsChanged) + Q_PROPERTY(double cpuFrequencyMHz READ cpuFrequencyMHz + NOTIFY metricsChanged) + Q_PROPERTY(bool cpuFrequencyAvailable + READ cpuFrequencyAvailable NOTIFY metricsChanged) + Q_PROPERTY(bool gpuPresent READ gpuPresent NOTIFY metricsChanged) + Q_PROPERTY(QString gpuName READ gpuName NOTIFY metricsChanged) + Q_PROPERTY(double gpuUsage READ gpuUsage NOTIFY metricsChanged) + Q_PROPERTY(bool gpuUsageAvailable READ gpuUsageAvailable + NOTIFY metricsChanged) + Q_PROPERTY(double gpuTemperature READ gpuTemperature + NOTIFY metricsChanged) + Q_PROPERTY(bool gpuTemperatureAvailable + READ gpuTemperatureAvailable NOTIFY metricsChanged) + Q_PROPERTY(double gpuFrequencyMHz READ gpuFrequencyMHz + NOTIFY metricsChanged) + Q_PROPERTY(bool gpuFrequencyAvailable + READ gpuFrequencyAvailable NOTIFY metricsChanged) + Q_PROPERTY(qint64 gpuVramUsedMB READ gpuVramUsedMB + NOTIFY metricsChanged) + Q_PROPERTY(qint64 gpuVramTotalMB READ gpuVramTotalMB + NOTIFY metricsChanged) + Q_PROPERTY(bool gpuVramAvailable READ gpuVramAvailable + NOTIFY metricsChanged) + Q_PROPERTY(double ramUsage READ ramUsage NOTIFY metricsChanged) + Q_PROPERTY(bool ramUsageAvailable READ ramUsageAvailable + NOTIFY metricsChanged) + Q_PROPERTY(qint64 ramUsedMB READ ramUsedMB NOTIFY metricsChanged) + Q_PROPERTY(qint64 ramTotalMB READ ramTotalMB NOTIFY metricsChanged) + Q_PROPERTY(double diskUsage READ diskUsage NOTIFY metricsChanged) + Q_PROPERTY(bool diskUsageAvailable READ diskUsageAvailable + NOTIFY metricsChanged) + Q_PROPERTY(qint64 diskUsedGB READ diskUsedGB NOTIFY metricsChanged) + Q_PROPERTY(qint64 diskTotalGB READ diskTotalGB NOTIFY metricsChanged) + Q_PROPERTY(bool networkAvailable READ networkAvailable + NOTIFY metricsChanged) + Q_PROPERTY(double rxSpeedKBs READ rxSpeedKBs + NOTIFY metricsChanged) + Q_PROPERTY(double txSpeedKBs READ txSpeedKBs + NOTIFY metricsChanged) + +public: + explicit SystemMetricsModel(QObject *parent = nullptr); + + bool sampled() const; + QString cpuName() const; + double cpuUsage() const; + bool cpuUsageAvailable() const; + double cpuTemperature() const; + bool cpuTemperatureAvailable() const; + double cpuFrequencyMHz() const; + bool cpuFrequencyAvailable() const; + + bool gpuPresent() const; + QString gpuName() const; + double gpuUsage() const; + bool gpuUsageAvailable() const; + double gpuTemperature() const; + bool gpuTemperatureAvailable() const; + double gpuFrequencyMHz() const; + bool gpuFrequencyAvailable() const; + qint64 gpuVramUsedMB() const; + qint64 gpuVramTotalMB() const; + bool gpuVramAvailable() const; + + double ramUsage() const; + bool ramUsageAvailable() const; + qint64 ramUsedMB() const; + qint64 ramTotalMB() const; + + double diskUsage() const; + bool diskUsageAvailable() const; + qint64 diskUsedGB() const; + qint64 diskTotalGB() const; + + bool networkAvailable() const; + double rxSpeedKBs() const; + double txSpeedKBs() const; + + Q_INVOKABLE void refresh(); + +signals: + void metricsChanged(); + +private: + friend class QuickClientTests; + + explicit SystemMetricsModel(bool autoStart, QObject *parent); + void applyMetrics(const SystemMetrics &metrics); + const GpuMetrics *primaryGpu() const; + + SystemMonitor *monitor_; + QTimer *updateTimer_; + SystemMetrics metrics_; + QString cpuName_; + bool sampled_ = false; +}; diff --git a/src/quick/windowchromecontroller.cpp b/src/quick/windowchromecontroller.cpp new file mode 100644 index 0000000..e5e4dcb --- /dev/null +++ b/src/quick/windowchromecontroller.cpp @@ -0,0 +1,150 @@ +#include "windowchromecontroller.h" + +#include + +WindowChromeController::WindowChromeController(QObject *parent) + : QObject(parent) {} + +bool WindowChromeController::ready() const { + return !window_.isNull(); +} + +bool WindowChromeController::maximized() const { + return window_ && + window_->visibility() == QWindow::Maximized; +} + +bool WindowChromeController::trayAvailable() const { + return trayAvailable_; +} + +bool WindowChromeController::hiddenToTray() const { + return hiddenToTray_; +} + +void WindowChromeController::setWindow(QWindow *window) { + if (window_ == window) { + return; + } + if (window_) { + disconnect(window_, nullptr, this, nullptr); + } + setHiddenToTray(false); + restoreMaximized_ = false; + window_ = window; + if (window_) { + connect(window_, &QWindow::visibilityChanged, + this, &WindowChromeController::windowStateChanged); + connect(window_, &QObject::destroyed, this, [this]() { + window_.clear(); + setHiddenToTray(false); + emit windowStateChanged(); + }); + } + emit windowStateChanged(); +} + +void WindowChromeController::setTrayAvailable(bool available) { + if (trayAvailable_ == available) { + return; + } + trayAvailable_ = available; + emit trayAvailabilityChanged(); + + // Never leave the GUI alive but unreachable after the desktop removes + // its StatusNotifier host or the watcher process restarts. + if (!trayAvailable_ && hiddenToTray_) { + showWindow(); + } +} + +bool WindowChromeController::startMove() { + if (!window_ || maximized()) { + return false; + } + return window_->startSystemMove(); +} + +bool WindowChromeController::startResize(int edges) { + const Qt::Edges requested(edges); + if (!window_ || maximized() || + !validResizeEdges(requested)) { + return false; + } + return window_->startSystemResize(requested); +} + +void WindowChromeController::minimize() { + if (window_) { + window_->showMinimized(); + } +} + +void WindowChromeController::toggleMaximized() { + if (!window_) { + return; + } + if (maximized()) { + window_->showNormal(); + } else { + window_->showMaximized(); + } +} + +void WindowChromeController::closeWindow() { + if (window_) { + window_->close(); + } +} + +bool WindowChromeController::handleCloseRequest() { + if (!window_ || !trayAvailable_) { + return false; + } + + restoreMaximized_ = + window_->visibility() == QWindow::Maximized; + setHiddenToTray(true); + window_->hide(); + return true; +} + +void WindowChromeController::showWindow() { + if (!window_) { + setHiddenToTray(false); + return; + } + + const bool restoreMaximized = restoreMaximized_; + setHiddenToTray(false); + if (restoreMaximized) { + window_->showMaximized(); + } else if (window_->visibility() == QWindow::Minimized) { + window_->showNormal(); + } else { + window_->show(); + } + restoreMaximized_ = false; + window_->raise(); + window_->requestActivate(); +} + +bool WindowChromeController::validResizeEdges( + Qt::Edges edges) { + return edges == Qt::LeftEdge || + edges == Qt::RightEdge || + edges == Qt::TopEdge || + edges == Qt::BottomEdge || + edges == (Qt::LeftEdge | Qt::TopEdge) || + edges == (Qt::RightEdge | Qt::TopEdge) || + edges == (Qt::LeftEdge | Qt::BottomEdge) || + edges == (Qt::RightEdge | Qt::BottomEdge); +} + +void WindowChromeController::setHiddenToTray(bool hidden) { + if (hiddenToTray_ == hidden) { + return; + } + hiddenToTray_ = hidden; + emit hiddenToTrayChanged(); +} diff --git a/src/quick/windowchromecontroller.h b/src/quick/windowchromecontroller.h new file mode 100644 index 0000000..5273cec --- /dev/null +++ b/src/quick/windowchromecontroller.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +class QWindow; + +class WindowChromeController final : public QObject { + Q_OBJECT + Q_PROPERTY(bool ready READ ready NOTIFY windowStateChanged) + Q_PROPERTY(bool maximized READ maximized NOTIFY windowStateChanged) + Q_PROPERTY(bool trayAvailable READ trayAvailable + WRITE setTrayAvailable + NOTIFY trayAvailabilityChanged) + Q_PROPERTY(bool hiddenToTray READ hiddenToTray + NOTIFY hiddenToTrayChanged) + +public: + explicit WindowChromeController(QObject *parent = nullptr); + + bool ready() const; + bool maximized() const; + bool trayAvailable() const; + bool hiddenToTray() const; + void setWindow(QWindow *window); + void setTrayAvailable(bool available); + + Q_INVOKABLE bool startMove(); + Q_INVOKABLE bool startResize(int edges); + Q_INVOKABLE void minimize(); + Q_INVOKABLE void toggleMaximized(); + Q_INVOKABLE void closeWindow(); + Q_INVOKABLE bool handleCloseRequest(); + Q_INVOKABLE void showWindow(); + +signals: + void windowStateChanged(); + void trayAvailabilityChanged(); + void hiddenToTrayChanged(); + +private: + static bool validResizeEdges(Qt::Edges edges); + void setHiddenToTray(bool hidden); + + QPointer window_; + bool trayAvailable_ = false; + bool hiddenToTray_ = false; + bool restoreMaximized_ = false; +}; diff --git a/src/replacejournal.cpp b/src/replacejournal.cpp new file mode 100644 index 0000000..3358ffd --- /dev/null +++ b/src/replacejournal.cpp @@ -0,0 +1,802 @@ +#include "replacejournal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr quint64 kMaximumMediaBytes = + 500ULL * 1024ULL * 1024ULL; +constexpr qsizetype kMaximumReferenceNames = 16; +constexpr qsizetype kMaximumDeviceIdentityLength = 256; + +const QStringList &allowedStages() { + static const QStringList values{ + QStringLiteral("Preflight"), + QStringLiteral("Preparing"), + QStringLiteral("Uploading"), + QStringLiteral("UploadVerified"), + QStringLiteral("Applying"), + QStringLiteral("ApplyVerification"), + QStringLiteral("ReferenceReconciliation"), + QStringLiteral("DeleteIntentLinked"), + QStringLiteral("Deleting"), + QStringLiteral("DeleteReconciliation"), + QStringLiteral("Terminal"), + }; + return values; +} + +const QSet &allowedDispositions() { + static const QSet values{ + QStringLiteral("OriginalRetained"), + QStringLiteral("NewCopyReady"), + QStringLiteral("Replaced"), + QStringLiteral("PartialOrUnknown"), + }; + return values; +} + +const QSet &exactJsonKeys() { + static const QSet values{ + QStringLiteral("version"), + QStringLiteral("operationId"), + QStringLiteral("deviceIdentity"), + QStringLiteral("deviceGeneration"), + QStringLiteral("originalMediaId"), + QStringLiteral("originalRemoteName"), + QStringLiteral("originalSize"), + QStringLiteral("artifactId"), + QStringLiteral("decodedSha256"), + QStringLiteral("transformFingerprint"), + QStringLiteral("applyFingerprint"), + QStringLiteral("referenceNames"), + QStringLiteral("newRemoteName"), + QStringLiteral("newSize"), + QStringLiteral("stage"), + QStringLiteral("uploadVerified"), + QStringLiteral("applyMayHaveStarted"), + QStringLiteral("applyVerified"), + QStringLiteral("deleteIntentLinked"), + QStringLiteral("fileRemoveMayHaveStarted"), + QStringLiteral("disposition"), + }; + return values; +} + +bool setError(QString *errorMessage, const QString &message) { + if (errorMessage) { + *errorMessage = message; + } + return false; +} + +bool isCanonicalUuid(const QString &value) { + const QUuid parsed(value); + return !parsed.isNull() && + parsed.toString(QUuid::WithoutBraces) == value; +} + +bool isSha256(const QString &value) { + if (value.size() != 64) { + return false; + } + for (const QChar character : value) { + const bool decimal = + character >= QLatin1Char('0') && + character <= QLatin1Char('9'); + const bool hexadecimal = + character >= QLatin1Char('a') && + character <= QLatin1Char('f'); + if (!decimal && !hexadecimal) { + return false; + } + } + return true; +} + +bool isSafeText(const QString &value, qsizetype maximumLength) { + if (value.isEmpty() || value.size() > maximumLength || + value.trimmed() != value) { + return false; + } + for (const QChar character : value) { + const ushort code = character.unicode(); + if (code < 0x20 || code == 0x7f) { + return false; + } + } + return true; +} + +bool isSafeRemoteName(const QString &value) { + if (value.isEmpty() || value.size() > 128 || + value.startsWith(QLatin1Char('.'))) { + return false; + } + for (const QChar character : value) { + const bool decimal = + character >= QLatin1Char('0') && + character <= QLatin1Char('9'); + const bool lower = + character >= QLatin1Char('a') && + character <= QLatin1Char('z'); + const bool upper = + character >= QLatin1Char('A') && + character <= QLatin1Char('Z'); + if (decimal || lower || upper || + character == QLatin1Char('.') || + character == QLatin1Char('_') || + character == QLatin1Char('-')) { + continue; + } + return false; + } + const QString lower = value.toLower(); + return lower.endsWith(QStringLiteral(".mp4")) || + lower.endsWith(QStringLiteral(".png")) || + lower.endsWith(QStringLiteral(".gif")) || + lower.endsWith( + QStringLiteral(".mp4.h264_2240x1080")) || + lower.endsWith( + QStringLiteral(".png.h264_2240x1080")) || + lower.endsWith( + QStringLiteral(".gif.h264_2240x1080")); +} + +bool parseSize(const QJsonValue &value, quint64 *result) { + if (!result || !value.isString()) { + return false; + } + const QString encoded = value.toString(); + if (encoded.isEmpty() || + (encoded.size() > 1 && encoded.startsWith(QLatin1Char('0')))) { + return false; + } + for (const QChar character : encoded) { + if (character < QLatin1Char('0') || + character > QLatin1Char('9')) { + return false; + } + } + bool ok = false; + const quint64 parsed = encoded.toULongLong(&ok); + if (!ok || QString::number(parsed) != encoded) { + return false; + } + *result = parsed; + return true; +} + +bool fileStatusIsSafe(const struct stat &status) { + return S_ISREG(status.st_mode) && + status.st_uid == ::geteuid() && + (status.st_mode & 07777) == + (S_IRUSR | S_IWUSR) && + status.st_nlink == 1 && status.st_size > 0 && + status.st_size <= TryxReplaceJournal::MaximumBytes; +} + +bool inspectExistingFile(const QString &path, struct stat *status, + bool *missing, QString *errorMessage) { + if (!status || !missing) { + return setError( + errorMessage, + QStringLiteral("Replace journal validation is unavailable")); + } + *missing = false; + const QByteArray encoded = QFile::encodeName(path); + if (::lstat(encoded.constData(), status) != 0) { + if (errno == ENOENT) { + *missing = true; + return true; + } + return setError( + errorMessage, + QStringLiteral("Cannot inspect replace journal: %1") + .arg(QString::fromLocal8Bit(std::strerror(errno)))); + } + if (!fileStatusIsSafe(*status)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal must be one owner-only 0600 regular file")); + } + return true; +} + +bool syncParentDirectory(const QString &path, QString *errorMessage) { + const QByteArray directory = QFile::encodeName( + QFileInfo(path).absolutePath()); + const int descriptor = ::open( + directory.constData(), + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (descriptor < 0) { + return setError( + errorMessage, + QStringLiteral("Cannot open replace journal directory: %1") + .arg(QString::fromLocal8Bit(std::strerror(errno)))); + } + const bool synced = ::fsync(descriptor) == 0; + const int savedError = errno; + ::close(descriptor); + if (!synced) { + return setError( + errorMessage, + QStringLiteral("Cannot sync replace journal directory: %1") + .arg(QString::fromLocal8Bit( + std::strerror(savedError)))); + } + return true; +} + +QJsonObject recordToJson(const TryxReplaceJournalRecord &record) { + QJsonArray references; + for (const QString &name : record.referenceNames) { + references.append(name); + } + + QJsonObject object; + object.insert(QStringLiteral("version"), + TryxReplaceJournal::FormatVersion); + object.insert(QStringLiteral("operationId"), record.operationId); + object.insert(QStringLiteral("deviceIdentity"), + record.deviceIdentity); + object.insert(QStringLiteral("deviceGeneration"), + QString::number(record.deviceGeneration)); + object.insert(QStringLiteral("originalMediaId"), + record.originalMediaId); + object.insert(QStringLiteral("originalRemoteName"), + record.originalRemoteName); + object.insert(QStringLiteral("originalSize"), + QString::number(record.originalSize)); + object.insert(QStringLiteral("artifactId"), record.artifactId); + object.insert(QStringLiteral("decodedSha256"), + record.decodedSha256); + object.insert(QStringLiteral("transformFingerprint"), + record.transformFingerprint); + object.insert(QStringLiteral("applyFingerprint"), + record.applyFingerprint); + object.insert(QStringLiteral("referenceNames"), references); + object.insert(QStringLiteral("newRemoteName"), + record.newRemoteName); + object.insert(QStringLiteral("newSize"), + QString::number(record.newSize)); + object.insert(QStringLiteral("stage"), record.stage); + object.insert(QStringLiteral("uploadVerified"), + record.uploadVerified); + object.insert(QStringLiteral("applyMayHaveStarted"), + record.applyMayHaveStarted); + object.insert(QStringLiteral("applyVerified"), + record.applyVerified); + object.insert(QStringLiteral("deleteIntentLinked"), + record.deleteIntentLinked); + object.insert(QStringLiteral("fileRemoveMayHaveStarted"), + record.fileRemoveMayHaveStarted); + object.insert(QStringLiteral("disposition"), + record.disposition); + return object; +} + +bool jsonToRecord(const QJsonObject &object, + TryxReplaceJournalRecord *record, + QString *errorMessage) { + const QStringList keys = object.keys(); + const QSet actualKeys(keys.cbegin(), keys.cend()); + if (!record || actualKeys != exactJsonKeys() || + !object.value(QStringLiteral("version")).isDouble() || + object.value(QStringLiteral("version")).toDouble(-1.0) != + static_cast( + TryxReplaceJournal::FormatVersion)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal has an unsupported JSON shape")); + } + + static const QStringList stringFields{ + QStringLiteral("operationId"), + QStringLiteral("deviceIdentity"), + QStringLiteral("originalMediaId"), + QStringLiteral("originalRemoteName"), + QStringLiteral("artifactId"), + QStringLiteral("decodedSha256"), + QStringLiteral("transformFingerprint"), + QStringLiteral("applyFingerprint"), + QStringLiteral("newRemoteName"), + QStringLiteral("stage"), + QStringLiteral("disposition"), + }; + static const QStringList boolFields{ + QStringLiteral("uploadVerified"), + QStringLiteral("applyMayHaveStarted"), + QStringLiteral("applyVerified"), + QStringLiteral("deleteIntentLinked"), + QStringLiteral("fileRemoveMayHaveStarted"), + }; + for (const QString &field : stringFields) { + if (!object.value(field).isString()) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal field %1 has an invalid type") + .arg(field)); + } + } + for (const QString &field : boolFields) { + if (!object.value(field).isBool()) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal field %1 has an invalid type") + .arg(field)); + } + } + if (!object.value(QStringLiteral("referenceNames")).isArray()) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal referenceNames has an invalid type")); + } + + TryxReplaceJournalRecord parsed; + parsed.operationId = + object.value(QStringLiteral("operationId")).toString(); + parsed.deviceIdentity = + object.value(QStringLiteral("deviceIdentity")).toString(); + parsed.originalMediaId = + object.value(QStringLiteral("originalMediaId")).toString(); + parsed.originalRemoteName = + object.value(QStringLiteral("originalRemoteName")).toString(); + parsed.artifactId = + object.value(QStringLiteral("artifactId")).toString(); + parsed.decodedSha256 = + object.value(QStringLiteral("decodedSha256")).toString(); + parsed.transformFingerprint = + object.value(QStringLiteral("transformFingerprint")).toString(); + parsed.applyFingerprint = + object.value(QStringLiteral("applyFingerprint")).toString(); + parsed.newRemoteName = + object.value(QStringLiteral("newRemoteName")).toString(); + parsed.stage = object.value(QStringLiteral("stage")).toString(); + parsed.uploadVerified = + object.value(QStringLiteral("uploadVerified")).toBool(); + parsed.applyMayHaveStarted = + object.value(QStringLiteral("applyMayHaveStarted")).toBool(); + parsed.applyVerified = + object.value(QStringLiteral("applyVerified")).toBool(); + parsed.deleteIntentLinked = + object.value(QStringLiteral("deleteIntentLinked")).toBool(); + parsed.fileRemoveMayHaveStarted = + object.value(QStringLiteral("fileRemoveMayHaveStarted")).toBool(); + parsed.disposition = + object.value(QStringLiteral("disposition")).toString(); + if (!parseSize(object.value(QStringLiteral("deviceGeneration")), + &parsed.deviceGeneration) || + !parseSize(object.value(QStringLiteral("originalSize")), + &parsed.originalSize) || + !parseSize(object.value(QStringLiteral("newSize")), + &parsed.newSize)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal contains an invalid media size")); + } + + const QJsonArray references = + object.value(QStringLiteral("referenceNames")).toArray(); + for (const QJsonValue &value : references) { + if (!value.isString()) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal contains a non-string reference")); + } + parsed.referenceNames.append(value.toString()); + } + if (!TryxReplaceJournal::validateRecord(parsed, errorMessage)) { + return false; + } + *record = parsed; + return true; +} + +bool immutableIdentityMatches( + const TryxReplaceJournalRecord ¤t, + const TryxReplaceJournalRecord &next) { + return current.operationId == next.operationId && + current.deviceIdentity == next.deviceIdentity && + current.deviceGeneration == next.deviceGeneration && + current.originalMediaId == next.originalMediaId && + current.originalRemoteName == next.originalRemoteName && + current.originalSize == next.originalSize && + current.artifactId == next.artifactId && + current.decodedSha256 == next.decodedSha256 && + current.transformFingerprint == + next.transformFingerprint && + current.applyFingerprint == next.applyFingerprint && + current.referenceNames == next.referenceNames; +} + +bool validateTransition(const TryxReplaceJournalRecord ¤t, + const TryxReplaceJournalRecord &next, + QString *errorMessage) { + if (!immutableIdentityMatches(current, next)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal immutable identity cannot change")); + } + if (current.stage == QStringLiteral("Terminal") && + recordToJson(current) != recordToJson(next)) { + return setError( + errorMessage, + QStringLiteral( + "A terminal replace journal cannot change")); + } + const int currentStage = + allowedStages().indexOf(current.stage); + const int nextStage = allowedStages().indexOf(next.stage); + if (nextStage < currentStage || + (current.uploadVerified && !next.uploadVerified) || + (current.applyMayHaveStarted && + !next.applyMayHaveStarted) || + (current.applyVerified && !next.applyVerified) || + (current.deleteIntentLinked && + !next.deleteIntentLinked) || + (current.fileRemoveMayHaveStarted && + !next.fileRemoveMayHaveStarted)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal safety state cannot move backwards")); + } + if ((!current.newRemoteName.isEmpty() && + current.newRemoteName != next.newRemoteName) || + (current.newSize != 0 && current.newSize != next.newSize)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal verified remote identity cannot change")); + } + if (current.disposition == QStringLiteral("Replaced") && + next.disposition != current.disposition) { + return setError( + errorMessage, + QStringLiteral( + "A completed replacement disposition cannot change")); + } + if (current.disposition == QStringLiteral("NewCopyReady") && + next.disposition == QStringLiteral("OriginalRetained")) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal disposition cannot lose a verified copy")); + } + return true; +} + +} // namespace + +TryxReplaceJournal::TryxReplaceJournal(QString path) + : path_(QFileInfo(path).absoluteFilePath()) {} + +QString TryxReplaceJournal::path() const { + return path_; +} + +TryxReplaceJournalLoadResult TryxReplaceJournal::load() const { + TryxReplaceJournalLoadResult result; + struct stat before {}; + bool missing = false; + if (!inspectExistingFile(path_, &before, &missing, &result.error)) { + result.status = TryxReplaceJournalLoadStatus::Invalid; + return result; + } + if (missing) { + return result; + } + + const QByteArray encoded = QFile::encodeName(path_); + const int descriptor = + ::open(encoded.constData(), + O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (descriptor < 0) { + result.status = TryxReplaceJournalLoadStatus::Invalid; + result.error = + QStringLiteral("Cannot open replace journal safely: %1") + .arg(QString::fromLocal8Bit(std::strerror(errno))); + return result; + } + struct stat after {}; + if (::fstat(descriptor, &after) != 0 || + !fileStatusIsSafe(after) || + before.st_dev != after.st_dev || + before.st_ino != after.st_ino || + before.st_size != after.st_size) { + ::close(descriptor); + result.status = TryxReplaceJournalLoadStatus::Invalid; + result.error = QStringLiteral( + "Replace journal identity changed while it was opened"); + return result; + } + + QFile file; + if (!file.open(descriptor, QIODevice::ReadOnly, + QFileDevice::AutoCloseHandle)) { + ::close(descriptor); + result.status = TryxReplaceJournalLoadStatus::Invalid; + result.error = QStringLiteral( + "Cannot read replace journal safely"); + return result; + } + const QByteArray payload = file.read(MaximumBytes + 1); + if (payload.size() != after.st_size || !file.atEnd()) { + result.status = TryxReplaceJournalLoadStatus::Invalid; + result.error = QStringLiteral( + "Replace journal size changed while it was read"); + return result; + } + + QJsonParseError parseError; + const QJsonDocument document = + QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || + !document.isObject() || + !jsonToRecord(document.object(), &result.record, + &result.error)) { + result.status = TryxReplaceJournalLoadStatus::Invalid; + if (result.error.isEmpty()) { + result.error = + QStringLiteral("Replace journal contains malformed JSON"); + } + return result; + } + result.status = TryxReplaceJournalLoadStatus::Loaded; + return result; +} + +bool TryxReplaceJournal::write( + const TryxReplaceJournalRecord &record, + QString *errorMessage) const { + if (!validateRecord(record, errorMessage)) { + return false; + } + + const TryxReplaceJournalLoadResult existing = load(); + if (existing.status == TryxReplaceJournalLoadStatus::Invalid) { + return setError( + errorMessage, + QStringLiteral( + "Refusing to overwrite an invalid replace journal: %1") + .arg(existing.error)); + } + if (existing.status == TryxReplaceJournalLoadStatus::Loaded && + !validateTransition(existing.record, record, errorMessage)) { + return false; + } + + const QFileInfo destination(path_); + const QFileInfo directory(destination.absolutePath()); + if (!directory.exists() || !directory.isDir() || + directory.isSymLink() || directory.ownerId() != ::geteuid()) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal directory is unavailable or unsafe")); + } + + const QByteArray payload = + QJsonDocument(recordToJson(record)) + .toJson(QJsonDocument::Compact); + if (payload.isEmpty() || payload.size() > MaximumBytes) { + return setError( + errorMessage, + QStringLiteral("Replace journal exceeds its size limit")); + } + + QSaveFile file(path_); + if (!file.open(QIODevice::WriteOnly)) { + return setError(errorMessage, file.errorString()); + } + if (!file.setPermissions( + QFileDevice::ReadOwner | QFileDevice::WriteOwner) || + file.write(payload) != payload.size() || + !file.commit()) { + file.cancelWriting(); + return setError(errorMessage, file.errorString()); + } + + struct stat status {}; + bool missing = false; + QString validationError; + if (!inspectExistingFile( + path_, &status, &missing, &validationError) || + missing) { + return setError( + errorMessage, + validationError.isEmpty() + ? QStringLiteral( + "Committed replace journal is unavailable") + : validationError); + } + return syncParentDirectory(path_, errorMessage); +} + +bool TryxReplaceJournal::clear(QString *errorMessage) const { + const TryxReplaceJournalLoadResult existing = load(); + if (existing.status == TryxReplaceJournalLoadStatus::Invalid) { + return setError( + errorMessage, + QStringLiteral( + "Refusing to clear an invalid replace journal: %1") + .arg(existing.error)); + } + if (existing.status == TryxReplaceJournalLoadStatus::Missing) { + if (errorMessage) { + errorMessage->clear(); + } + return true; + } + + struct stat status {}; + bool missing = false; + if (!inspectExistingFile(path_, &status, &missing, errorMessage)) { + return false; + } + if (missing) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal disappeared before it could be cleared")); + } + + const QByteArray encoded = QFile::encodeName(path_); + if (::unlink(encoded.constData()) != 0) { + return setError( + errorMessage, + QStringLiteral("Cannot remove replace journal: %1") + .arg(QString::fromLocal8Bit(std::strerror(errno)))); + } + return syncParentDirectory(path_, errorMessage); +} + +bool TryxReplaceJournal::validateRecord( + const TryxReplaceJournalRecord &record, + QString *errorMessage) { + if (!isCanonicalUuid(record.operationId) || + !isCanonicalUuid(record.artifactId) || + !isSafeText(record.deviceIdentity, + kMaximumDeviceIdentityLength) || + record.deviceGeneration == 0 || + !isSha256(record.originalMediaId) || + !isSafeRemoteName(record.originalRemoteName) || + record.originalSize == 0 || + record.originalSize > kMaximumMediaBytes || + !isSha256(record.decodedSha256) || + !isSha256(record.transformFingerprint) || + !isSha256(record.applyFingerprint)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal contains an invalid immutable identity")); + } + + if (record.referenceNames.size() > + kMaximumReferenceNames) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal contains too many media references")); + } + QSet uniqueReferences; + for (const QString &reference : record.referenceNames) { + if (!isSafeRemoteName(reference) || + uniqueReferences.contains(reference)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal contains an invalid media reference")); + } + uniqueReferences.insert(reference); + } + + const bool hasNewRemoteName = + !record.newRemoteName.isEmpty(); + const bool hasNewRemoteSize = record.newSize > 0; + const bool hasVerifiedRemoteIdentity = + hasNewRemoteName && hasNewRemoteSize; + if (hasNewRemoteName != hasNewRemoteSize || + record.uploadVerified != hasVerifiedRemoteIdentity || + (hasNewRemoteName && + !isSafeRemoteName(record.newRemoteName)) || + record.newSize > kMaximumMediaBytes || + (record.applyMayHaveStarted && !record.uploadVerified) || + (record.applyVerified && + !record.applyMayHaveStarted) || + (record.deleteIntentLinked && + (!record.uploadVerified || + !record.applyVerified)) || + (record.fileRemoveMayHaveStarted && + !record.deleteIntentLinked)) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal safety flags are inconsistent")); + } + + const int stageIndex = allowedStages().indexOf(record.stage); + if (stageIndex < 0 || + !allowedDispositions().contains(record.disposition) || + (record.stage == QStringLiteral("Terminal") && + record.disposition == + QStringLiteral("PartialOrUnknown"))) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal stage or disposition is unsupported")); + } + const QSet stagesAfterVerifiedUpload{ + QStringLiteral("UploadVerified"), + QStringLiteral("Applying"), + QStringLiteral("ApplyVerification"), + QStringLiteral("ReferenceReconciliation"), + QStringLiteral("DeleteIntentLinked"), + QStringLiteral("Deleting"), + QStringLiteral("DeleteReconciliation"), + }; + const QSet stagesAfterDeleteIntent{ + QStringLiteral("DeleteIntentLinked"), + QStringLiteral("Deleting"), + QStringLiteral("DeleteReconciliation"), + }; + const QSet stagesAfterFileRemoveDispatch{ + QStringLiteral("Deleting"), + QStringLiteral("DeleteReconciliation"), + QStringLiteral("Terminal"), + }; + if ((stagesAfterVerifiedUpload.contains(record.stage) && + !record.uploadVerified) || + (stagesAfterDeleteIntent.contains(record.stage) && + !record.deleteIntentLinked) || + (record.fileRemoveMayHaveStarted && + !stagesAfterFileRemoveDispatch.contains(record.stage))) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal stage contradicts its safety flags")); + } + + if ((record.disposition == QStringLiteral("OriginalRetained") && + record.uploadVerified) || + (record.disposition == QStringLiteral("NewCopyReady") && + !record.uploadVerified) || + (record.disposition == QStringLiteral("Replaced") && + (!record.uploadVerified || + !record.applyVerified || + !record.deleteIntentLinked || + !record.fileRemoveMayHaveStarted || + record.stage != QStringLiteral("Terminal")))) { + return setError( + errorMessage, + QStringLiteral( + "Replace journal disposition is not proven by its state")); + } + if (errorMessage) { + errorMessage->clear(); + } + return true; +} diff --git a/src/replacejournal.h b/src/replacejournal.h new file mode 100644 index 0000000..0b8e340 --- /dev/null +++ b/src/replacejournal.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +enum class TryxReplaceJournalLoadStatus { + Missing, + Loaded, + Invalid +}; + +struct TryxReplaceJournalRecord { + QString operationId; + QString deviceIdentity; + quint64 deviceGeneration = 0; + QString originalMediaId; + QString originalRemoteName; + quint64 originalSize = 0; + QString artifactId; + QString decodedSha256; + QString transformFingerprint; + QString applyFingerprint; + QStringList referenceNames; + QString newRemoteName; + quint64 newSize = 0; + QString stage = QStringLiteral("Preflight"); + bool uploadVerified = false; + bool applyMayHaveStarted = false; + bool applyVerified = false; + bool deleteIntentLinked = false; + bool fileRemoveMayHaveStarted = false; + QString disposition = QStringLiteral("OriginalRetained"); +}; + +struct TryxReplaceJournalLoadResult { + TryxReplaceJournalLoadStatus status = + TryxReplaceJournalLoadStatus::Missing; + TryxReplaceJournalRecord record; + QString error; +}; + +class TryxReplaceJournal final { +public: + static constexpr int FormatVersion = 1; + static constexpr qint64 MaximumBytes = 256 * 1024; + + explicit TryxReplaceJournal(QString path); + + QString path() const; + TryxReplaceJournalLoadResult load() const; + bool write(const TryxReplaceJournalRecord &record, + QString *errorMessage = nullptr) const; + bool clear(QString *errorMessage = nullptr) const; + + static bool validateRecord(const TryxReplaceJournalRecord &record, + QString *errorMessage = nullptr); + +private: + QString path_; +}; diff --git a/src/runtime/main.cpp b/src/runtime/main.cpp new file mode 100644 index 0000000..cd3b8c4 --- /dev/null +++ b/src/runtime/main.cpp @@ -0,0 +1,329 @@ +#include "devicemanager.h" +#include "firmwarebridge.h" +#include "runtimebridge.h" + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +volatile std::sig_atomic_t shutdownSignalWriteFd = -1; + +void captureShutdownSignal(int signalNumber) { + const int savedErrno = errno; + const int writeFd = + static_cast(shutdownSignalWriteFd); + if (writeFd >= 0) { + const unsigned char signalByte = + static_cast(signalNumber); + const ssize_t ignored = + ::write(writeFd, &signalByte, sizeof(signalByte)); + (void)ignored; + } + errno = savedErrno; +} + +class ShutdownSignalPipe final { +public: + ~ShutdownSignalPipe() { + restore(); + } + + bool install(QString *errorMessage) { + if (::pipe2( + fileDescriptors_, + O_CLOEXEC | O_NONBLOCK) != 0) { + setError( + errorMessage, + QStringLiteral( + "Failed to create the shutdown signal pipe: %1") + .arg(systemError())); + return false; + } + + struct sigaction action {}; + action.sa_handler = captureShutdownSignal; + ::sigemptyset(&action.sa_mask); + action.sa_flags = SA_RESTART; + + shutdownSignalWriteFd = fileDescriptors_[1]; + if (::sigaction( + SIGTERM, &action, &previousSigterm_) != 0) { + setError( + errorMessage, + QStringLiteral( + "Failed to install the SIGTERM handler: %1") + .arg(systemError())); + closePipe(); + return false; + } + sigtermInstalled_ = true; + + if (::sigaction( + SIGINT, &action, &previousSigint_) != 0) { + setError( + errorMessage, + QStringLiteral( + "Failed to install the SIGINT handler: %1") + .arg(systemError())); + restore(); + return false; + } + sigintInstalled_ = true; + return true; + } + + int readFd() const { + return fileDescriptors_[0]; + } + + bool drain(QString *errorMessage) const { + bool signalReceived = false; + unsigned char buffer[32]; + for (;;) { + const ssize_t bytesRead = + ::read(fileDescriptors_[0], buffer, sizeof(buffer)); + if (bytesRead > 0) { + signalReceived = true; + continue; + } + if (bytesRead == 0) { + return signalReceived; + } + if (errno == EINTR) { + continue; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return signalReceived; + } + + setError( + errorMessage, + QStringLiteral( + "Failed to read the shutdown signal pipe: %1") + .arg(systemError())); + return signalReceived; + } + } + +private: + static QString systemError() { + const int errorNumber = errno; + return QString::fromLocal8Bit( + std::strerror(errorNumber)); + } + + static void setError(QString *errorMessage, + const QString &message) { + if (errorMessage) { + *errorMessage = message; + } + } + + void restore() { + shutdownSignalWriteFd = -1; + if (sigintInstalled_) { + ::sigaction( + SIGINT, &previousSigint_, nullptr); + sigintInstalled_ = false; + } + if (sigtermInstalled_) { + ::sigaction( + SIGTERM, &previousSigterm_, nullptr); + sigtermInstalled_ = false; + } + closePipe(); + } + + void closePipe() { + shutdownSignalWriteFd = -1; + for (int &fileDescriptor : fileDescriptors_) { + if (fileDescriptor >= 0) { + ::close(fileDescriptor); + fileDescriptor = -1; + } + } + } + + int fileDescriptors_[2] = {-1, -1}; + struct sigaction previousSigterm_ {}; + struct sigaction previousSigint_ {}; + bool sigtermInstalled_ = false; + bool sigintInstalled_ = false; +}; + +void configureApplicationIdentity(QCoreApplication &app) { + app.setApplicationName(QStringLiteral("TRYX Panorama Runtime")); + app.setApplicationVersion(QStringLiteral(TRYX_APP_VERSION)); + app.setOrganizationName(QStringLiteral("DXVSI")); +} + +} // namespace + +int main(int argc, char *argv[]) { + for (int index = 1; index < argc; ++index) { + if (qstrcmp(argv[index], "--version") == 0) { + std::fputs( + "tryx-panorama-runtime " TRYX_APP_VERSION "\n", + stdout); + return 0; + } + } + + setenv("GST_DEBUG", "0", 0); + setenv("PIPEWIRE_LOG_LEVEL", "0", 0); + QLoggingCategory::setFilterRules( + QStringLiteral( + "qt.multimedia.*=false\n" + "qt.core.qfuture.*=false\n")); + + QCoreApplication app(argc, argv); + configureApplicationIdentity(app); + registerTryxRuntimeMetaTypes(); + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + qCritical() << "The user D-Bus session is unavailable"; + return 2; + } + + // Acquire the singleton name before DeviceManager construction. Its + // constructor owns local runtime cleanup and starts device discovery, so a + // second process must fail before it can touch the device or spool. + if (!bus.registerService(tryxRuntimeServiceName())) { + qCritical() << "Failed to acquire TRYX D-Bus service name:" + << bus.lastError().message(); + return 4; + } + + // Keep the async-signal-safe pipe alive until all objects that own worker + // threads have been destroyed. + ShutdownSignalPipe shutdownSignalPipe; + DeviceManager manager; + TryxRuntimeExportedObject exportedObject; + TryxRuntimeManagerAdaptor connectionAdaptor( + &exportedObject, &manager); + TryxRuntimeOperationsAdaptor operationsAdaptor( + &exportedObject, &manager, &connectionAdaptor); + FirmwareBridge firmwareBridge(&manager, &exportedObject); + FirmwareAdaptor firmwareAdaptor( + &exportedObject, &firmwareBridge); + + QString shutdownSignalError; + if (!shutdownSignalPipe.install(&shutdownSignalError)) { + qCritical().noquote() << shutdownSignalError; + bus.unregisterService(tryxRuntimeServiceName()); + return 5; + } + + QSocketNotifier shutdownSignalNotifier( + shutdownSignalPipe.readFd(), + QSocketNotifier::Read, + &app); + bool shutdownRequested = false; + QObject::connect( + &shutdownSignalNotifier, + &QSocketNotifier::activated, + &app, + [&](QSocketDescriptor, QSocketNotifier::Type) { + QString drainError; + if (!shutdownSignalPipe.drain(&drainError)) { + if (!drainError.isEmpty()) { + qWarning().noquote() << drainError; + } + return; + } + if (shutdownRequested) { + return; + } + + shutdownRequested = true; + firmwareBridge.prepareForShutdown(); + if (firmwareBridge.shutdownInhibited()) { + qInfo() << "Shutdown requested while firmware flashing is" + " active; waiting for the updater to finish"; + return; + } + app.quit(); + }); + QObject::connect( + &firmwareBridge, + &FirmwareBridge::shutdownInhibitionChanged, + &app, + [&](bool inhibited) { + if (shutdownRequested && !inhibited) { + qInfo() << "Firmware updater finished; completing the" + " deferred shutdown"; + app.quit(); + } + }); + + if (!bus.registerObject( + tryxRuntimeObjectPath(), &exportedObject, + QDBusConnection::ExportAdaptors)) { + qCritical() << "Failed to register TRYX D-Bus object:" + << bus.lastError().message(); + bus.unregisterService(tryxRuntimeServiceName()); + return 3; + } + + QObject::connect( + &manager, &DeviceManager::deviceError, + &app, [](const QString &message) { + qWarning().noquote() << message; + }); + QObject::connect( + &manager, &DeviceManager::uploadStatus, + &app, [](const QString &message) { + qInfo().noquote() << message; + }); + QObject::connect( + &manager, + &DeviceManager::printerDisplaySessionChanged, + &app, [](bool active) { + qInfo() << "PASE display session active:" << active; + }); + + const auto loadedConfig = + panorama::ConfigManager::load_config(); + const panorama::Config config = + loadedConfig.value_or(panorama::Config{}); + if (!loadedConfig) { + qWarning() << "The runtime could not read the saved configuration;" + " using safe built-in connection defaults"; + } + QObject::connect( + &manager, &DeviceManager::deviceConnected, + &app, [&manager, keepalive = config.keepalive_interval]( + const QString &, const QString &, + const QString &, const QString &) { + if (!manager.isPrinterClassDevicePresent()) { + manager.startKeepalive(qBound(5, keepalive, 60)); + } + }); + + if (firmwareBridge.recoveryRequired()) { + qWarning() << "Device connection is blocked until firmware recovery" + " is explicitly acknowledged"; + } else { + manager.connectDevice( + QString::fromStdString(config.port).trimmed()); + } + qInfo() << "TRYX background runtime acquired" + << tryxRuntimeServiceName(); + return app.exec(); +} diff --git a/src/runtimebridge.cpp b/src/runtimebridge.cpp index 04da457..b1f6539 100644 --- a/src/runtimebridge.cpp +++ b/src/runtimebridge.cpp @@ -2,370 +2,7 @@ #include "devicemanager.h" -#include - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeDeviceInfo &info) { - argument.beginStructure(); - argument << info.devicePath << info.manufacturer << info.usbProduct - << info.usbSerial << info.osName << info.osVersion - << info.firmwareVersion << info.productName << info.appVersion - << info.serialNumber << info.chipId << info.serialNumberLocked; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeDeviceInfo &info) { - argument.beginStructure(); - argument >> info.devicePath >> info.manufacturer >> info.usbProduct - >> info.usbSerial >> info.osName >> info.osVersion - >> info.firmwareVersion >> info.productName >> info.appVersion - >> info.serialNumber >> info.chipId >> info.serialNumberLocked; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeSnapshot &snapshot) { - argument.beginStructure(); - argument << snapshot.revision << snapshot.connected - << snapshot.printerClassConnected - << snapshot.printerClassDevicePresent - << snapshot.displaySessionActive << snapshot.productId - << snapshot.serial << snapshot.firmware << snapshot.appVersion - << snapshot.mediaFiles << snapshot.diagnostic; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeSnapshot &snapshot) { - argument.beginStructure(); - argument >> snapshot.revision >> snapshot.connected - >> snapshot.printerClassConnected - >> snapshot.printerClassDevicePresent - >> snapshot.displaySessionActive >> snapshot.productId - >> snapshot.serial >> snapshot.firmware >> snapshot.appVersion - >> snapshot.mediaFiles >> snapshot.diagnostic; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeMediaEntry &entry) { - argument.beginStructure(); - argument << entry.name << entry.size << entry.source << entry.readOnly - << entry.thumbnailKey << entry.managedOrigin - << entry.deleteAllowed << entry.deleteBlockReason; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeMediaEntry &entry) { - argument.beginStructure(); - argument >> entry.name >> entry.size >> entry.source >> entry.readOnly - >> entry.thumbnailKey >> entry.managedOrigin - >> entry.deleteAllowed >> entry.deleteBlockReason; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<( - QDBusArgument &argument, - const TryxRuntimeMediaCatalogSnapshot &snapshot) { - argument.beginStructure(); - argument << snapshot.revision << snapshot.deviceIdentity - << snapshot.entries; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>( - const QDBusArgument &argument, - TryxRuntimeMediaCatalogSnapshot &snapshot) { - argument.beginStructure(); - argument >> snapshot.revision >> snapshot.deviceIdentity - >> snapshot.entries; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeLegacyMediaEntry &entry) { - argument.beginStructure(); - argument << entry.name << entry.size << entry.source << entry.readOnly - << entry.thumbnailKey; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeLegacyMediaEntry &entry) { - argument.beginStructure(); - argument >> entry.name >> entry.size >> entry.source >> entry.readOnly - >> entry.thumbnailKey; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<( - QDBusArgument &argument, - const TryxRuntimeLegacyMediaCatalogSnapshot &snapshot) { - argument.beginStructure(); - argument << snapshot.revision << snapshot.deviceIdentity - << snapshot.entries; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>( - const QDBusArgument &argument, - TryxRuntimeLegacyMediaCatalogSnapshot &snapshot) { - argument.beginStructure(); - argument >> snapshot.revision >> snapshot.deviceIdentity - >> snapshot.entries; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeDisplayMutation &mutation) { - argument.beginStructure(); - argument << mutation.brightnessPresent << mutation.brightness - << mutation.standbyPresent << mutation.standbyEnabled - << mutation.orientationPresent << mutation.mirrorMode - << mutation.waterfallMode << mutation.backlightPresent - << mutation.backlightEnabled; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeDisplayMutation &mutation) { - argument.beginStructure(); - argument >> mutation.brightnessPresent >> mutation.brightness - >> mutation.standbyPresent >> mutation.standbyEnabled - >> mutation.orientationPresent >> mutation.mirrorMode - >> mutation.waterfallMode >> mutation.backlightPresent - >> mutation.backlightEnabled; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeApplyRequest &request) { - argument.beginStructure(); - argument << request.media << request.ratio << request.screenMode - << request.playMode << request.sysinfoLabels - << request.settingsPosition << request.settingsColor - << request.settingsAlign << request.settingsBadges - << request.filterOpacity << request.presetId - << request.sysinfoLabels2 << request.settingsBadges2 - << request.settingsPosition2 << request.settingsColor2 - << request.settingsAlign2 << request.waterfallMode - << request.replaceOverlay << request.display; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeApplyRequest &request) { - argument.beginStructure(); - argument >> request.media >> request.ratio >> request.screenMode - >> request.playMode >> request.sysinfoLabels - >> request.settingsPosition >> request.settingsColor - >> request.settingsAlign >> request.settingsBadges - >> request.filterOpacity >> request.presetId - >> request.sysinfoLabels2 >> request.settingsBadges2 - >> request.settingsPosition2 >> request.settingsColor2 - >> request.settingsAlign2 >> request.waterfallMode - >> request.replaceOverlay >> request.display; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeDisplayState &state) { - argument.beginStructure(); - argument << state.revision << state.deviceSerial << state.valid - << state.backlightEnabled << state.brightness - << state.standbyEnabled << state.standbyMedia - << state.mirrorMode << state.waterfallMode - << state.screenMode << state.playMode << state.media - << state.sysinfoLabels << state.settingsBadges - << state.settingsPosition << state.settingsColor - << state.settingsAlign << state.sysinfoLabels2 - << state.settingsBadges2 << state.settingsPosition2 - << state.settingsColor2 << state.settingsAlign2 - << state.diagnostic; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeDisplayState &state) { - argument.beginStructure(); - argument >> state.revision >> state.deviceSerial >> state.valid - >> state.backlightEnabled >> state.brightness - >> state.standbyEnabled >> state.standbyMedia - >> state.mirrorMode >> state.waterfallMode - >> state.screenMode >> state.playMode >> state.media - >> state.sysinfoLabels >> state.settingsBadges - >> state.settingsPosition >> state.settingsColor - >> state.settingsAlign >> state.sysinfoLabels2 - >> state.settingsBadges2 >> state.settingsPosition2 - >> state.settingsColor2 >> state.settingsAlign2 - >> state.diagnostic; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<( - QDBusArgument &argument, - const TryxRuntimeMetricsConfigRequest &request) { - argument.beginStructure(); - argument << request.enabled << request.metrics << request.alignment - << request.textColor; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>( - const QDBusArgument &argument, - TryxRuntimeMetricsConfigRequest &request) { - argument.beginStructure(); - argument >> request.enabled >> request.metrics >> request.alignment - >> request.textColor; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeMetricsState &state) { - argument.beginStructure(); - argument << state.revision << state.deviceSerial << state.enabled - << state.samplingActive << state.metrics - << state.availableMetrics << state.alignment << state.textColor - << state.diagnostic; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeMetricsState &state) { - argument.beginStructure(); - argument >> state.revision >> state.deviceSerial >> state.enabled - >> state.samplingActive >> state.metrics - >> state.availableMetrics >> state.alignment >> state.textColor - >> state.diagnostic; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeOperationInfo &info) { - argument.beginStructure(); - argument << info.id << info.parentId << info.kind << info.state - << info.stage << info.errorCategory << info.terminalOutcome - << info.primaryErrorCategory << info.primaryErrorMessage - << info.retryMode << info.subject << info.resultName - << info.message << info.completed << info.total - << info.confirmedBytes << info.lastConfirmedChunkIndex - << info.attempt << info.deviceGeneration - << info.applyAfterUpload; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeOperationInfo &info) { - argument.beginStructure(); - argument >> info.id >> info.parentId >> info.kind >> info.state - >> info.stage >> info.errorCategory >> info.terminalOutcome - >> info.primaryErrorCategory >> info.primaryErrorMessage - >> info.retryMode >> info.subject >> info.resultName - >> info.message >> info.completed >> info.total - >> info.confirmedBytes >> info.lastConfirmedChunkIndex - >> info.attempt >> info.deviceGeneration - >> info.applyAfterUpload; - argument.endStructure(); - return argument; -} - -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeOperationsSnapshot &snapshot) { - argument.beginStructure(); - argument << snapshot.revision << snapshot.activeOperationId - << snapshot.operations; - argument.endStructure(); - return argument; -} - -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeOperationsSnapshot &snapshot) { - argument.beginStructure(); - argument >> snapshot.revision >> snapshot.activeOperationId - >> snapshot.operations; - argument.endStructure(); - return argument; -} - -QString tryxRuntimeServiceName() { - return QStringLiteral("org.tryx.Panorama"); -} - -QString tryxRuntimeObjectPath() { - return QStringLiteral("/org/tryx/Panorama"); -} - -QString tryxRuntimeInterfaceName() { - return QStringLiteral("org.tryx.Panorama.Manager1"); -} - -QString tryxRuntimeOperationsInterfaceName() { - return QStringLiteral("org.tryx.Panorama.Manager2"); -} - -quint32 tryxRuntimeApiVersion() { - return 6U; -} - -void registerTryxRuntimeMetaTypes() { - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType>(); - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType>(); - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType(); - qRegisterMetaType>(); - qRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType>(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType>(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType(); - qDBusRegisterMetaType>(); - qDBusRegisterMetaType(); -} +#include namespace { @@ -388,6 +25,27 @@ TryxRuntimeLegacyMediaCatalogSnapshot legacyMediaCatalog( } // namespace +QString TryxRuntimeExportedObject::callerUniqueName() const { + if (!calledFromDBus()) { + return {}; + } + const QString owner = message().service().trimmed(); + if (!owner.startsWith(QLatin1Char(':'))) { + sendErrorReply( + QStringLiteral("org.tryx.Panorama.Error.InvalidCaller"), + tr("The operation requires a unique D-Bus caller identity")); + return {}; + } + return owner; +} + +void TryxRuntimeExportedObject::sendCurrentCallError( + const QString &name, const QString &message) const { + if (calledFromDBus()) { + sendErrorReply(name, message); + } +} + TryxRuntimeManagerAdaptor::TryxRuntimeManagerAdaptor( QObject *exportedObject, DeviceManager *manager) : QDBusAbstractAdaptor(exportedObject), manager_(manager) { @@ -593,9 +251,10 @@ void TryxRuntimeManagerAdaptor::updateConnectionSnapshot( } TryxRuntimeOperationsAdaptor::TryxRuntimeOperationsAdaptor( - QObject *exportedObject, DeviceManager *manager, + TryxRuntimeExportedObject *exportedObject, DeviceManager *manager, TryxRuntimeManagerAdaptor *connectionAdaptor) : QDBusAbstractAdaptor(exportedObject), + exportedObject_(exportedObject), manager_(manager), connectionAdaptor_(connectionAdaptor) { connect(manager_, &DeviceManager::operationChanged, this, @@ -662,6 +321,14 @@ QString TryxRuntimeOperationsAdaptor::QueueUpload( TryxRuntimeApplyRequest{}, false); } +QString TryxRuntimeOperationsAdaptor::QueueUploadWithTransform( + const QString &operationId, const QString &localPath, + const TryxRuntimeMediaTransform &transform) { + return manager_->queueUploadOperation( + operationId, localPath, false, TryxRuntimeApplyRequest{}, false, + false, transform); +} + QString TryxRuntimeOperationsAdaptor::QueueUploadWithApply( const QString &operationId, const QString &localPath, const TryxRuntimeApplyRequest &request) { @@ -669,6 +336,14 @@ QString TryxRuntimeOperationsAdaptor::QueueUploadWithApply( request, true); } +QString TryxRuntimeOperationsAdaptor::QueueUploadWithApplyAndTransform( + const QString &operationId, const QString &localPath, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform) { + return manager_->queueUploadOperation( + operationId, localPath, true, request, true, false, transform); +} + QString TryxRuntimeOperationsAdaptor::QueueEnsureMediaAndApply( const QString &operationId, const QString &localPath, const TryxRuntimeApplyRequest &request) { @@ -676,6 +351,15 @@ QString TryxRuntimeOperationsAdaptor::QueueEnsureMediaAndApply( operationId, localPath, request); } +QString +TryxRuntimeOperationsAdaptor::QueueEnsureMediaAndApplyWithTransform( + const QString &operationId, const QString &localPath, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform) { + return manager_->queueEnsureMediaAndApplyOperation( + operationId, localPath, request, transform); +} + QString TryxRuntimeOperationsAdaptor::QueueDeleteMedia( const QString &operationId, const QStringList &fileNames) { return manager_->queueDeleteMediaOperation(operationId, fileNames); @@ -697,6 +381,115 @@ QString TryxRuntimeOperationsAdaptor::QueueMetricsConfig( return manager_->queueMetricsConfigOperation(operationId, request); } +QString TryxRuntimeOperationsAdaptor::QueueStageDeviceMedia( + const QString &operationId, const QString &mediaId) { + const QString owner = callerUniqueName(); + if (owner.isEmpty()) { + return {}; + } + const QString queued = manager_->queueStageDeviceMediaOperation( + operationId, mediaId, owner); + if (queued.isEmpty()) { + sendInvalidArtifactError( + tr("Device media could not be staged for this caller")); + } + return queued; +} + +TryxRuntimeDeviceMediaArtifact +TryxRuntimeOperationsAdaptor::ClaimDeviceMediaArtifact( + const QString &operationId, const QString &artifactId) { + const QString owner = callerUniqueName(); + if (owner.isEmpty()) { + return {}; + } + QString errorMessage; + const TryxRuntimeDeviceMediaArtifact artifact = + manager_->claimDeviceMediaArtifact( + operationId, artifactId, owner, &errorMessage); + if (artifact.artifactId.isEmpty()) { + sendInvalidArtifactError( + errorMessage.isEmpty() + ? tr("The device media artifact is unavailable") + : errorMessage); + } + return artifact; +} + +bool TryxRuntimeOperationsAdaptor::RenewDeviceMediaArtifactLease( + const QString &artifactId, const QString &leaseId) { + const QString owner = callerUniqueName(); + if (owner.isEmpty()) { + return false; + } + QString errorMessage; + const bool renewed = manager_->renewDeviceMediaArtifactLease( + artifactId, leaseId, owner, &errorMessage); + if (!renewed) { + sendInvalidArtifactError( + errorMessage.isEmpty() + ? tr("The device media artifact lease could not be renewed") + : errorMessage); + } + return renewed; +} + +bool TryxRuntimeOperationsAdaptor::ReleaseDeviceMediaArtifact( + const QString &artifactId, const QString &leaseId) { + const QString owner = callerUniqueName(); + if (owner.isEmpty()) { + return false; + } + QString errorMessage; + const bool released = manager_->releaseDeviceMediaArtifact( + artifactId, leaseId, owner, &errorMessage); + if (!released) { + sendInvalidArtifactError( + errorMessage.isEmpty() + ? tr("The device media artifact could not be released") + : errorMessage); + } + return released; +} + +QString +TryxRuntimeOperationsAdaptor::QueueRecoveredMediaUploadWithTransform( + const QString &operationId, const QString &artifactId, + const QString &leaseId, + const TryxRuntimeMediaTransform &transform) { + const QString owner = callerUniqueName(); + if (owner.isEmpty()) { + return {}; + } + const QString queued = + manager_->queueRecoveredMediaUploadOperation( + operationId, artifactId, leaseId, owner, transform); + if (queued.isEmpty()) { + sendInvalidArtifactError( + tr("The recovered media artifact is unavailable for upload")); + } + return queued; +} + +QString TryxRuntimeOperationsAdaptor::QueueReplaceDeviceMedia( + const QString &operationId, const QString &artifactId, + const QString &leaseId, const QString &originalMediaId, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform) { + const QString owner = callerUniqueName(); + if (owner.isEmpty()) { + return {}; + } + const QString queued = manager_->queueReplaceDeviceMediaOperation( + operationId, artifactId, leaseId, originalMediaId, + request, transform, owner); + if (queued.isEmpty()) { + sendInvalidArtifactError( + tr("The recovered media artifact is unavailable for replacement")); + } + return queued; +} + QString TryxRuntimeOperationsAdaptor::RetryOperation( const QString &sourceOperationId, const QString &newOperationId) { return manager_->retryOperation(sourceOperationId, newOperationId); @@ -706,3 +499,19 @@ void TryxRuntimeOperationsAdaptor::CancelOperation( const QString &operationId) { manager_->cancelOperation(operationId); } + +QString TryxRuntimeOperationsAdaptor::callerUniqueName() { + return exportedObject_ + ? exportedObject_->callerUniqueName() + : QString(); +} + +void TryxRuntimeOperationsAdaptor::sendInvalidArtifactError( + const QString &message) { + if (!exportedObject_) { + return; + } + exportedObject_->sendCurrentCallError( + QStringLiteral("org.tryx.Panorama.Error.InvalidArtifact"), + message); +} diff --git a/src/runtimebridge.h b/src/runtimebridge.h index 60304f5..fbfafad 100644 --- a/src/runtimebridge.h +++ b/src/runtimebridge.h @@ -1,265 +1,26 @@ #pragma once -#include "printerprotocol.h" +#include "runtimecontract.h" #include -#include +#include #include -#include -#include -#include class DeviceManager; -struct TryxRuntimeDeviceInfo { - QString devicePath; - QString manufacturer; - QString usbProduct; - QString usbSerial; - QString osName; - QString osVersion; - QString firmwareVersion; - QString productName; - QString appVersion; - QString serialNumber; - QString chipId; - bool serialNumberLocked = false; -}; - -struct TryxRuntimeSnapshot { - quint64 revision = 0; - bool connected = false; - bool printerClassConnected = false; - bool printerClassDevicePresent = false; - bool displaySessionActive = false; - QString productId; - QString serial; - QString firmware; - QString appVersion; - QStringList mediaFiles; - QString diagnostic; -}; - -struct TryxRuntimeMediaEntry { - QString name; - quint64 size = 0; - quint32 source = 0; - bool readOnly = false; - QString thumbnailKey; - bool managedOrigin = false; - bool deleteAllowed = false; - QString deleteBlockReason; -}; - -struct TryxRuntimeMediaCatalogSnapshot { - quint64 revision = 0; - QString deviceIdentity; - QList entries; -}; - -// Manager1 keeps the API v2 positional D-Bus shape. Manager2 exposes the -// extended catalog above after an explicit API version handshake. -struct TryxRuntimeLegacyMediaEntry { - QString name; - quint64 size = 0; - quint32 source = 0; - bool readOnly = false; - QString thumbnailKey; -}; - -struct TryxRuntimeLegacyMediaCatalogSnapshot { - quint64 revision = 0; - QString deviceIdentity; - QList entries; -}; - -struct TryxRuntimeDisplayMutation { - bool brightnessPresent = false; - int brightness = 0; - bool standbyPresent = false; - bool standbyEnabled = false; - bool orientationPresent = false; - bool mirrorMode = false; - bool waterfallMode = false; - bool backlightPresent = false; - bool backlightEnabled = true; -}; - -struct TryxRuntimeApplyRequest { - QStringList media; - QString ratio; - QString screenMode; - QString playMode; - QStringList sysinfoLabels; - QString settingsPosition; - QString settingsColor; - QString settingsAlign; - QStringList settingsBadges; - int filterOpacity = 0; - QString presetId; - QStringList sysinfoLabels2; - QStringList settingsBadges2; - QString settingsPosition2; - QString settingsColor2; - QString settingsAlign2; - bool waterfallMode = false; - bool replaceOverlay = false; - TryxRuntimeDisplayMutation display; -}; - -struct TryxRuntimeDisplayState { - quint64 revision = 0; - QString deviceSerial; - bool valid = false; - bool backlightEnabled = false; - int brightness = 0; - bool standbyEnabled = false; - QString standbyMedia; - bool mirrorMode = false; - bool waterfallMode = false; - QString screenMode; - QString playMode; - QStringList media; - QStringList sysinfoLabels; - QStringList settingsBadges; - QString settingsPosition; - QString settingsColor; - QString settingsAlign; - QStringList sysinfoLabels2; - QStringList settingsBadges2; - QString settingsPosition2; - QString settingsColor2; - QString settingsAlign2; - QString diagnostic; -}; - -struct TryxRuntimeMetricsConfigRequest { - bool enabled = false; - QStringList metrics; - QString alignment = QStringLiteral("Left"); - quint32 textColor = 0x00DCDCDC; -}; - -struct TryxRuntimeMetricsState { - quint64 revision = 0; - QString deviceSerial; - bool enabled = false; - bool samplingActive = false; - QStringList metrics; - QStringList availableMetrics; - QString alignment = QStringLiteral("Left"); - quint32 textColor = 0x00DCDCDC; - QString diagnostic; -}; +class TryxRuntimeExportedObject final + : public QObject, + protected QDBusContext { + Q_OBJECT -struct TryxRuntimeOperationInfo { - QString id; - QString parentId; - QString kind; - QString state; - QString stage; - QString errorCategory; - QString terminalOutcome; - QString primaryErrorCategory; - QString primaryErrorMessage; - QString retryMode; - QString subject; - QString resultName; - QString message; - qint64 completed = 0; - qint64 total = 0; - qint64 confirmedBytes = 0; - qint64 lastConfirmedChunkIndex = -1; - quint32 attempt = 1; - quint64 deviceGeneration = 0; - bool applyAfterUpload = false; -}; +public: + using QObject::QObject; -struct TryxRuntimeOperationsSnapshot { - quint64 revision = 0; - QString activeOperationId; - QList operations; + QString callerUniqueName() const; + void sendCurrentCallError( + const QString &name, const QString &message) const; }; -Q_DECLARE_METATYPE(TryxRuntimeDeviceInfo) -Q_DECLARE_METATYPE(TryxRuntimeSnapshot) -Q_DECLARE_METATYPE(TryxRuntimeMediaEntry) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(TryxRuntimeMediaCatalogSnapshot) -Q_DECLARE_METATYPE(TryxRuntimeLegacyMediaEntry) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(TryxRuntimeLegacyMediaCatalogSnapshot) -Q_DECLARE_METATYPE(TryxRuntimeDisplayMutation) -Q_DECLARE_METATYPE(TryxRuntimeApplyRequest) -Q_DECLARE_METATYPE(TryxRuntimeDisplayState) -Q_DECLARE_METATYPE(TryxRuntimeMetricsConfigRequest) -Q_DECLARE_METATYPE(TryxRuntimeMetricsState) -Q_DECLARE_METATYPE(TryxRuntimeOperationInfo) -Q_DECLARE_METATYPE(QList) -Q_DECLARE_METATYPE(TryxRuntimeOperationsSnapshot) -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeDeviceInfo &info); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeDeviceInfo &info); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeSnapshot &snapshot); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeSnapshot &snapshot); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeMediaEntry &entry); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeMediaEntry &entry); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeMediaCatalogSnapshot &snapshot); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeMediaCatalogSnapshot &snapshot); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeLegacyMediaEntry &entry); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeLegacyMediaEntry &entry); -QDBusArgument &operator<<( - QDBusArgument &argument, - const TryxRuntimeLegacyMediaCatalogSnapshot &snapshot); -const QDBusArgument &operator>>( - const QDBusArgument &argument, - TryxRuntimeLegacyMediaCatalogSnapshot &snapshot); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeDisplayMutation &mutation); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeDisplayMutation &mutation); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeApplyRequest &request); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeApplyRequest &request); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeDisplayState &state); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeDisplayState &state); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeMetricsConfigRequest &request); -const QDBusArgument &operator>>( - const QDBusArgument &argument, - TryxRuntimeMetricsConfigRequest &request); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeMetricsState &state); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeMetricsState &state); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeOperationInfo &info); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeOperationInfo &info); -QDBusArgument &operator<<(QDBusArgument &argument, - const TryxRuntimeOperationsSnapshot &snapshot); -const QDBusArgument &operator>>(const QDBusArgument &argument, - TryxRuntimeOperationsSnapshot &snapshot); - -QString tryxRuntimeServiceName(); -QString tryxRuntimeObjectPath(); -QString tryxRuntimeInterfaceName(); -QString tryxRuntimeOperationsInterfaceName(); -quint32 tryxRuntimeApiVersion(); -void registerTryxRuntimeMetaTypes(); - class TryxRuntimeManagerAdaptor final : public QDBusAbstractAdaptor { Q_OBJECT Q_CLASSINFO("D-Bus Interface", "org.tryx.Panorama.Manager1") @@ -332,14 +93,16 @@ public slots: TryxRuntimeSnapshot snapshot_; }; -class TryxRuntimeOperationsAdaptor final : public QDBusAbstractAdaptor { +class TryxRuntimeOperationsAdaptor final + : public QDBusAbstractAdaptor { Q_OBJECT Q_CLASSINFO("D-Bus Interface", "org.tryx.Panorama.Manager2") public: - TryxRuntimeOperationsAdaptor(QObject *exportedObject, - DeviceManager *manager, - TryxRuntimeManagerAdaptor *connectionAdaptor); + TryxRuntimeOperationsAdaptor( + TryxRuntimeExportedObject *exportedObject, + DeviceManager *manager, + TryxRuntimeManagerAdaptor *connectionAdaptor); public slots: TryxRuntimeSnapshot GetConnectionSnapshot() const; @@ -353,12 +116,23 @@ public slots: TryxRuntimeDisplayState GetDisplayState() const; QString QueueUpload(const QString &operationId, const QString &localPath, bool applyAfterUpload); + QString QueueUploadWithTransform( + const QString &operationId, const QString &localPath, + const TryxRuntimeMediaTransform &transform); QString QueueUploadWithApply( const QString &operationId, const QString &localPath, const TryxRuntimeApplyRequest &request); + QString QueueUploadWithApplyAndTransform( + const QString &operationId, const QString &localPath, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform); QString QueueEnsureMediaAndApply( const QString &operationId, const QString &localPath, const TryxRuntimeApplyRequest &request); + QString QueueEnsureMediaAndApplyWithTransform( + const QString &operationId, const QString &localPath, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform); QString QueueDeleteMedia(const QString &operationId, const QStringList &fileNames); QString QueueApply(const QString &operationId, @@ -369,6 +143,23 @@ public slots: QString QueueMetricsConfig( const QString &operationId, const TryxRuntimeMetricsConfigRequest &request); + QString QueueStageDeviceMedia(const QString &operationId, + const QString &mediaId); + TryxRuntimeDeviceMediaArtifact ClaimDeviceMediaArtifact( + const QString &operationId, const QString &artifactId); + bool RenewDeviceMediaArtifactLease(const QString &artifactId, + const QString &leaseId); + bool ReleaseDeviceMediaArtifact(const QString &artifactId, + const QString &leaseId); + QString QueueRecoveredMediaUploadWithTransform( + const QString &operationId, const QString &artifactId, + const QString &leaseId, + const TryxRuntimeMediaTransform &transform); + QString QueueReplaceDeviceMedia( + const QString &operationId, const QString &artifactId, + const QString &leaseId, const QString &originalMediaId, + const TryxRuntimeApplyRequest &request, + const TryxRuntimeMediaTransform &transform); QString RetryOperation(const QString &sourceOperationId, const QString &newOperationId); void CancelOperation(const QString &operationId); @@ -383,6 +174,10 @@ public slots: void DisplayStateUpdated(const TryxRuntimeDisplayState &state); private: + QString callerUniqueName(); + void sendInvalidArtifactError(const QString &message); + + TryxRuntimeExportedObject *exportedObject_; DeviceManager *manager_; TryxRuntimeManagerAdaptor *connectionAdaptor_; }; diff --git a/src/runtimecontract.cpp b/src/runtimecontract.cpp new file mode 100644 index 0000000..54e3087 --- /dev/null +++ b/src/runtimecontract.cpp @@ -0,0 +1,454 @@ +#include "runtimecontract.h" + +#include +#include +#include + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeDeviceInfo &info) { + argument.beginStructure(); + argument << info.devicePath << info.manufacturer << info.usbProduct + << info.usbSerial << info.osName << info.osVersion + << info.firmwareVersion << info.productName << info.appVersion + << info.serialNumber << info.chipId << info.serialNumberLocked; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeDeviceInfo &info) { + argument.beginStructure(); + argument >> info.devicePath >> info.manufacturer >> info.usbProduct + >> info.usbSerial >> info.osName >> info.osVersion + >> info.firmwareVersion >> info.productName >> info.appVersion + >> info.serialNumber >> info.chipId >> info.serialNumberLocked; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeSnapshot &snapshot) { + argument.beginStructure(); + argument << snapshot.revision << snapshot.connected + << snapshot.printerClassConnected + << snapshot.printerClassDevicePresent + << snapshot.displaySessionActive << snapshot.productId + << snapshot.serial << snapshot.firmware << snapshot.appVersion + << snapshot.mediaFiles << snapshot.diagnostic; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeSnapshot &snapshot) { + argument.beginStructure(); + argument >> snapshot.revision >> snapshot.connected + >> snapshot.printerClassConnected + >> snapshot.printerClassDevicePresent + >> snapshot.displaySessionActive >> snapshot.productId + >> snapshot.serial >> snapshot.firmware >> snapshot.appVersion + >> snapshot.mediaFiles >> snapshot.diagnostic; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeMediaEntry &entry) { + argument.beginStructure(); + argument << entry.name << entry.size << entry.source << entry.readOnly + << entry.thumbnailKey << entry.managedOrigin + << entry.deleteAllowed << entry.deleteBlockReason + << entry.mediaId; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeMediaEntry &entry) { + argument.beginStructure(); + argument >> entry.name >> entry.size >> entry.source >> entry.readOnly + >> entry.thumbnailKey >> entry.managedOrigin + >> entry.deleteAllowed >> entry.deleteBlockReason + >> entry.mediaId; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<( + QDBusArgument &argument, + const TryxRuntimeMediaCatalogSnapshot &snapshot) { + argument.beginStructure(); + argument << snapshot.revision << snapshot.deviceIdentity + << snapshot.entries; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>( + const QDBusArgument &argument, + TryxRuntimeMediaCatalogSnapshot &snapshot) { + argument.beginStructure(); + argument >> snapshot.revision >> snapshot.deviceIdentity + >> snapshot.entries; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<( + QDBusArgument &argument, + const TryxRuntimeDeviceMediaArtifact &artifact) { + argument.beginStructure(); + argument << artifact.schemaVersion << artifact.operationId + << artifact.artifactId << artifact.mediaId + << artifact.deviceIdentity << artifact.remoteName + << artifact.size << artifact.decodedSha256 + << artifact.localPath << artifact.logicalType + << artifact.leaseId << artifact.leaseExpiresUtcMs; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>( + const QDBusArgument &argument, + TryxRuntimeDeviceMediaArtifact &artifact) { + argument.beginStructure(); + argument >> artifact.schemaVersion >> artifact.operationId + >> artifact.artifactId >> artifact.mediaId + >> artifact.deviceIdentity >> artifact.remoteName + >> artifact.size >> artifact.decodedSha256 + >> artifact.localPath >> artifact.logicalType + >> artifact.leaseId >> artifact.leaseExpiresUtcMs; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeLegacyMediaEntry &entry) { + argument.beginStructure(); + argument << entry.name << entry.size << entry.source << entry.readOnly + << entry.thumbnailKey; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeLegacyMediaEntry &entry) { + argument.beginStructure(); + argument >> entry.name >> entry.size >> entry.source >> entry.readOnly + >> entry.thumbnailKey; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<( + QDBusArgument &argument, + const TryxRuntimeLegacyMediaCatalogSnapshot &snapshot) { + argument.beginStructure(); + argument << snapshot.revision << snapshot.deviceIdentity + << snapshot.entries; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>( + const QDBusArgument &argument, + TryxRuntimeLegacyMediaCatalogSnapshot &snapshot) { + argument.beginStructure(); + argument >> snapshot.revision >> snapshot.deviceIdentity + >> snapshot.entries; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeDisplayMutation &mutation) { + argument.beginStructure(); + argument << mutation.brightnessPresent << mutation.brightness + << mutation.standbyPresent << mutation.standbyEnabled + << mutation.orientationPresent << mutation.mirrorMode + << mutation.waterfallMode << mutation.backlightPresent + << mutation.backlightEnabled; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeDisplayMutation &mutation) { + argument.beginStructure(); + argument >> mutation.brightnessPresent >> mutation.brightness + >> mutation.standbyPresent >> mutation.standbyEnabled + >> mutation.orientationPresent >> mutation.mirrorMode + >> mutation.waterfallMode >> mutation.backlightPresent + >> mutation.backlightEnabled; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeApplyRequest &request) { + argument.beginStructure(); + argument << request.media << request.ratio << request.screenMode + << request.playMode << request.sysinfoLabels + << request.settingsPosition << request.settingsColor + << request.settingsAlign << request.settingsBadges + << request.filterOpacity << request.presetId + << request.sysinfoLabels2 << request.settingsBadges2 + << request.settingsPosition2 << request.settingsColor2 + << request.settingsAlign2 << request.waterfallMode + << request.replaceOverlay << request.display; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeApplyRequest &request) { + argument.beginStructure(); + argument >> request.media >> request.ratio >> request.screenMode + >> request.playMode >> request.sysinfoLabels + >> request.settingsPosition >> request.settingsColor + >> request.settingsAlign >> request.settingsBadges + >> request.filterOpacity >> request.presetId + >> request.sysinfoLabels2 >> request.settingsBadges2 + >> request.settingsPosition2 >> request.settingsColor2 + >> request.settingsAlign2 >> request.waterfallMode + >> request.replaceOverlay >> request.display; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeMediaTransform &transform) { + argument.beginStructure(); + argument << transform.schemaVersion << transform.mode + << transform.rotationQuarterTurns << transform.zoomPermille + << transform.focusX << transform.focusY + << transform.backgroundRgb; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeMediaTransform &transform) { + argument.beginStructure(); + argument >> transform.schemaVersion >> transform.mode + >> transform.rotationQuarterTurns >> transform.zoomPermille + >> transform.focusX >> transform.focusY + >> transform.backgroundRgb; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeDisplayState &state) { + argument.beginStructure(); + argument << state.revision << state.deviceSerial << state.valid + << state.backlightEnabled << state.brightness + << state.standbyEnabled << state.standbyMedia + << state.mirrorMode << state.waterfallMode + << state.screenMode << state.playMode << state.media + << state.sysinfoLabels << state.settingsBadges + << state.settingsPosition << state.settingsColor + << state.settingsAlign << state.sysinfoLabels2 + << state.settingsBadges2 << state.settingsPosition2 + << state.settingsColor2 << state.settingsAlign2 + << state.diagnostic; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeDisplayState &state) { + argument.beginStructure(); + argument >> state.revision >> state.deviceSerial >> state.valid + >> state.backlightEnabled >> state.brightness + >> state.standbyEnabled >> state.standbyMedia + >> state.mirrorMode >> state.waterfallMode + >> state.screenMode >> state.playMode >> state.media + >> state.sysinfoLabels >> state.settingsBadges + >> state.settingsPosition >> state.settingsColor + >> state.settingsAlign >> state.sysinfoLabels2 + >> state.settingsBadges2 >> state.settingsPosition2 + >> state.settingsColor2 >> state.settingsAlign2 + >> state.diagnostic; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<( + QDBusArgument &argument, + const TryxRuntimeMetricsConfigRequest &request) { + argument.beginStructure(); + argument << request.enabled << request.metrics << request.alignment + << request.textColor; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>( + const QDBusArgument &argument, + TryxRuntimeMetricsConfigRequest &request) { + argument.beginStructure(); + argument >> request.enabled >> request.metrics >> request.alignment + >> request.textColor; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeMetricsState &state) { + argument.beginStructure(); + argument << state.revision << state.deviceSerial << state.enabled + << state.samplingActive << state.metrics + << state.availableMetrics << state.alignment << state.textColor + << state.diagnostic; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeMetricsState &state) { + argument.beginStructure(); + argument >> state.revision >> state.deviceSerial >> state.enabled + >> state.samplingActive >> state.metrics + >> state.availableMetrics >> state.alignment >> state.textColor + >> state.diagnostic; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeOperationInfo &info) { + argument.beginStructure(); + argument << info.id << info.parentId << info.kind << info.state + << info.stage << info.errorCategory << info.terminalOutcome + << info.primaryErrorCategory << info.primaryErrorMessage + << info.retryMode << info.subject << info.resultName + << info.message << info.completed << info.total + << info.confirmedBytes << info.lastConfirmedChunkIndex + << info.attempt << info.deviceGeneration + << info.applyAfterUpload; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeOperationInfo &info) { + argument.beginStructure(); + argument >> info.id >> info.parentId >> info.kind >> info.state + >> info.stage >> info.errorCategory >> info.terminalOutcome + >> info.primaryErrorCategory >> info.primaryErrorMessage + >> info.retryMode >> info.subject >> info.resultName + >> info.message >> info.completed >> info.total + >> info.confirmedBytes >> info.lastConfirmedChunkIndex + >> info.attempt >> info.deviceGeneration + >> info.applyAfterUpload; + argument.endStructure(); + return argument; +} + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeOperationsSnapshot &snapshot) { + argument.beginStructure(); + argument << snapshot.revision << snapshot.activeOperationId + << snapshot.operations; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeOperationsSnapshot &snapshot) { + argument.beginStructure(); + argument >> snapshot.revision >> snapshot.activeOperationId + >> snapshot.operations; + argument.endStructure(); + return argument; +} + +QString tryxRuntimeServiceName() { + return QStringLiteral("org.tryx.Panorama"); +} + +QString tryxRuntimeObjectPath() { + return QStringLiteral("/org/tryx/Panorama"); +} + +QString tryxRuntimeInterfaceName() { + return QStringLiteral("org.tryx.Panorama.Manager1"); +} + +QString tryxRuntimeOperationsInterfaceName() { + return QStringLiteral("org.tryx.Panorama.Manager2"); +} + +QString tryxRuntimeMediaInboxPath() { + const QString runtimePath = QStandardPaths::writableLocation( + QStandardPaths::RuntimeLocation); + if (runtimePath.isEmpty()) { + return {}; + } + return QDir(runtimePath).filePath( + QStringLiteral("tryx-panorama-manager/media-inbox")); +} + +QString tryxRuntimeMediaSpoolPath() { + const QString runtimePath = QStandardPaths::writableLocation( + QStandardPaths::RuntimeLocation); + if (runtimePath.isEmpty()) { + return {}; + } + return QDir(runtimePath).filePath( + QStringLiteral("tryx-panorama-manager/media-spool")); +} + +QString tryxRuntimeDeviceMediaOutboxPath() { + const QString runtimePath = QStandardPaths::writableLocation( + QStandardPaths::RuntimeLocation); + if (runtimePath.isEmpty()) { + return {}; + } + return QDir(runtimePath).filePath( + QStringLiteral("tryx-panorama-manager/device-media-outbox")); +} + +quint32 tryxRuntimeApiVersion() { + return 8U; +} + +void registerTryxRuntimeMetaTypes() { + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType>(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType>(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType>(); + qRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType>(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType>(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType>(); + qDBusRegisterMetaType(); +} diff --git a/src/runtimecontract.h b/src/runtimecontract.h new file mode 100644 index 0000000..d314aff --- /dev/null +++ b/src/runtimecontract.h @@ -0,0 +1,297 @@ +#pragma once + +#include +#include +#include +#include + +struct TryxRuntimeDeviceInfo { + QString devicePath; + QString manufacturer; + QString usbProduct; + QString usbSerial; + QString osName; + QString osVersion; + QString firmwareVersion; + QString productName; + QString appVersion; + QString serialNumber; + QString chipId; + bool serialNumberLocked = false; +}; + +struct TryxRuntimeSnapshot { + quint64 revision = 0; + bool connected = false; + bool printerClassConnected = false; + bool printerClassDevicePresent = false; + bool displaySessionActive = false; + QString productId; + QString serial; + QString firmware; + QString appVersion; + QStringList mediaFiles; + QString diagnostic; +}; + +struct TryxRuntimeMediaEntry { + QString name; + quint64 size = 0; + quint32 source = 0; + bool readOnly = false; + QString thumbnailKey; + bool managedOrigin = false; + bool deleteAllowed = false; + QString deleteBlockReason; + QString mediaId; +}; + +struct TryxRuntimeMediaCatalogSnapshot { + quint64 revision = 0; + QString deviceIdentity; + QList entries; +}; + +struct TryxRuntimeDeviceMediaArtifact { + quint32 schemaVersion = 1; + QString operationId; + QString artifactId; + QString mediaId; + QString deviceIdentity; + QString remoteName; + quint64 size = 0; + QString decodedSha256; + QString localPath; + QString logicalType; + QString leaseId; + qint64 leaseExpiresUtcMs = 0; +}; + +// Manager1 keeps the API v2 positional D-Bus shape. Manager2 exposes the +// extended catalog above after an explicit API version handshake. +struct TryxRuntimeLegacyMediaEntry { + QString name; + quint64 size = 0; + quint32 source = 0; + bool readOnly = false; + QString thumbnailKey; +}; + +struct TryxRuntimeLegacyMediaCatalogSnapshot { + quint64 revision = 0; + QString deviceIdentity; + QList entries; +}; + +struct TryxRuntimeDisplayMutation { + bool brightnessPresent = false; + int brightness = 0; + bool standbyPresent = false; + bool standbyEnabled = false; + bool orientationPresent = false; + bool mirrorMode = false; + bool waterfallMode = false; + bool backlightPresent = false; + bool backlightEnabled = true; +}; + +struct TryxRuntimeApplyRequest { + QStringList media; + QString ratio; + QString screenMode; + QString playMode; + QStringList sysinfoLabels; + QString settingsPosition; + QString settingsColor; + QString settingsAlign; + QStringList settingsBadges; + int filterOpacity = 0; + QString presetId; + QStringList sysinfoLabels2; + QStringList settingsBadges2; + QString settingsPosition2; + QString settingsColor2; + QString settingsAlign2; + bool waterfallMode = false; + bool replaceOverlay = false; + TryxRuntimeDisplayMutation display; +}; + +struct TryxRuntimeMediaTransform { + quint32 schemaVersion = 1; + QString mode = QStringLiteral("Fit"); + quint32 rotationQuarterTurns = 0; + quint32 zoomPermille = 1000; + quint32 focusX = 5000; + quint32 focusY = 5000; + quint32 backgroundRgb = 0; +}; + +struct TryxRuntimeDisplayState { + quint64 revision = 0; + QString deviceSerial; + bool valid = false; + bool backlightEnabled = false; + int brightness = 0; + bool standbyEnabled = false; + QString standbyMedia; + bool mirrorMode = false; + bool waterfallMode = false; + QString screenMode; + QString playMode; + QStringList media; + QStringList sysinfoLabels; + QStringList settingsBadges; + QString settingsPosition; + QString settingsColor; + QString settingsAlign; + QStringList sysinfoLabels2; + QStringList settingsBadges2; + QString settingsPosition2; + QString settingsColor2; + QString settingsAlign2; + QString diagnostic; +}; + +struct TryxRuntimeMetricsConfigRequest { + bool enabled = false; + QStringList metrics; + QString alignment = QStringLiteral("Left"); + quint32 textColor = 0x00DCDCDC; +}; + +struct TryxRuntimeMetricsState { + quint64 revision = 0; + QString deviceSerial; + bool enabled = false; + bool samplingActive = false; + QStringList metrics; + QStringList availableMetrics; + QString alignment = QStringLiteral("Left"); + quint32 textColor = 0x00DCDCDC; + QString diagnostic; +}; + +struct TryxRuntimeOperationInfo { + QString id; + QString parentId; + QString kind; + QString state; + QString stage; + QString errorCategory; + QString terminalOutcome; + QString primaryErrorCategory; + QString primaryErrorMessage; + QString retryMode; + QString subject; + QString resultName; + QString message; + qint64 completed = 0; + qint64 total = 0; + qint64 confirmedBytes = 0; + qint64 lastConfirmedChunkIndex = -1; + quint32 attempt = 1; + quint64 deviceGeneration = 0; + bool applyAfterUpload = false; +}; + +struct TryxRuntimeOperationsSnapshot { + quint64 revision = 0; + QString activeOperationId; + QList operations; +}; + +Q_DECLARE_METATYPE(TryxRuntimeDeviceInfo) +Q_DECLARE_METATYPE(TryxRuntimeSnapshot) +Q_DECLARE_METATYPE(TryxRuntimeMediaEntry) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(TryxRuntimeMediaCatalogSnapshot) +Q_DECLARE_METATYPE(TryxRuntimeDeviceMediaArtifact) +Q_DECLARE_METATYPE(TryxRuntimeLegacyMediaEntry) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(TryxRuntimeLegacyMediaCatalogSnapshot) +Q_DECLARE_METATYPE(TryxRuntimeDisplayMutation) +Q_DECLARE_METATYPE(TryxRuntimeApplyRequest) +Q_DECLARE_METATYPE(TryxRuntimeMediaTransform) +Q_DECLARE_METATYPE(TryxRuntimeDisplayState) +Q_DECLARE_METATYPE(TryxRuntimeMetricsConfigRequest) +Q_DECLARE_METATYPE(TryxRuntimeMetricsState) +Q_DECLARE_METATYPE(TryxRuntimeOperationInfo) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(TryxRuntimeOperationsSnapshot) + +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeDeviceInfo &info); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeDeviceInfo &info); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeSnapshot &snapshot); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeSnapshot &snapshot); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeMediaEntry &entry); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeMediaEntry &entry); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeMediaCatalogSnapshot &snapshot); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeMediaCatalogSnapshot &snapshot); +QDBusArgument &operator<<( + QDBusArgument &argument, + const TryxRuntimeDeviceMediaArtifact &artifact); +const QDBusArgument &operator>>( + const QDBusArgument &argument, + TryxRuntimeDeviceMediaArtifact &artifact); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeLegacyMediaEntry &entry); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeLegacyMediaEntry &entry); +QDBusArgument &operator<<( + QDBusArgument &argument, + const TryxRuntimeLegacyMediaCatalogSnapshot &snapshot); +const QDBusArgument &operator>>( + const QDBusArgument &argument, + TryxRuntimeLegacyMediaCatalogSnapshot &snapshot); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeDisplayMutation &mutation); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeDisplayMutation &mutation); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeApplyRequest &request); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeApplyRequest &request); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeMediaTransform &transform); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeMediaTransform &transform); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeDisplayState &state); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeDisplayState &state); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeMetricsConfigRequest &request); +const QDBusArgument &operator>>( + const QDBusArgument &argument, + TryxRuntimeMetricsConfigRequest &request); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeMetricsState &state); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeMetricsState &state); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeOperationInfo &info); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeOperationInfo &info); +QDBusArgument &operator<<(QDBusArgument &argument, + const TryxRuntimeOperationsSnapshot &snapshot); +const QDBusArgument &operator>>(const QDBusArgument &argument, + TryxRuntimeOperationsSnapshot &snapshot); + +QString tryxRuntimeServiceName(); +QString tryxRuntimeObjectPath(); +QString tryxRuntimeInterfaceName(); +QString tryxRuntimeOperationsInterfaceName(); +QString tryxRuntimeMediaInboxPath(); +QString tryxRuntimeMediaSpoolPath(); +QString tryxRuntimeDeviceMediaOutboxPath(); +quint32 tryxRuntimeApiVersion(); +void registerTryxRuntimeMetaTypes(); diff --git a/src/settingspage.cpp b/src/settingspage.cpp deleted file mode 100644 index a8c962f..0000000 --- a/src/settingspage.cpp +++ /dev/null @@ -1,602 +0,0 @@ -#include "settingspage.h" -#include "devicemanager.h" -#include "firmwareupdater.h" -#include "printerprotocol.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -SettingsPage::SettingsPage(DeviceManager *deviceMgr, QWidget *parent) - : QWidget(parent), deviceMgr_(deviceMgr), - firmwareUpdater_(new FirmwareUpdater(this)), - autostartProcess_(new QProcess(this)) { - setupUi(); - loadSettings(); - - connect(deviceMgr_, &DeviceManager::printerDeviceInfoReady, this, - [this](const PrinterProtocol::DeviceInfo &info) { - deviceInfoBtn_->setEnabled(true); - const QString message = - tr("Transport: USB printer-class\n" - "Device: %1\n" - "USB product: %2 %3\n" - "USB serial: %4\n" - "Product: %5\n" - "Serial: %6\n" - "Chip ID: %7\n" - "OS: %8 %9\n" - "Firmware: %10\n" - "App version: %11\n" - "Serial locked: %12") - .arg(info.devicePath, - info.manufacturer, - info.usbProduct, - info.usbSerial, - info.productName, - info.serialNumber, - info.chipId, - info.osName, - info.osVersion, - info.firmwareVersion, - info.appVersion, - info.serialNumberLocked ? tr("yes") : tr("no")); - QMessageBox::information(this, tr("Device information"), message); - emit statusMessage(tr("Printer-class device information loaded")); - }); - connect(deviceMgr_, &DeviceManager::printerDeviceInfoFailed, this, - [this](const QString &message) { - deviceInfoBtn_->setEnabled(true); - QMessageBox::information(this, tr("Device"), message); - }); - autostartProcess_->setProcessChannelMode(QProcess::MergedChannels); - connect(autostartProcess_, qOverload( - &QProcess::finished), - this, &SettingsPage::onAutostartCommandFinished); - connect(autostartProcess_, &QProcess::errorOccurred, this, - [this](QProcess::ProcessError error) { - if (error == QProcess::FailedToStart && - autostartOperation_ != AutostartOperation::None) { - onAutostartCommandFinished(-1, - QProcess::CrashExit); - } - }); - queryAutostartState(); -} - -void SettingsPage::setupUi() { - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setSpacing(12); - - // Port settings - connectionGroup_ = new QGroupBox(tr("Connection")); - auto *portLayout = new QGridLayout(connectionGroup_); - - portCombo_ = new QComboBox; - portCombo_->setEditable(true); - portCombo_->addItem(tr("Auto")); - refreshPortsBtn_ = new QPushButton(tr("Refresh")); - - portLayout->addWidget(new QLabel(tr("Port:")), 0, 0); - portLayout->addWidget(portCombo_, 0, 1); - portLayout->addWidget(refreshPortsBtn_, 0, 2); - - keepaliveSpin_ = new QSpinBox; - keepaliveSpin_->setRange(5, 60); - keepaliveSpin_->setValue(10); - keepaliveSpin_->setSuffix(tr(" sec")); - - portLayout->addWidget(new QLabel(tr("Keepalive interval:")), 1, 0); - portLayout->addWidget(keepaliveSpin_, 1, 1); - - mainLayout->addWidget(connectionGroup_); - connectionGroup_->setVisible( - !deviceMgr_->isPrinterClassDevicePresent()); - - connect(refreshPortsBtn_, &QPushButton::clicked, this, &SettingsPage::onRefreshPorts); - connect(deviceMgr_, &DeviceManager::printerPresenceChanged, this, - [this](bool present) { - connectionGroup_->setVisible(!present); - }); - - // Behavior - auto *behaviorGroup = new QGroupBox(tr("Behavior")); - auto *behaviorLayout = new QVBoxLayout(behaviorGroup); - - cbMinimizeToTray_ = new QCheckBox(tr("Minimize to tray on close")); - cbStartMinimized_ = new QCheckBox(tr("Start minimized")); - cbAutostart_ = new QCheckBox(tr("Autostart on login (systemd user service)")); - - cbMinimizeToTray_->setChecked(true); - - behaviorLayout->addWidget(cbMinimizeToTray_); - behaviorLayout->addWidget(cbStartMinimized_); - behaviorLayout->addWidget(cbAutostart_); - - mainLayout->addWidget(behaviorGroup); - - // Device info - auto *infoGroup = new QGroupBox(tr("Device")); - auto *infoLayout = new QHBoxLayout(infoGroup); - - deviceInfoBtn_ = new QPushButton(tr("Device information")); - infoLayout->addWidget(deviceInfoBtn_); - infoLayout->addStretch(); - - mainLayout->addWidget(infoGroup); - - connect(deviceInfoBtn_, &QPushButton::clicked, this, &SettingsPage::onShowDeviceInfo); - - // Firmware update - auto *firmwareGroup = new QGroupBox(tr("Firmware")); - auto *firmwareLayout = new QGridLayout(firmwareGroup); - - firmwarePackageLabel_ = new QLabel(tr("No package selected")); - firmwarePackageLabel_->setWordWrap(true); - firmwarePackageLabel_->setTextInteractionFlags(Qt::TextSelectableByMouse); - - selectFirmwareBtn_ = new QPushButton(tr("Select firmware ZIP...")); - validateFirmwareBtn_ = new QPushButton(tr("Validate")); - flashFirmwareBtn_ = new QPushButton(tr("Flash firmware")); - validateFirmwareBtn_->setEnabled(false); - flashFirmwareBtn_->setEnabled(false); - - firmwareProgress_ = new QProgressBar; - firmwareProgress_->setRange(0, 100); - firmwareProgress_->setValue(0); - - firmwareStatusLabel_ = new QLabel(tr("Select a TRYX firmware package to enable flashing.")); - firmwareStatusLabel_->setWordWrap(true); - - firmwareLayout->addWidget(new QLabel(tr("Package:")), 0, 0); - firmwareLayout->addWidget(firmwarePackageLabel_, 0, 1, 1, 3); - firmwareLayout->addWidget(selectFirmwareBtn_, 1, 1); - firmwareLayout->addWidget(validateFirmwareBtn_, 1, 2); - firmwareLayout->addWidget(flashFirmwareBtn_, 1, 3); - firmwareLayout->addWidget(firmwareProgress_, 2, 1, 1, 3); - firmwareLayout->addWidget(firmwareStatusLabel_, 3, 1, 1, 3); - firmwareLayout->setColumnStretch(1, 1); - - mainLayout->addWidget(firmwareGroup); - - connect(selectFirmwareBtn_, &QPushButton::clicked, - this, &SettingsPage::onSelectFirmwarePackage); - connect(validateFirmwareBtn_, &QPushButton::clicked, - this, &SettingsPage::onValidateFirmwarePackage); - connect(flashFirmwareBtn_, &QPushButton::clicked, - this, &SettingsPage::onFlashFirmware); - connect(firmwareUpdater_, &FirmwareUpdater::statusChanged, - this, &SettingsPage::onFirmwareStatusChanged); - connect(firmwareUpdater_, &FirmwareUpdater::progressChanged, - this, &SettingsPage::onFirmwareProgressChanged); - connect(firmwareUpdater_, &FirmwareUpdater::finished, - this, &SettingsPage::onFirmwareFinished); - updateFirmwareControls(); - - // Buttons - auto *btnLayout = new QHBoxLayout; - saveBtn_ = new QPushButton(tr("Save")); - resetBtn_ = new QPushButton(tr("Reset")); - btnLayout->addStretch(); - btnLayout->addWidget(saveBtn_); - btnLayout->addWidget(resetBtn_); - - mainLayout->addLayout(btnLayout); - mainLayout->addStretch(); - - connect(saveBtn_, &QPushButton::clicked, this, &SettingsPage::onSaveSettings); - connect(resetBtn_, &QPushButton::clicked, this, &SettingsPage::onResetSettings); - - // Initial port scan - onRefreshPorts(); -} - -void SettingsPage::loadSettings() { - auto config = panorama::ConfigManager::load_config(); - if (config) { - if (!config->port.empty()) { - portCombo_->setCurrentText(QString::fromStdString(config->port)); - } - keepaliveSpin_->setValue(config->keepalive_interval); - } -} - -QString SettingsPage::selectedPort() const { - if (portCombo_->currentText() == tr("Auto")) { - return {}; - } - return portCombo_->currentText(); -} - -int SettingsPage::keepaliveInterval() const { - return keepaliveSpin_->value(); -} - -bool SettingsPage::minimizeToTray() const { - return cbMinimizeToTray_->isChecked(); -} - -bool SettingsPage::startMinimized() const { - return cbStartMinimized_->isChecked(); -} - -void SettingsPage::onRefreshPorts() { - QString current = portCombo_->currentText(); - portCombo_->clear(); - portCombo_->addItem(tr("Auto")); - - QDir devDir("/dev"); - for (const auto &entry : devDir.entryList(QStringList{"ttyACM*"}, QDir::System)) { - portCombo_->addItem("/dev/" + entry); - } - - int idx = portCombo_->findText(current); - if (idx >= 0) { - portCombo_->setCurrentIndex(idx); - } -} - -void SettingsPage::onShowDeviceInfo() { - if (!deviceMgr_->isPrinterClassDevicePresent()) { - if (!deviceMgr_->isConnected()) { - QMessageBox::information(this, tr("Device"), - tr("TRYX device is not connected")); - return; - } - emit statusMessage(tr("Requesting device information...")); - return; - } - - deviceInfoBtn_->setEnabled(false); - emit statusMessage(tr("Requesting printer-class device information...")); - deviceMgr_->requestDeviceInfo(); -} - -void SettingsPage::onSelectFirmwarePackage() { - const QString selected = QFileDialog::getOpenFileName( - this, - tr("Select firmware package"), - QDir::homePath(), - tr("Firmware packages (*.zip);;All files (*)")); - if (selected.isEmpty()) { - return; - } - - firmwarePackagePath_ = selected; - firmwarePackageValidated_ = false; - firmwarePackageFlashSupported_ = false; - firmwarePackageNeedsRockchipFlasher_ = false; - const QFileInfo info(selected); - firmwarePackageLabel_->setText(info.fileName() + "\n" + selected); - firmwareStatusLabel_->setText(tr("Package selected. Validate it before flashing.")); - firmwareProgress_->setValue(0); - updateFirmwareControls(); -} - -void SettingsPage::onValidateFirmwarePackage() { - const QString dependencyMessage = firmwareDependencyMessage(false); - if (!dependencyMessage.isEmpty()) { - firmwareStatusLabel_->setText(dependencyMessage); - QMessageBox::critical(this, tr("Firmware"), dependencyMessage); - updateFirmwareControls(); - return; - } - - const auto info = firmwareUpdater_->validatePackage(firmwarePackagePath_); - if (!info.valid) { - firmwarePackageValidated_ = false; - firmwarePackageFlashSupported_ = false; - firmwarePackageNeedsRockchipFlasher_ = false; - flashFirmwareBtn_->setEnabled(false); - firmwareStatusLabel_->setText(info.error); - QMessageBox::critical(this, tr("Firmware"), info.error); - return; - } - - firmwarePackageValidated_ = true; - firmwarePackageNeedsRockchipFlasher_ = info.kind == FirmwareUpdater::PackageKind::RockchipBundle; - firmwarePackageFlashSupported_ = - info.kind == FirmwareUpdater::PackageKind::LegacyAndroidOta || - (info.kind == FirmwareUpdater::PackageKind::RockchipBundle && - info.productCode == "PASE" && - firmwareUpdater_->rockchipFlashingAvailable()); - updateFirmwareControls(); - - QString message; - if (info.kind == FirmwareUpdater::PackageKind::LegacyAndroidOta) { - message = tr("Type: Android OTA\nTarget: %1\nBuild: %2\nSize: %3 MiB") - .arg(info.preDevice, - info.postBuildIncremental, - QString::number(info.sizeBytes / 1024.0 / 1024.0, 'f', 1)); - } else { - message = tr("Type: Rockchip loader bundle\nProduct: %1\nApp version: %2\nFirmware: %3\nMachine: %4\nPartitions: %5\nSize: %6 MiB") - .arg(info.productCode, - info.appVersion.isEmpty() ? tr("unknown") : info.appVersion, - info.firmwareVersion, - info.machineModel, - info.partitions.join(", "), - QString::number(info.sizeBytes / 1024.0 / 1024.0, 'f', 1)); - message += "\n\n" + firmwareUpdater_->rockchipFlashingUnavailableMessage(); - } - firmwareStatusLabel_->setText(firmwarePackageFlashSupported_ - ? tr("Firmware package is valid") - : tr("Firmware package is valid, but flashing is unavailable for this package.")); - QMessageBox::information(this, tr("Firmware package"), message); -} - -void SettingsPage::onFlashFirmware() { - const QString validationDependencyMessage = firmwareDependencyMessage(false); - if (!validationDependencyMessage.isEmpty()) { - firmwareStatusLabel_->setText(validationDependencyMessage); - QMessageBox::critical(this, tr("Firmware"), validationDependencyMessage); - updateFirmwareControls(); - return; - } - - const auto info = firmwareUpdater_->validatePackage(firmwarePackagePath_); - if (!info.valid) { - firmwarePackageValidated_ = false; - firmwarePackageFlashSupported_ = false; - firmwarePackageNeedsRockchipFlasher_ = false; - firmwareStatusLabel_->setText(info.error); - QMessageBox::critical(this, tr("Firmware"), info.error); - return; - } - - firmwarePackageValidated_ = true; - firmwarePackageNeedsRockchipFlasher_ = info.kind == FirmwareUpdater::PackageKind::RockchipBundle; - firmwarePackageFlashSupported_ = - info.kind == FirmwareUpdater::PackageKind::LegacyAndroidOta || - (info.kind == FirmwareUpdater::PackageKind::RockchipBundle && - info.productCode == "PASE" && - firmwareUpdater_->rockchipFlashingAvailable()); - - const auto dependencies = firmwareUpdater_->dependencyStatus(); - if (info.kind == FirmwareUpdater::PackageKind::LegacyAndroidOta && - !dependencies.canFlashLegacy()) { - QStringList missing; - if (dependencies.adbPath.isEmpty()) { - missing.append("adb"); - } - if (dependencies.unzipPath.isEmpty()) { - missing.append("unzip"); - } - const QString dependencyMessage = tr("Missing firmware dependencies: %1") - .arg(missing.join(", ")); - firmwareStatusLabel_->setText(dependencyMessage); - QMessageBox::critical(this, tr("Firmware"), dependencyMessage); - updateFirmwareControls(); - return; - } - - if (info.kind == FirmwareUpdater::PackageKind::RockchipBundle && - info.productCode != "PASE") { - const QString error = tr("Rockchip bundle product %1 is not supported by this Panorama SE updater") - .arg(info.productCode); - firmwareStatusLabel_->setText(error); - QMessageBox::critical(this, tr("Firmware"), error); - return; - } - if (info.kind == FirmwareUpdater::PackageKind::RockchipBundle) { - const QString dependencyMessage = firmwareDependencyMessage(true); - if (!dependencyMessage.isEmpty()) { - firmwareStatusLabel_->setText(dependencyMessage); - QMessageBox::critical(this, tr("Firmware"), dependencyMessage); - updateFirmwareControls(); - return; - } - if (!firmwareUpdater_->rockchipFlashingAvailable()) { - const QString error = firmwareUpdater_->rockchipFlashingUnavailableMessage(); - firmwareStatusLabel_->setText(error); - QMessageBox::critical(this, tr("Firmware"), error); - updateFirmwareControls(); - return; - } - } - - QString message; - if (info.kind == FirmwareUpdater::PackageKind::LegacyAndroidOta) { - message = - tr("Package: %1\nType: Android OTA\nTarget: %2\nBuild: %3\n\n" - "The package will be copied to the cooler and the device will reboot into recovery. " - "Do not disconnect USB or power until the cooler finishes updating.") - .arg(QFileInfo(info.path).fileName(), info.preDevice, info.postBuildIncremental); - } else { - message = - tr("Package: %1\nType: Rockchip loader bundle\nProduct: %2\nApp version: %3\nFirmware: %4\n\n" - "If the cooler is visible over ADB, it will reboot into Rockchip Loader mode. If it is already in Loader or Maskrom, the app will continue directly. The app will use external upgrade_tool to rewrite GPT, boot, recovery, rootfs, oem, and userdata. " - "After this update the device may appear as RK PASE USB printer-class instead of ADB. " - "Use this only for Panorama SE / PASE firmware. Do not disconnect USB or power until flashing finishes.") - .arg(QFileInfo(info.path).fileName(), - info.productCode, - info.appVersion.isEmpty() ? tr("unknown") : info.appVersion, - info.firmwareVersion); - } - - const auto choice = QMessageBox::warning( - this, - tr("Flash firmware?"), - message, - QMessageBox::Cancel | QMessageBox::Ok, - QMessageBox::Cancel); - if (choice != QMessageBox::Ok) { - return; - } - - setFirmwareBusy(true); - firmwareProgress_->setValue(0); - if (info.kind == FirmwareUpdater::PackageKind::LegacyAndroidOta) { - firmwareUpdater_->startLegacyAdbOta(firmwarePackagePath_); - } else { - firmwareUpdater_->startRockchipLoaderUpdate(firmwarePackagePath_); - } -} - -void SettingsPage::onFirmwareStatusChanged(const QString &message) { - firmwareStatusLabel_->setText(message); - emit statusMessage(message); -} - -void SettingsPage::onFirmwareProgressChanged(int value) { - firmwareProgress_->setValue(value); -} - -void SettingsPage::onFirmwareFinished(bool success, const QString &message) { - setFirmwareBusy(false); - if (success) { - QMessageBox::information(this, tr("Firmware"), message); - } else { - QMessageBox::critical(this, tr("Firmware"), message); - } -} - -void SettingsPage::setFirmwareBusy(bool busy) { - if (busy) { - selectFirmwareBtn_->setEnabled(false); - validateFirmwareBtn_->setEnabled(false); - flashFirmwareBtn_->setEnabled(false); - return; - } - - updateFirmwareControls(); -} - -void SettingsPage::updateFirmwareControls() { - const bool running = firmwareUpdater_->isRunning(); - const bool hasPackage = !firmwarePackagePath_.isEmpty(); - const auto dependencies = firmwareUpdater_->dependencyStatus(); - const bool validationReady = dependencies.canValidate(); - const bool flashReady = firmwarePackageNeedsRockchipFlasher_ - ? dependencies.canFlashRockchip() - : dependencies.canFlashLegacy(); - - selectFirmwareBtn_->setEnabled(!running); - validateFirmwareBtn_->setEnabled(!running && hasPackage && validationReady); - flashFirmwareBtn_->setEnabled(!running && hasPackage && firmwarePackageValidated_ && - firmwarePackageFlashSupported_ && flashReady); - - if (!running && !validationReady) { - firmwareStatusLabel_->setText(firmwareDependencyMessage(false)); - } -} - -QString SettingsPage::firmwareDependencyMessage(bool includeFlasher) const { - const auto dependencies = firmwareUpdater_->dependencyStatus(); - if (includeFlasher ? dependencies.canFlashRockchip() : dependencies.canValidate()) { - return {}; - } - - return tr("Missing firmware dependencies: %1") - .arg(dependencies.missingNames(includeFlasher).join(", ")); -} - -void SettingsPage::onResetSettings() { - portCombo_->setCurrentIndex(0); - keepaliveSpin_->setValue(10); - cbMinimizeToTray_->setChecked(true); - cbStartMinimized_->setChecked(false); - cbAutostart_->setChecked(false); - emit statusMessage(tr("Settings reset")); -} - -void SettingsPage::onSaveSettings() { - panorama::Config config = panorama::ConfigManager::load_config().value_or(panorama::Config{}); - config.port = selectedPort().toStdString(); - config.keepalive_interval = keepaliveSpin_->value(); - - panorama::ConfigManager::save_config(config); - - emit settingsChanged(); - startAutostartCommand(cbAutostart_->isChecked()); -} - -void SettingsPage::queryAutostartState() { - if (autostartProcess_->state() != QProcess::NotRunning) { - return; - } - autostartOperation_ = AutostartOperation::Query; - cbAutostart_->setEnabled(false); - autostartProcess_->start( - QStringLiteral("systemctl"), - {QStringLiteral("--user"), QStringLiteral("is-enabled"), - QStringLiteral("tryx-panorama.service")}); -} - -void SettingsPage::startAutostartCommand(bool enable) { - if (autostartProcess_->state() != QProcess::NotRunning || - autostartOperation_ != AutostartOperation::None) { - emit statusMessage( - tr("The systemd autostart state is still being checked")); - return; - } - - autostartOperation_ = enable - ? AutostartOperation::Enable - : AutostartOperation::Disable; - cbAutostart_->setEnabled(false); - saveBtn_->setEnabled(false); - autostartProcess_->start( - QStringLiteral("systemctl"), - {QStringLiteral("--user"), - enable ? QStringLiteral("enable") : QStringLiteral("disable"), - QStringLiteral("tryx-panorama.service")}); -} - -void SettingsPage::onAutostartCommandFinished( - int exitCode, QProcess::ExitStatus exitStatus) { - const AutostartOperation completedOperation = autostartOperation_; - if (completedOperation == AutostartOperation::None) { - return; - } - autostartOperation_ = AutostartOperation::None; - const QString output = - QString::fromLocal8Bit(autostartProcess_->readAll()).trimmed(); - const bool success = exitStatus == QProcess::NormalExit && exitCode == 0; - - if (completedOperation == AutostartOperation::Query) { - autostartEnabled_ = success && - (output == QStringLiteral("enabled") || - output == QStringLiteral("enabled-runtime") || - output == QStringLiteral("linked") || - output == QStringLiteral("linked-runtime")); - cbAutostart_->setChecked(autostartEnabled_); - cbAutostart_->setEnabled(true); - if (!success && !output.isEmpty() && - output != QStringLiteral("disabled")) { - cbAutostart_->setToolTip(output); - } - return; - } - - cbAutostart_->setEnabled(true); - saveBtn_->setEnabled(true); - if (success) { - autostartEnabled_ = - completedOperation == AutostartOperation::Enable; - cbAutostart_->setChecked(autostartEnabled_); - emit statusMessage( - autostartEnabled_ - ? tr("Settings saved; background runtime autostart enabled") - : tr("Settings saved; background runtime autostart disabled")); - return; - } - - cbAutostart_->setChecked(autostartEnabled_); - const QString error = output.isEmpty() - ? tr("systemctl failed with exit code %1").arg(exitCode) - : output; - emit statusMessage(tr("Failed to change background runtime autostart: %1") - .arg(error)); - QMessageBox::critical( - this, tr("Autostart"), - tr("Failed to change background runtime autostart: %1") - .arg(error)); -} diff --git a/src/settingspage.h b/src/settingspage.h deleted file mode 100644 index 2bce01c..0000000 --- a/src/settingspage.h +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -class DeviceManager; -class FirmwareUpdater; -class QGroupBox; - -class SettingsPage : public QWidget { - Q_OBJECT -public: - explicit SettingsPage(DeviceManager *deviceMgr, QWidget *parent = nullptr); - - QString selectedPort() const; - int keepaliveInterval() const; - bool minimizeToTray() const; - bool startMinimized() const; - -signals: - void statusMessage(const QString &msg); - void settingsChanged(); - -private slots: - void onRefreshPorts(); - void onShowDeviceInfo(); - void onSelectFirmwarePackage(); - void onValidateFirmwarePackage(); - void onFlashFirmware(); - void onFirmwareStatusChanged(const QString &message); - void onFirmwareProgressChanged(int value); - void onFirmwareFinished(bool success, const QString &message); - void onResetSettings(); - void onSaveSettings(); - void onAutostartCommandFinished(int exitCode, - QProcess::ExitStatus exitStatus); - -private: - void setupUi(); - void loadSettings(); - void setFirmwareBusy(bool busy); - void updateFirmwareControls(); - QString firmwareDependencyMessage(bool includeFlasher) const; - void queryAutostartState(); - void startAutostartCommand(bool enable); - - enum class AutostartOperation { - None, - Query, - Enable, - Disable - }; - - DeviceManager *deviceMgr_; - FirmwareUpdater *firmwareUpdater_; - QProcess *autostartProcess_; - QGroupBox *connectionGroup_; - QComboBox *portCombo_; - QSpinBox *keepaliveSpin_; - QCheckBox *cbMinimizeToTray_; - QCheckBox *cbStartMinimized_; - QCheckBox *cbAutostart_; - QPushButton *deviceInfoBtn_; - QLabel *firmwarePackageLabel_; - QLabel *firmwareStatusLabel_; - QProgressBar *firmwareProgress_; - QPushButton *selectFirmwareBtn_; - QPushButton *validateFirmwareBtn_; - QPushButton *flashFirmwareBtn_; - QPushButton *resetBtn_; - QPushButton *saveBtn_; - QPushButton *refreshPortsBtn_; - QString firmwarePackagePath_; - bool firmwarePackageValidated_ = false; - bool firmwarePackageFlashSupported_ = false; - bool firmwarePackageNeedsRockchipFlasher_ = false; - AutostartOperation autostartOperation_ = AutostartOperation::None; - bool autostartEnabled_ = false; -}; diff --git a/src/splitconfig.cpp b/src/splitconfig.cpp deleted file mode 100644 index e6a1f75..0000000 --- a/src/splitconfig.cpp +++ /dev/null @@ -1,558 +0,0 @@ -#include "splitconfig.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace { - -const QString kCustomColorAction = - QStringLiteral("__choose_custom_color__"); - -} // namespace - -static const char *METRIC_LABELS[] = { - QT_TRANSLATE_NOOP("SplitConfigWidget", "CPU Temperature"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "CPU Frequency"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "CPU Usage"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "CPU Power"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "GPU Temperature"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "GPU Frequency"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "GPU Usage"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "GPU Power"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "Memory Frequency"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "Memory Usage"), - QT_TRANSLATE_NOOP("SplitConfigWidget", "Date&Time") -}; - -SplitConfigWidget::SplitConfigWidget(QWidget *parent) - : QWidget(parent) { - setupUi(); -} - -void SplitConfigWidget::setupUi() { - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(12); - - // Preview frames side by side - auto *previewLayout = new QHBoxLayout; - previewLayout->setSpacing(12); - - // Left preview - auto *leftBox = new QVBoxLayout; - auto *leftLabel = new QLabel(tr("Left")); - leftLabel->setAlignment(Qt::AlignCenter); - leftLabel->setStyleSheet("color: #ccc; font-weight: bold; font-size: 11px;"); - leftBox->addWidget(leftLabel); - - leftPreview_ = new QLabel; - leftPreview_->setMinimumHeight(150); - leftPreview_->setMinimumWidth(200); - leftPreview_->setAlignment(Qt::AlignCenter); - leftPreview_->setScaledContents(false); - leftPreview_->setStyleSheet( - "QLabel { background: #1e1e2e; border: 2px dashed #555; border-radius: 8px; color: #555; font-size: 12px; }"); - leftPreview_->setText(tr("Drop media here")); - leftBox->addWidget(leftPreview_); - - leftFileLabel_ = new QLabel; - leftFileLabel_->setAlignment(Qt::AlignCenter); - leftFileLabel_->setStyleSheet("color: #888; font-size: 10px;"); - leftBox->addWidget(leftFileLabel_); - - previewLayout->addLayout(leftBox, 1); - - // Right preview - auto *rightBox = new QVBoxLayout; - auto *rightLabel = new QLabel(tr("Right")); - rightLabel->setAlignment(Qt::AlignCenter); - rightLabel->setStyleSheet("color: #ccc; font-weight: bold; font-size: 11px;"); - rightBox->addWidget(rightLabel); - - rightPreview_ = new QLabel; - rightPreview_->setMinimumHeight(150); - rightPreview_->setMinimumWidth(200); - rightPreview_->setAlignment(Qt::AlignCenter); - rightPreview_->setScaledContents(false); - rightPreview_->setStyleSheet( - "QLabel { background: #1e1e2e; border: 2px dashed #555; border-radius: 8px; color: #555; font-size: 12px; }"); - rightPreview_->setText(tr("Drop media here")); - rightBox->addWidget(rightPreview_); - - rightFileLabel_ = new QLabel; - rightFileLabel_->setAlignment(Qt::AlignCenter); - rightFileLabel_->setStyleSheet("color: #888; font-size: 10px;"); - rightBox->addWidget(rightFileLabel_); - - previewLayout->addLayout(rightBox, 1); - mainLayout->addLayout(previewLayout); - - // Settings row - auto *settingsLayout = new QHBoxLayout; - settingsLayout->setSpacing(12); - - settingsLayout->addWidget(new QLabel(tr("Play Mode:"))); - playModeCombo_ = new QComboBox; - playModeCombo_->addItem(tr("Single"), "Single"); - playModeCombo_->addItem(tr("Shuffle"), "Shuffle"); - playModeCombo_->addItem(tr("Loop"), "Loop"); - settingsLayout->addWidget(playModeCombo_); - - // Left metrics button - leftMetricsBtn_ = new QToolButton; - leftMetricsBtn_->setText(QString::fromUtf8("%1: 0 / 3 \u25BC").arg(tr("Left"))); - leftMetricsBtn_->setPopupMode(QToolButton::InstantPopup); - leftMetricsBtn_->setStyleSheet( - "QToolButton { background: #2a2a3e; color: #fff; border: 1px solid #4a4a5e; " - "border-radius: 4px; padding: 6px 12px; min-width: 100px; font-size: 12px; } " - "QToolButton::menu-indicator { image: none; } " - "QToolButton:hover { background: #3a3a4e; }"); - - leftMetricsMenu_ = new QMenu(this); - for (const auto *label : METRIC_LABELS) { - auto *wa = new QWidgetAction(leftMetricsMenu_); - auto *cb = new QCheckBox(tr(label)); - cb->setProperty("protocolLabel", label); - cb->setStyleSheet("QCheckBox { color: #fff; padding: 4px 8px; } QCheckBox:hover { background: #3a3a4e; }"); - wa->setDefaultWidget(cb); - leftMetricsMenu_->addAction(wa); - leftMetricCheckboxes_.append(cb); - connect(cb, &QCheckBox::toggled, this, [this](bool) { - int count = 0; - for (auto *c : leftMetricCheckboxes_) { - if (c->isChecked()) count++; - } - if (count > 3) { - auto *sender = qobject_cast(QObject::sender()); - if (sender) sender->setChecked(false); - return; - } - rebuildMetricsButtonCb(leftMetricsBtn_, leftMetricCheckboxes_, tr("Left")); - }); - } - leftMetricsBtn_->setMenu(leftMetricsMenu_); - settingsLayout->addWidget(leftMetricsBtn_); - - // Right metrics button - rightMetricsBtn_ = new QToolButton; - rightMetricsBtn_->setText(QString::fromUtf8("%1: 0 / 3 \u25BC").arg(tr("Right"))); - rightMetricsBtn_->setPopupMode(QToolButton::InstantPopup); - rightMetricsBtn_->setStyleSheet( - "QToolButton { background: #2a2a3e; color: #fff; border: 1px solid #4a4a5e; " - "border-radius: 4px; padding: 6px 12px; min-width: 100px; font-size: 12px; } " - "QToolButton::menu-indicator { image: none; } " - "QToolButton:hover { background: #3a3a4e; }"); - - rightMetricsMenu_ = new QMenu(this); - for (const auto *label : METRIC_LABELS) { - auto *wa = new QWidgetAction(rightMetricsMenu_); - auto *cb = new QCheckBox(tr(label)); - cb->setProperty("protocolLabel", label); - cb->setStyleSheet("QCheckBox { color: #fff; padding: 4px 8px; } QCheckBox:hover { background: #3a3a4e; }"); - wa->setDefaultWidget(cb); - rightMetricsMenu_->addAction(wa); - rightMetricCheckboxes_.append(cb); - connect(cb, &QCheckBox::toggled, this, [this](bool) { - int count = 0; - for (auto *c : rightMetricCheckboxes_) { - if (c->isChecked()) count++; - } - if (count > 3) { - auto *sender = qobject_cast(QObject::sender()); - if (sender) sender->setChecked(false); - return; - } - rebuildMetricsButtonCb(rightMetricsBtn_, rightMetricCheckboxes_, tr("Right")); - }); - } - rightMetricsBtn_->setMenu(rightMetricsMenu_); - settingsLayout->addWidget(rightMetricsBtn_); - - settingsLayout->addStretch(); - mainLayout->addLayout(settingsLayout); - - auto *badgesLayout = new QHBoxLayout; - badgesLayout->setSpacing(12); - badgesLayout->addWidget(new QLabel(tr("Badges:"))); - leftCpuBadge_ = new QCheckBox(tr("Left CPU")); - leftGpuBadge_ = new QCheckBox(tr("Left GPU")); - rightCpuBadge_ = new QCheckBox(tr("Right CPU")); - rightGpuBadge_ = new QCheckBox(tr("Right GPU")); - const QList badges{ - leftCpuBadge_, leftGpuBadge_, rightCpuBadge_, rightGpuBadge_}; - for (QCheckBox *badge : badges) { - badge->setStyleSheet("QCheckBox { color: #ccc; }"); - badgesLayout->addWidget(badge); - } - badgesLayout->addStretch(); - mainLayout->addLayout(badgesLayout); - - const auto addAreaSettings = - [this, mainLayout]( - const QString &side, QComboBox **positionCombo, - QComboBox **colorCombo, QComboBox **alignmentCombo) { - auto *layout = new QHBoxLayout; - layout->setSpacing(10); - auto *sideLabel = new QLabel(side); - sideLabel->setStyleSheet( - "color: #ccc; font-weight: bold;"); - layout->addWidget(sideLabel); - - layout->addWidget(new QLabel(tr("Position:"))); - *positionCombo = new QComboBox; - (*positionCombo)->addItem(tr("Top"), "Top"); - (*positionCombo)->addItem(tr("Bottom"), "Bottom"); - layout->addWidget(*positionCombo); - - layout->addWidget(new QLabel(tr("Color:"))); - *colorCombo = new QComboBox; - (*colorCombo)->addItem(tr("Light"), "#dcdcdc"); - (*colorCombo)->addItem(tr("Black"), "#000000"); - (*colorCombo)->addItem(tr("Custom..."), - kCustomColorAction); - (*colorCombo)->setProperty( - "selectedColor", QStringLiteral("#dcdcdc")); - connect( - *colorCombo, - qOverload(&QComboBox::currentIndexChanged), - this, [colorCombo](int index) { - const QString value = - (*colorCombo)->itemData(index).toString(); - const QColor color(value); - if (color.isValid()) { - (*colorCombo)->setProperty( - "selectedColor", color.name()); - } - }); - connect( - *colorCombo, qOverload(&QComboBox::activated), - this, [this, colorCombo](int index) { - if ((*colorCombo)->itemData(index).toString() == - kCustomColorAction) { - chooseCustomColor(*colorCombo); - } - }); - layout->addWidget(*colorCombo); - - layout->addWidget(new QLabel(tr("Align:"))); - *alignmentCombo = new QComboBox; - (*alignmentCombo)->addItem(tr("Left"), "Left"); - (*alignmentCombo)->addItem(tr("Center"), "Center"); - (*alignmentCombo)->addItem(tr("Right"), "Right"); - layout->addWidget(*alignmentCombo); - layout->addStretch(); - mainLayout->addLayout(layout); - }; - addAreaSettings( - tr("Left"), &leftPositionCombo_, &leftColorCombo_, - &leftAlignmentCombo_); - addAreaSettings( - tr("Right"), &rightPositionCombo_, &rightColorCombo_, - &rightAlignmentCombo_); -} - -void SplitConfigWidget::rebuildMetricsButtonCb(QToolButton *btn, const QList &checkboxes, const QString &side) { - int count = 0; - for (auto *c : checkboxes) { - if (c->isChecked()) count++; - } - btn->setText(QString::fromUtf8("%1: %2 / 3 \u25BC").arg(side).arg(count)); -} - -QStringList SplitConfigWidget::leftMedia() const { - QStringList list; - if (!leftFilename_.isEmpty()) - list << leftFilename_; - return list; -} - -QStringList SplitConfigWidget::rightMedia() const { - QStringList list; - if (!rightFilename_.isEmpty()) - list << rightFilename_; - return list; -} - -QStringList SplitConfigWidget::leftMetrics() const { - QStringList list; - for (auto *c : leftMetricCheckboxes_) { - if (c->isChecked()) - list << c->property("protocolLabel").toString(); - } - return list; -} - -QStringList SplitConfigWidget::rightMetrics() const { - QStringList list; - for (auto *c : rightMetricCheckboxes_) { - if (c->isChecked()) - list << c->property("protocolLabel").toString(); - } - return list; -} - -QStringList SplitConfigWidget::checkedBadges(QCheckBox *cpu, - QCheckBox *gpu) { - QStringList badges; - if (cpu && cpu->isChecked()) { - badges.append(QStringLiteral("CPU Badge")); - } - if (gpu && gpu->isChecked()) { - badges.append(QStringLiteral("GPU Badge")); - } - return badges; -} - -QStringList SplitConfigWidget::leftBadges() const { - return checkedBadges(leftCpuBadge_, leftGpuBadge_); -} - -QStringList SplitConfigWidget::rightBadges() const { - return checkedBadges(rightCpuBadge_, rightGpuBadge_); -} - -QString SplitConfigWidget::leftPosition() const { - return leftPositionCombo_->currentData().toString(); -} - -QString SplitConfigWidget::rightPosition() const { - return rightPositionCombo_->currentData().toString(); -} - -QString SplitConfigWidget::leftColor() const { - return colorComboValue(leftColorCombo_); -} - -QString SplitConfigWidget::rightColor() const { - return colorComboValue(rightColorCombo_); -} - -QString SplitConfigWidget::colorComboValue( - const QComboBox *combo) { - if (!combo) { - return QStringLiteral("#dcdcdc"); - } - const QColor color( - combo->property("selectedColor").toString()); - return color.isValid() - ? color.name() - : QStringLiteral("#dcdcdc"); -} - -void SplitConfigWidget::chooseCustomColor(QComboBox *combo) { - if (!combo) { - return; - } - const QString previous = colorComboValue(combo); - const QColor selected = QColorDialog::getColor( - QColor(previous), this, tr("Text Color")); - if (!selected.isValid()) { - setColorComboValue(combo, previous); - return; - } - setColorComboValue(combo, selected.name()); -} - -void SplitConfigWidget::setColorComboValue( - QComboBox *combo, const QString &colorValue) { - if (!combo) { - return; - } - const QColor color(colorValue); - if (!color.isValid()) { - return; - } - const QString normalized = color.name(); - int index = combo->findData(normalized); - if (index < 0) { - const QString previousCustom = - combo->property("customColor").toString(); - const int previousIndex = - combo->findData(previousCustom); - if (!previousCustom.isEmpty() && - previousIndex >= 0) { - combo->removeItem(previousIndex); - } - const int actionIndex = - combo->findData(kCustomColorAction); - const int insertIndex = - actionIndex >= 0 ? actionIndex : combo->count(); - combo->insertItem( - insertIndex, - tr("Selected: %1").arg(normalized), - normalized); - combo->setProperty("customColor", normalized); - index = insertIndex; - } - combo->setProperty("selectedColor", normalized); - const QSignalBlocker blocker(combo); - combo->setCurrentIndex(index); -} - -QString SplitConfigWidget::leftAlignment() const { - return leftAlignmentCombo_->currentData().toString(); -} - -QString SplitConfigWidget::rightAlignment() const { - return rightAlignmentCombo_->currentData().toString(); -} - -QString SplitConfigWidget::playMode() const { - return playModeCombo_->currentData().toString(); -} - -void SplitConfigWidget::assignToLeft(const QString &filename, const QPixmap &thumb) { - leftFilename_ = filename; - if (filename.isEmpty()) { - leftPreview_->clear(); - leftPreview_->setText(tr("Drop media here")); - leftPreview_->setStyleSheet( - "QLabel { background: #1e1e2e; border: 2px dashed #555; border-radius: 8px; color: #555; font-size: 12px; }"); - leftFileLabel_->clear(); - return; - } - if (!thumb.isNull()) { - QSize labelSize = leftPreview_->size(); - if (labelSize.width() < 50) labelSize = QSize(200, 150); - leftPreview_->setPixmap(thumb.scaled(labelSize - QSize(8, 8), - Qt::KeepAspectRatio, Qt::SmoothTransformation)); - leftPreview_->setStyleSheet( - "QLabel { background: #1e1e2e; border: 2px solid #6c5ce7; border-radius: 8px; padding: 4px; }"); - } else { - leftPreview_->clear(); - leftPreview_->setText(filename); - leftPreview_->setStyleSheet( - "QLabel { background: #1e1e2e; border: 2px solid #6c5ce7; border-radius: 8px; color: #aaa; font-size: 11px; padding: 4px; }"); - } - leftFileLabel_->setText(filename); -} - -void SplitConfigWidget::assignToRight(const QString &filename, const QPixmap &thumb) { - rightFilename_ = filename; - if (filename.isEmpty()) { - rightPreview_->clear(); - rightPreview_->setText(tr("Drop media here")); - rightPreview_->setStyleSheet( - "QLabel { background: #1e1e2e; border: 2px dashed #555; border-radius: 8px; color: #555; font-size: 12px; }"); - rightFileLabel_->clear(); - return; - } - if (!thumb.isNull()) { - QSize labelSize = rightPreview_->size(); - if (labelSize.width() < 50) labelSize = QSize(200, 150); - rightPreview_->setPixmap(thumb.scaled(labelSize - QSize(8, 8), - Qt::KeepAspectRatio, Qt::SmoothTransformation)); - rightPreview_->setStyleSheet( - "QLabel { background: #1e1e2e; border: 2px solid #6c5ce7; border-radius: 8px; padding: 4px; }"); - } else { - rightPreview_->clear(); - rightPreview_->setText(filename); - rightPreview_->setStyleSheet( - "QLabel { background: #1e1e2e; border: 2px solid #6c5ce7; border-radius: 8px; color: #aaa; font-size: 11px; padding: 4px; }"); - } - rightFileLabel_->setText(filename); -} - -void SplitConfigWidget::setMetricSelection( - const QList &checkboxes, - const QStringList &metrics) { - for (QCheckBox *checkbox : checkboxes) { - const QSignalBlocker blocker(checkbox); - checkbox->setChecked( - metrics.contains( - checkbox->property("protocolLabel").toString())); - } -} - -void SplitConfigWidget::setConfiguration( - const QString &leftMedia, const QString &rightMedia, - const QStringList &leftMetrics, - const QStringList &rightMetrics, - const QStringList &leftBadges, - const QStringList &rightBadges, - const QString &playMode) { - assignToLeft(leftMedia, {}); - assignToRight(rightMedia, {}); - setMetricSelection(leftMetricCheckboxes_, leftMetrics); - setMetricSelection(rightMetricCheckboxes_, rightMetrics); - rebuildMetricsButtonCb(leftMetricsBtn_, leftMetricCheckboxes_, - tr("Left")); - rebuildMetricsButtonCb(rightMetricsBtn_, rightMetricCheckboxes_, - tr("Right")); - { - const QSignalBlocker blocker(leftCpuBadge_); - leftCpuBadge_->setChecked( - leftBadges.contains(QStringLiteral("CPU Badge"))); - } - { - const QSignalBlocker blocker(leftGpuBadge_); - leftGpuBadge_->setChecked( - leftBadges.contains(QStringLiteral("GPU Badge"))); - } - { - const QSignalBlocker blocker(rightCpuBadge_); - rightCpuBadge_->setChecked( - rightBadges.contains(QStringLiteral("CPU Badge"))); - } - { - const QSignalBlocker blocker(rightGpuBadge_); - rightGpuBadge_->setChecked( - rightBadges.contains(QStringLiteral("GPU Badge"))); - } - const int playModeIndex = playModeCombo_->findData(playMode); - if (playModeIndex >= 0) { - const QSignalBlocker blocker(playModeCombo_); - playModeCombo_->setCurrentIndex(playModeIndex); - } -} - -void SplitConfigWidget::setAreaSettings( - const QString &leftPosition, const QString &leftColor, - const QString &leftAlignment, const QString &rightPosition, - const QString &rightColor, const QString &rightAlignment) { - const auto setCombo = [](QComboBox *combo, const QString &value) { - const int index = combo->findData(value); - if (index >= 0) { - const QSignalBlocker blocker(combo); - combo->setCurrentIndex(index); - } - }; - setCombo(leftPositionCombo_, leftPosition); - setColorComboValue(leftColorCombo_, leftColor); - setCombo(leftAlignmentCombo_, leftAlignment); - setCombo(rightPositionCombo_, rightPosition); - setColorComboValue(rightColorCombo_, rightColor); - setCombo(rightAlignmentCombo_, rightAlignment); -} - -void SplitConfigWidget::setAvailableMetrics( - const QStringList &metrics) { - const auto update = [&metrics](const QList &checkboxes) { - for (QCheckBox *checkbox : checkboxes) { - const QString label = - checkbox->property("protocolLabel").toString(); - checkbox->setEnabled( - checkbox->isChecked() || metrics.isEmpty() || - metrics.contains(label)); - } - }; - update(leftMetricCheckboxes_); - update(rightMetricCheckboxes_); -} - -void SplitConfigWidget::setPaseMode(bool enabled) { - const QSignalBlocker blocker(playModeCombo_); - if (enabled) { - const int singleIndex = - playModeCombo_->findData(QStringLiteral("Single")); - if (singleIndex >= 0) { - playModeCombo_->setCurrentIndex(singleIndex); - } - } - playModeCombo_->setEnabled(!enabled); -} diff --git a/src/splitconfig.h b/src/splitconfig.h deleted file mode 100644 index cf7b959..0000000 --- a/src/splitconfig.h +++ /dev/null @@ -1,90 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -class SplitConfigWidget : public QWidget { - Q_OBJECT -public: - explicit SplitConfigWidget(QWidget *parent = nullptr); - - QStringList leftMedia() const; - QStringList rightMedia() const; - QStringList leftMetrics() const; - QStringList rightMetrics() const; - QStringList leftBadges() const; - QStringList rightBadges() const; - QString leftPosition() const; - QString rightPosition() const; - QString leftColor() const; - QString rightColor() const; - QString leftAlignment() const; - QString rightAlignment() const; - QString playMode() const; - - void assignToLeft(const QString &filename, const QPixmap &thumb); - void assignToRight(const QString &filename, const QPixmap &thumb); - void setConfiguration(const QString &leftMedia, - const QString &rightMedia, - const QStringList &leftMetrics, - const QStringList &rightMetrics, - const QStringList &leftBadges, - const QStringList &rightBadges, - const QString &playMode); - void setAreaSettings(const QString &leftPosition, - const QString &leftColor, - const QString &leftAlignment, - const QString &rightPosition, - const QString &rightColor, - const QString &rightAlignment); - void setAvailableMetrics(const QStringList &metrics); - void setPaseMode(bool enabled); - -private: - void setupUi(); - void rebuildMetricsButtonCb(QToolButton *btn, const QList &checkboxes, const QString &side); - void setMetricSelection(const QList &checkboxes, - const QStringList &metrics); - void chooseCustomColor(QComboBox *combo); - void setColorComboValue(QComboBox *combo, - const QString &color); - static QString colorComboValue(const QComboBox *combo); - static QStringList checkedBadges(QCheckBox *cpu, QCheckBox *gpu); - - // Preview frames - QLabel *leftPreview_; - QLabel *rightPreview_; - QLabel *leftFileLabel_; - QLabel *rightFileLabel_; - - // Settings - QComboBox *playModeCombo_; - QToolButton *leftMetricsBtn_; - QToolButton *rightMetricsBtn_; - QMenu *leftMetricsMenu_; - QMenu *rightMetricsMenu_; - QList leftMetricCheckboxes_; - QList rightMetricCheckboxes_; - QCheckBox *leftCpuBadge_; - QCheckBox *leftGpuBadge_; - QCheckBox *rightCpuBadge_; - QCheckBox *rightGpuBadge_; - QComboBox *leftPositionCombo_; - QComboBox *leftColorCombo_; - QComboBox *leftAlignmentCombo_; - QComboBox *rightPositionCombo_; - QComboBox *rightColorCombo_; - QComboBox *rightAlignmentCombo_; - - // Media assignments - QString leftFilename_; - QString rightFilename_; -}; diff --git a/src/systemmonitor.cpp b/src/systemmonitor.cpp index afec02e..c8f4461 100644 --- a/src/systemmonitor.cpp +++ b/src/systemmonitor.cpp @@ -610,11 +610,15 @@ NetMetrics SystemMonitor::readNetMetrics() { QFile file("/proc/net/dev"); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + prevRxBytes_ = 0; + prevTxBytes_ = 0; + prevNetTimestamp_ = 0; return net; } int64_t totalRx = 0; int64_t totalTx = 0; + bool countersAvailable = false; QTextStream in(&file); QString line; @@ -631,18 +635,34 @@ NetMetrics SystemMonitor::readNetMetrics() { QStringList parts = line.section(':', 1).split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); if (parts.size() >= 9) { - totalRx += parts[0].toLongLong(); - totalTx += parts[8].toLongLong(); + bool rxOk = false; + bool txOk = false; + const int64_t rxBytes = parts[0].toLongLong(&rxOk); + const int64_t txBytes = parts[8].toLongLong(&txOk); + if (rxOk && txOk && rxBytes >= 0 && txBytes >= 0) { + totalRx += rxBytes; + totalTx += txBytes; + countersAvailable = true; + } } } - int64_t now = QDateTime::currentMSecsSinceEpoch(); + if (!countersAvailable) { + prevRxBytes_ = 0; + prevTxBytes_ = 0; + prevNetTimestamp_ = 0; + return net; + } - if (prevNetTimestamp_ > 0) { - double dtSec = (now - prevNetTimestamp_) / 1000.0; - if (dtSec > 0) { + const int64_t now = QDateTime::currentMSecsSinceEpoch(); + if (prevNetTimestamp_ > 0 && now > prevNetTimestamp_ && + totalRx >= prevRxBytes_ && totalTx >= prevTxBytes_) { + const double dtSec = + static_cast(now - prevNetTimestamp_) / 1000.0; + if (dtSec > 0.0) { net.rxSpeedKBs = (totalRx - prevRxBytes_) / 1024.0 / dtSec; net.txSpeedKBs = (totalTx - prevTxBytes_) / 1024.0 / dtSec; + net.available = true; } } @@ -655,14 +675,20 @@ NetMetrics SystemMonitor::readNetMetrics() { DiskMetrics SystemMonitor::readDiskMetrics() { DiskMetrics disk; - QStorageInfo storage = QStorageInfo::root(); - if (storage.isValid()) { - disk.totalGB = storage.bytesTotal() / (1024LL * 1024 * 1024); - int64_t freeGB = storage.bytesAvailable() / (1024LL * 1024 * 1024); - disk.usedGB = disk.totalGB - freeGB; - disk.usagePercent = disk.totalGB > 0 - ? static_cast(disk.usedGB) / disk.totalGB * 100.0 - : 0.0; + const QStorageInfo storage = QStorageInfo::root(); + const qint64 totalBytes = storage.bytesTotal(); + const qint64 availableBytes = storage.bytesAvailable(); + if (storage.isValid() && storage.isReady() && + totalBytes > 0 && availableBytes >= 0 && + availableBytes <= totalBytes) { + constexpr qint64 bytesPerGiB = 1024LL * 1024 * 1024; + const qint64 usedBytes = totalBytes - availableBytes; + disk.totalGB = totalBytes / bytesPerGiB; + disk.usedGB = usedBytes / bytesPerGiB; + disk.usagePercent = + static_cast(usedBytes) / + static_cast(totalBytes) * 100.0; + disk.usageAvailable = true; } disk.temperature = readDiskTemperature(); return disk; diff --git a/src/systemmonitor.h b/src/systemmonitor.h index 0451c28..e2f55fc 100644 --- a/src/systemmonitor.h +++ b/src/systemmonitor.h @@ -49,6 +49,7 @@ struct RamMetrics { struct NetMetrics { double rxSpeedKBs = 0.0; double txSpeedKBs = 0.0; + bool available = false; }; struct DiskMetrics { @@ -56,6 +57,7 @@ struct DiskMetrics { int64_t usedGB = 0; double usagePercent = 0.0; double temperature = 0.0; + bool usageAvailable = false; }; struct SystemMetrics { diff --git a/src/traymanager.cpp b/src/traymanager.cpp deleted file mode 100644 index f962ba7..0000000 --- a/src/traymanager.cpp +++ /dev/null @@ -1,114 +0,0 @@ -#include "traymanager.h" -#include -#include -#include - -TrayManager::TrayManager(QObject *parent) - : QObject(parent) { - setupTray(); -} - -void TrayManager::setupTray() { - trayIcon_ = new QSystemTrayIcon(this); - const QIcon appIcon(":/tryx-panorama.png"); - trayIcon_->setIcon(appIcon.isNull() - ? QApplication::style()->standardIcon(QStyle::SP_ComputerIcon) - : appIcon); - - trayMenu_ = new QMenu; - - showHideAction_ = trayMenu_->addAction(tr("Hide")); - connect(showHideAction_, &QAction::triggered, this, [this]() { - if (windowVisible_) { - windowVisible_ = false; - showHideAction_->setText(tr("Show")); - emit hideWindowRequested(); - } else { - windowVisible_ = true; - showHideAction_->setText(tr("Hide")); - emit showWindowRequested(); - } - }); - - trayMenu_->addSeparator(); - - metricsAction_ = trayMenu_->addAction(tr("Start Metrics")); - connect(metricsAction_, &QAction::triggered, this, &TrayManager::metricsToggleRequested); - - trayMenu_->addSeparator(); - - // Brightness submenu - brightnessMenu_ = trayMenu_->addMenu(tr("Brightness")); - for (int val : {25, 50, 75, 100}) { - auto *action = brightnessMenu_->addAction(QString("%1%").arg(val)); - connect(action, &QAction::triggered, this, [this, val]() { - emit brightnessChangeRequested(val); - }); - } - - trayMenu_->addSeparator(); - - auto *quitAction = trayMenu_->addAction(tr("Quit")); - connect(quitAction, &QAction::triggered, this, &TrayManager::quitRequested); - - trayIcon_->setContextMenu(trayMenu_); - - connect(trayIcon_, &QSystemTrayIcon::activated, this, - [this](QSystemTrayIcon::ActivationReason reason) { - if (reason == QSystemTrayIcon::Trigger) { - if (windowVisible_) { - windowVisible_ = false; - showHideAction_->setText(tr("Show")); - emit hideWindowRequested(); - } else { - windowVisible_ = true; - showHideAction_->setText(tr("Hide")); - emit showWindowRequested(); - } - } - }); - - updateTooltip(); -} - -void TrayManager::show() { - trayIcon_->show(); -} - -void TrayManager::hide() { - trayIcon_->hide(); -} - -void TrayManager::showNotification(const QString &title, const QString &message) { - trayIcon_->showMessage(title, message, QSystemTrayIcon::Information, 3000); -} - -void TrayManager::setConnected(bool connected) { - connected_ = connected; - updateTooltip(); -} - -void TrayManager::setMetricsRunning(bool running) { - metricsRunning_ = running; - metricsAction_->setText(running ? tr("Stop Metrics") : tr("Start Metrics")); - updateTooltip(); -} - -void TrayManager::setBrightnessValue(int value) { - brightness_ = value; - updateTooltip(); -} - -void TrayManager::updateTooltip() { - QString tooltip = "TRYX Panorama Manager"; - if (connected_) { - tooltip += "\n" + tr("Connected"); - tooltip += "\n" + tr("Brightness: %1%").arg(brightness_); - if (metricsRunning_) { - tooltip += "\n" + tr("Metrics active"); - } - } else { - tooltip += "\n" + tr("Disconnected"); - } - trayIcon_->setToolTip(tooltip); -} diff --git a/src/traymanager.h b/src/traymanager.h deleted file mode 100644 index 67bb445..0000000 --- a/src/traymanager.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -class TrayManager : public QObject { - Q_OBJECT -public: - explicit TrayManager(QObject *parent = nullptr); - - void show(); - void hide(); - void showNotification(const QString &title, const QString &message); - -public slots: - void setConnected(bool connected); - void setMetricsRunning(bool running); - void setBrightnessValue(int value); - -signals: - void showWindowRequested(); - void hideWindowRequested(); - void quitRequested(); - void brightnessChangeRequested(int value); - void metricsToggleRequested(); - -private: - void setupTray(); - void updateTooltip(); - - QSystemTrayIcon *trayIcon_; - QMenu *trayMenu_; - QAction *showHideAction_; - QAction *metricsAction_; - QMenu *brightnessMenu_; - - bool connected_ = false; - bool metricsRunning_ = false; - int brightness_ = 75; - bool windowVisible_ = true; -}; diff --git a/systemd/tryx-panorama.service b/systemd/tryx-panorama.service index df36ff6..8a52533 100644 --- a/systemd/tryx-panorama.service +++ b/systemd/tryx-panorama.service @@ -1,6 +1,6 @@ [Unit] Description=TRYX Panorama SE 360 Display Manager -Documentation=https://github.com/DXVSI/tryx-panorama-se-360-linux-gui +Documentation=https://github.com/DXVSI/Tryx-Linux-GUI PartOf=graphical-session.target After=graphical-session.target StartLimitIntervalSec=60 @@ -9,11 +9,15 @@ StartLimitBurst=5 [Service] Type=dbus BusName=org.tryx.Panorama -ExecStart=/usr/bin/tryx-panorama-manager --daemon +ExecStart=/usr/lib/tryx-panorama-manager/tryx-panorama-runtime Restart=on-failure RestartSec=1 TimeoutStartSec=15 -TimeoutStopSec=10 +# SIGTERM must reach only the runtime first. It defers exit while its adb or +# upgrade_tool child owns an irreversible firmware write. +KillMode=mixed +# A firmware write has no universally safe forced-termination deadline. +TimeoutStopSec=infinity [Install] WantedBy=graphical-session.target diff --git a/tests/printerprotocol_tests.cpp b/tests/printerprotocol_tests.cpp index 264aac8..f789c0f 100644 --- a/tests/printerprotocol_tests.cpp +++ b/tests/printerprotocol_tests.cpp @@ -3,23 +3,24 @@ #include "printerprotocol.h" #include "devicemanager.h" +#include "firmwarebridge.h" +#include "firmwareupdater.h" +#include "mediatransform.h" #include "runtimebridge.h" #include "systemmonitor.h" -#include "panoramapage.h" -#include "displaypage.h" -#include "splitconfig.h" #include "overlay.pb.h" #include "transport.pb.h" #include "configuration.pb.h" #include -#include +#include #include #include #include #include -#include +#include +#include #include #include @@ -248,6 +249,53 @@ panorama::wire::v1::Response baseResponse( return response; } +panorama::wire::v1::Response mediaCatalogResponse( + const panorama::wire::v1::Request &request, + const QByteArray &rawPath, quint32 fileSize, + bool readOnly = false, bool preset = false) { + auto response = baseResponse(request); + auto *catalog = response.mutable_media_catalog(); + auto *entry = preset + ? catalog->add_preset_file_list() + : catalog->add_media_file_list(); + entry->set_file_path( + rawPath.constData(), + static_cast(rawPath.size())); + entry->set_file_size(fileSize); + entry->set_read_only(readOnly); + return response; +} + +bool verifyNoPeerPayload(int fd, int timeoutMs, + QString *errorMessage) { + pollfd descriptor{}; + descriptor.fd = fd; + descriptor.events = POLLIN; + const int pollResult = ::poll(&descriptor, 1, timeoutMs); + if (pollResult < 0) { + if (errorMessage) { + *errorMessage = QStringLiteral( + "failed to inspect peer payload"); + } + return false; + } + if (pollResult == 0) { + return true; + } + char byte = 0; + const ssize_t received = + ::recv(fd, &byte, sizeof(byte), + MSG_DONTWAIT | MSG_PEEK); + if (received <= 0) { + return true; + } + if (errorMessage) { + *errorMessage = QStringLiteral( + "unexpected media pull request was dispatched"); + } + return false; +} + bool serveUdbBootstrap(int fd, QString *errorMessage, QByteArray *persistentBuffer = nullptr) { const QList expectedBodies = { @@ -348,6 +396,41 @@ bool writeTextFile(const QString &path, const QByteArray &contents) { file.write(contents) == contents.size(); } +TryxFirmwareRecoveryRecord firmwareRecoveryRecord( + const QString &phase = QStringLiteral("Armed"), + QChar hashCharacter = QLatin1Char('a')) { + const qint64 now = + QDateTime::currentDateTimeUtc() + .toMSecsSinceEpoch(); + TryxFirmwareRecoveryRecord record; + record.attemptId = + QUuid::createUuid().toString( + QUuid::WithoutBraces); + record.phase = phase; + record.packageKind = + QStringLiteral("RockchipBundle"); + record.packageSha256 = + QString(64, hashCharacter); + record.createdUtcMs = now; + record.updatedUtcMs = now; + return record; +} + +bool sameFirmwareRecoveryRecord( + const TryxFirmwareRecoveryRecord &left, + const TryxFirmwareRecoveryRecord &right) { + return left.attemptId == right.attemptId && + left.phase == right.phase && + left.packageKind == + right.packageKind && + left.packageSha256 == + right.packageSha256 && + left.createdUtcMs == + right.createdUtcMs && + left.updatedUtcMs == + right.updatedUtcMs; +} + bool createUsbDevice(const QString &sysRoot, const QString &name, const QByteArray &productId) { const QString devicePath = QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/") + name); @@ -425,6 +508,14 @@ public slots: const TryxRuntimeApplyRequest &request) const { return request; } + TryxRuntimeMediaTransform EchoMediaTransform( + const TryxRuntimeMediaTransform &transform) const { + return transform; + } + TryxRuntimeDeviceMediaArtifact EchoDeviceMediaArtifact( + const TryxRuntimeDeviceMediaArtifact &artifact) const { + return artifact; + } TryxRuntimeDisplayState EchoDisplayState( const TryxRuntimeDisplayState &state) const { return state; @@ -440,13 +531,29 @@ private slots: void malformedAndOversizedFrames(); void runtimeOperationDbusRoundTrip(); void runtimeMediaCatalogDbusRoundTrip(); + void runtimeDeviceMediaArtifactDbusRoundTrip(); + void runtimeArtifactAdaptorCapturesCallerIdentity(); + void deviceMediaArtifactOwnershipAndLease(); + void replaceJournalTerminalAndUnknownBoundaries(); + void recoveredOperationIdempotencySurvivesActiveHold(); + void staleReplacePreflightCannotAdvanceSaga(); + void replaceDeleteCrashWindowsUseReadOnlyReconciliation(); + void unknownApplyRecoveryKeepsOutcomeTruthful(); void runtimeLegacyMediaCatalogDbusRoundTrip(); void runtimeMetricsDbusRoundTrip(); + void runtimeMediaTransformDbusRoundTrip(); + void mediaTransformValidationAndFilters(); + void mediaTransformChangesConversionProfile(); + void runtimeUploadAdaptorsPreserveMediaTransform(); + void invalidMediaTransformDoesNotStartPreparation(); + void pendingPreparationPreservesMediaTransform(); + void mediaTransformProfilePreventsOriginReuse(); + void quickStagedSourceIsClaimedBeforeAcceptance(); + void quickStagedSourceRejectionKeepsInboxOwnership(); + void quickStagedSourceValidationAndLegacyBoundary(); + void mediaRuntimeStartupCleanupIsBounded(); void runtimeDisplayConfigDbusRoundTrip(); void remoteDisplayStateRequiresStrictlyIncreasingRevision(); - void panoramaPageRestoresDisplayAndSplitState(); - void panoramaPageUsesUnifiedMediaLibrary(); - void panoramaBrightnessCoalescesUntilTransportReady(); void paseRunConfigUsesWireLayout(); void paseWaterfallFullScreenGeometry_data(); void paseWaterfallFullScreenGeometry(); @@ -506,6 +613,19 @@ private slots: void applyPreflightTimeoutPreservesNotStartedBeforeSessionLoss(); void generationChangeWaitsForStructuredApplyOutcome(); void wireCriticalGoldenFixtures(); + void mediaReadWireGoldenFixtures(); + void mediaPullDecodesBoundedChunks(); + void mediaPullPathValidation_data(); + void mediaPullPathValidation(); + void mediaPullCatalogPreflightIsStrict_data(); + void mediaPullCatalogPreflightIsStrict(); + void mediaPullRejectsInvalidResponse_data(); + void mediaPullRejectsInvalidResponse(); + void mediaPullCancellationIsBounded(); + void mediaPullChunkAndDeadlineLimits(); + void mediaReferencePreflightReadsAllSlots(); + void mediaReferencePreflightRejectsUnsafeCatalog_data(); + void mediaReferencePreflightRejectsUnsafeCatalog(); void unknownFieldsSurviveMutation(); void discoveryStateSequence(); void productionEndpointValidationWithOfflineSysfs(); @@ -518,6 +638,24 @@ private slots: void lostPrinterSessionRejectsMutationsBeforeDispatch(); void lostPrinterSessionRequiresObservedRemovalBeforeReconnect(); void sessionNotReadyRejectsMutationsBeforeDispatch(); + void firmwareExclusiveGateRejectsDeviceWork(); + void firmwareExclusiveGateRejectsUnresolvedDeviceState(); + void firmwareExclusiveGateSuppressesReconnectUntilRelease(); + void firmwareWorkerQuiesceClosesTransport(); + void firmwareReleaseFenceWaitsForLateQuiesce(); + void approvedFirmwareStagingPinsBytes(); + void rockchipLoaderIdentityIsFailClosed(); + void rockchipRciRequiresRk3568(); + void rockchipWritesAreIdentityFenced(); + void irreversibleFirmwareTimeoutDoesNotKillProcess(); + void irreversibleFirmwareFailureDisablesReconnect(); + void firmwareRecoveryJournalPersistsAcrossRestart(); + void firmwareRecoveryInheritedSafeExitPreservesRecord(); + void firmwareRecoveryCleanSafeExitClearsRecord(); + void firmwareRecoverySuccessRequiresExplicitAcknowledgement(); + void firmwareRecoveryAcknowledgementWaitsForReleaseFence(); + void firmwareRecoveryAcknowledgementUnlinksSymlinkExactly(); + void firmwareRecoveryDirectoryEntryRemainsFailClosed(); void persistentUsbInputFailureStopsSameGenerationWithoutRecovery(); void partialUploadRequiresObservedDeviceRemovalBeforeRetry(); void tamperedV8RetryManifestIsRejected_data(); @@ -560,6 +698,8 @@ private slots: void deleteUserMediaLostAckReconcilesWithoutReplay(); void deleteUserMediaAcceptsHeaderOnlySuccess(); void deleteUserMediaRejectsReferencedFileBeforeDispatch(); + void deleteExpectedIdentityIsRecheckedBeforeDispatch(); + void deleteReplacementIdentityIsRecheckedBeforeDispatch(); void deleteUserMediaRetainsUnknownAfterBoundedReconciliation(); void deleteReconcileOnlyNeverDispatchesFileRemove(); void deleteIntentSurvivesRestartAndOnlyReconcilesSameDevice(); @@ -733,6 +873,7 @@ void PrinterProtocolTests::runtimeMediaCatalogDbusRoundTrip() { entry.managedOrigin = true; entry.deleteAllowed = true; entry.deleteBlockReason = QStringLiteral("ManagedUserMedia"); + entry.mediaId = QString(64, QLatin1Char('b')); TryxRuntimeMediaCatalogSnapshot expected; expected.revision = 9; expected.deviceIdentity = QStringLiteral("PASE-001"); @@ -766,299 +907,244 @@ void PrinterProtocolTests::runtimeMediaCatalogDbusRoundTrip() { QCOMPARE(actual.entries.first().deleteAllowed, entry.deleteAllowed); QCOMPARE(actual.entries.first().deleteBlockReason, entry.deleteBlockReason); + QCOMPARE(actual.entries.first().mediaId, entry.mediaId); } -void PrinterProtocolTests::runtimeLegacyMediaCatalogDbusRoundTrip() { +void PrinterProtocolTests::runtimeDeviceMediaArtifactDbusRoundTrip() { registerTryxRuntimeMetaTypes(); - TryxRuntimeLegacyMediaEntry entry; - entry.name = QStringLiteral("legacy.mp4.h264_2240x1080"); - entry.size = 42; - entry.source = 1; - entry.readOnly = false; - entry.thumbnailKey = QString(64, QLatin1Char('c')); - TryxRuntimeLegacyMediaCatalogSnapshot expected; - expected.revision = 8; - expected.deviceIdentity = QStringLiteral("PASE-LEGACY"); - expected.entries.append(entry); + TryxRuntimeDeviceMediaArtifact expected; + expected.schemaVersion = 1; + expected.operationId = + QStringLiteral("11111111-1111-4111-8111-111111111111"); + expected.artifactId = + QStringLiteral("22222222-2222-4222-8222-222222222222"); + expected.mediaId = QString(64, QLatin1Char('a')); + expected.deviceIdentity = QStringLiteral("PASE-001"); + expected.remoteName = + QStringLiteral("source.mp4.h264_2240x1080"); + expected.size = 123456; + expected.decodedSha256 = QString(64, QLatin1Char('b')); + expected.localPath = + QStringLiteral("/run/user/1000/tryx/device-copy.h264"); + expected.logicalType = QStringLiteral("Video"); + expected.leaseId = + QStringLiteral("33333333-3333-4333-8333-333333333333"); + expected.leaseExpiresUtcMs = 1234567890; QDBusConnection bus = QDBusConnection::sessionBus(); QVERIFY2(bus.isConnected(), qPrintable(bus.lastError().message())); RuntimeRoundTripObject serviceObject; const QString objectPath = - QStringLiteral("/org/tryx/Panorama/LegacyMediaTest/%1") + QStringLiteral("/org/tryx/Panorama/ArtifactTest/%1") .arg(QCoreApplication::applicationPid()); QVERIFY2(bus.registerObject(objectPath, &serviceObject, QDBusConnection::ExportAllSlots), qPrintable(bus.lastError().message())); QDBusInterface interface(bus.baseService(), objectPath, QStringLiteral("org.tryx.Panorama.Test"), bus); - const QDBusReply reply = - interface.call(QStringLiteral("EchoLegacyMediaCatalog"), - QVariant::fromValue(expected)); + const QDBusReply reply = + interface.call( + QStringLiteral("EchoDeviceMediaArtifact"), + QVariant::fromValue(expected)); bus.unregisterObject(objectPath); QVERIFY2(reply.isValid(), qPrintable(reply.error().message())); - const auto actual = reply.value(); - QCOMPARE(actual.revision, expected.revision); + const TryxRuntimeDeviceMediaArtifact actual = reply.value(); + QCOMPARE(actual.schemaVersion, expected.schemaVersion); + QCOMPARE(actual.operationId, expected.operationId); + QCOMPARE(actual.artifactId, expected.artifactId); + QCOMPARE(actual.mediaId, expected.mediaId); QCOMPARE(actual.deviceIdentity, expected.deviceIdentity); - QCOMPARE(actual.entries.size(), 1); - QCOMPARE(actual.entries.first().name, entry.name); - QCOMPARE(actual.entries.first().size, entry.size); - QCOMPARE(actual.entries.first().source, entry.source); - QCOMPARE(actual.entries.first().readOnly, entry.readOnly); - QCOMPARE(actual.entries.first().thumbnailKey, entry.thumbnailKey); + QCOMPARE(actual.remoteName, expected.remoteName); + QCOMPARE(actual.size, expected.size); + QCOMPARE(actual.decodedSha256, expected.decodedSha256); + QCOMPARE(actual.localPath, expected.localPath); + QCOMPARE(actual.logicalType, expected.logicalType); + QCOMPARE(actual.leaseId, expected.leaseId); + QCOMPARE(actual.leaseExpiresUtcMs, + expected.leaseExpiresUtcMs); } -void PrinterProtocolTests::runtimeMetricsDbusRoundTrip() { +void PrinterProtocolTests::runtimeArtifactAdaptorCapturesCallerIdentity() { registerTryxRuntimeMetaTypes(); - QCOMPARE(tryxRuntimeApiVersion(), 6U); - - TryxRuntimeMetricsConfigRequest expectedRequest; - expectedRequest.enabled = true; - expectedRequest.metrics = { - QStringLiteral("CPU Power"), - QStringLiteral("GPU Temperature"), - QStringLiteral("Date&Time")}; - expectedRequest.alignment = QStringLiteral("Right"); - expectedRequest.textColor = 0x000000U; - TryxRuntimeMetricsState expectedState; - expectedState.revision = 27; - expectedState.deviceSerial = QStringLiteral("PASE-METRICS-001"); - expectedState.enabled = true; - expectedState.samplingActive = true; - expectedState.metrics = expectedRequest.metrics; - expectedState.availableMetrics = { - QStringLiteral("CPU Power"), QStringLiteral("Date&Time")}; - expectedState.alignment = expectedRequest.alignment; - expectedState.textColor = expectedRequest.textColor; - expectedState.diagnostic = QStringLiteral("sensor status"); + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); QDBusConnection bus = QDBusConnection::sessionBus(); QVERIFY2(bus.isConnected(), qPrintable(bus.lastError().message())); - RuntimeRoundTripObject serviceObject; + TryxRuntimeExportedObject serviceObject; + TryxRuntimeManagerAdaptor managerAdaptor( + &serviceObject, manager.get()); + TryxRuntimeOperationsAdaptor operationsAdaptor( + &serviceObject, manager.get(), &managerAdaptor); const QString objectPath = - QStringLiteral("/org/tryx/Panorama/MetricsTest/%1") + QStringLiteral("/org/tryx/Panorama/CallerTest/%1") .arg(QCoreApplication::applicationPid()); - QVERIFY2(bus.registerObject(objectPath, &serviceObject, - QDBusConnection::ExportAllSlots), + QVERIFY2(bus.registerObject( + objectPath, &serviceObject, + QDBusConnection::ExportAdaptors), qPrintable(bus.lastError().message())); - QDBusInterface interface(bus.baseService(), objectPath, - QStringLiteral("org.tryx.Panorama.Test"), bus); - - const QDBusReply requestReply = - interface.call(QStringLiteral("EchoMetricsRequest"), - QVariant::fromValue(expectedRequest)); - QVERIFY2(requestReply.isValid(), - qPrintable(requestReply.error().message())); - const TryxRuntimeMetricsConfigRequest actualRequest = - requestReply.value(); - QCOMPARE(actualRequest.enabled, expectedRequest.enabled); - QCOMPARE(actualRequest.metrics, expectedRequest.metrics); - QCOMPARE(actualRequest.alignment, expectedRequest.alignment); - QCOMPARE(actualRequest.textColor, expectedRequest.textColor); - const QDBusReply stateReply = - interface.call(QStringLiteral("EchoMetricsState"), - QVariant::fromValue(expectedState)); + QDBusInterface interface( + bus.baseService(), objectPath, + tryxRuntimeOperationsInterfaceName(), bus); + const QString operationId = + QStringLiteral("71717171-7171-4171-8171-717171717171"); + const QDBusReply reply = interface.call( + QStringLiteral("QueueStageDeviceMedia"), + operationId, QStringLiteral("not-a-sha256")); + QVERIFY2(reply.isValid(), qPrintable(reply.error().message())); + QCOMPARE(reply.value(), operationId); + const auto found = manager->operations_.constFind(operationId); + QVERIFY(found != manager->operations_.constEnd()); + QCOMPARE(found->artifactOwner, bus.baseService()); + QCOMPARE(found->info.state, QStringLiteral("Failed")); + QCOMPARE(found->info.errorCategory, + QStringLiteral("InvalidMediaId")); + + const QDBusReply missingArtifact = + interface.call( + QStringLiteral("ClaimDeviceMediaArtifact"), + QStringLiteral( + "72727272-7272-4272-8272-727272727272"), + QStringLiteral( + "73737373-7373-4373-8373-737373737373")); bus.unregisterObject(objectPath); - QVERIFY2(stateReply.isValid(), qPrintable(stateReply.error().message())); - const TryxRuntimeMetricsState actualState = stateReply.value(); - QCOMPARE(actualState.revision, expectedState.revision); - QCOMPARE(actualState.deviceSerial, expectedState.deviceSerial); - QCOMPARE(actualState.enabled, expectedState.enabled); - QCOMPARE(actualState.samplingActive, expectedState.samplingActive); - QCOMPARE(actualState.metrics, expectedState.metrics); - QCOMPARE(actualState.availableMetrics, expectedState.availableMetrics); - QCOMPARE(actualState.alignment, expectedState.alignment); - QCOMPARE(actualState.textColor, expectedState.textColor); - QCOMPARE(actualState.diagnostic, expectedState.diagnostic); + QVERIFY(!missingArtifact.isValid()); + QCOMPARE( + missingArtifact.error().name(), + QStringLiteral( + "org.tryx.Panorama.Error.InvalidArtifact")); } -void PrinterProtocolTests::runtimeDisplayConfigDbusRoundTrip() { - registerTryxRuntimeMetaTypes(); +void PrinterProtocolTests::deviceMediaArtifactOwnershipAndLease() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + QVERIFY(manager->ensureDeviceMediaOutbox()); - TryxRuntimeApplyRequest expectedRequest; - expectedRequest.media = { - QStringLiteral("left.h264"), - QStringLiteral("right.h264")}; - expectedRequest.ratio = QStringLiteral("2:1"); - expectedRequest.screenMode = - QStringLiteral("Screen Splitting"); - expectedRequest.playMode = QStringLiteral("Single"); - expectedRequest.sysinfoLabels = { - QStringLiteral("CPU Temperature")}; - expectedRequest.settingsPosition = QStringLiteral("Top"); - expectedRequest.settingsColor = QStringLiteral("#DCDCDC"); - expectedRequest.settingsAlign = QStringLiteral("Left"); - expectedRequest.settingsBadges = { - QStringLiteral("CPU Badge")}; - expectedRequest.filterOpacity = 33; - expectedRequest.presetId = QStringLiteral("preset"); - expectedRequest.sysinfoLabels2 = { - QStringLiteral("GPU Power")}; - expectedRequest.settingsBadges2 = { - QStringLiteral("GPU Badge")}; - expectedRequest.settingsPosition2 = QStringLiteral("Bottom"); - expectedRequest.settingsColor2 = QStringLiteral("#000000"); - expectedRequest.settingsAlign2 = QStringLiteral("Right"); - expectedRequest.waterfallMode = true; - expectedRequest.replaceOverlay = true; - expectedRequest.display.brightnessPresent = true; - expectedRequest.display.brightness = 64; - expectedRequest.display.standbyPresent = true; - expectedRequest.display.standbyEnabled = false; - expectedRequest.display.orientationPresent = true; - expectedRequest.display.mirrorMode = true; - expectedRequest.display.waterfallMode = true; - expectedRequest.display.backlightPresent = true; - expectedRequest.display.backlightEnabled = false; + const QString operationId = + QStringLiteral("44444444-4444-4444-8444-444444444444"); + const QString artifactId = + QStringLiteral("55555555-5555-4555-8555-555555555555"); + const QString owner = QStringLiteral(":1.4242"); + const QByteArray payload("trusted-recovered-media"); + const QString artifactPath = + QDir(manager->deviceMediaOutboxDirectory()) + .filePath(artifactId + QStringLiteral(".h264")); + QVERIFY(writeTextFile(artifactPath, payload)); + QVERIFY(QFile::setPermissions( + artifactPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + struct stat status {}; + QVERIFY(::lstat(QFile::encodeName(artifactPath).constData(), + &status) == 0); + + DeviceManager::DeviceMediaArtifactRecord artifact; + artifact.metadata.schemaVersion = 1; + artifact.metadata.operationId = operationId; + artifact.metadata.artifactId = artifactId; + artifact.metadata.mediaId = QString(64, QLatin1Char('a')); + artifact.metadata.deviceIdentity = + QStringLiteral("PASE-ARTIFACT"); + artifact.metadata.remoteName = + QStringLiteral("source.mp4.h264_2240x1080"); + artifact.metadata.size = + static_cast(payload.size()); + artifact.metadata.decodedSha256 = + QString::fromLatin1( + QCryptographicHash::hash( + payload, QCryptographicHash::Sha256) + .toHex()); + artifact.metadata.logicalType = QStringLiteral("Video"); + artifact.ownerUniqueName = owner; + artifact.canonicalPath = + QFileInfo(artifactPath).canonicalFilePath(); + artifact.expiresUtcMs = + QDateTime::currentMSecsSinceEpoch() + 60000; + artifact.deviceNumber = + static_cast(status.st_dev); + artifact.inodeNumber = + static_cast(status.st_ino); + manager->deviceMediaArtifacts_.insert(artifactId, artifact); + + DeviceManager::OperationRecord operation; + operation.info.id = operationId; + operation.info.kind = QStringLiteral("StageDeviceMedia"); + operation.info.state = QStringLiteral("Succeeded"); + operation.info.resultName = artifactId; + operation.artifactId = artifactId; + operation.artifactOwner = owner; + manager->operations_.insert(operationId, operation); - TryxRuntimeDisplayState expectedState; - expectedState.revision = 41; - expectedState.deviceSerial = QStringLiteral("PASE-DISPLAY-001"); - expectedState.valid = true; - expectedState.backlightEnabled = true; - expectedState.brightness = 64; - expectedState.standbyEnabled = false; - expectedState.standbyMedia = QStringLiteral("standby.h264"); - expectedState.mirrorMode = true; - expectedState.waterfallMode = true; - expectedState.screenMode = expectedRequest.screenMode; - expectedState.playMode = expectedRequest.playMode; - expectedState.media = expectedRequest.media; - expectedState.sysinfoLabels = expectedRequest.sysinfoLabels; - expectedState.settingsBadges = expectedRequest.settingsBadges; - expectedState.settingsPosition = - expectedRequest.settingsPosition; - expectedState.settingsColor = expectedRequest.settingsColor; - expectedState.settingsAlign = expectedRequest.settingsAlign; - expectedState.sysinfoLabels2 = - expectedRequest.sysinfoLabels2; - expectedState.settingsBadges2 = - expectedRequest.settingsBadges2; - expectedState.settingsPosition2 = - expectedRequest.settingsPosition2; - expectedState.settingsColor2 = - expectedRequest.settingsColor2; - expectedState.settingsAlign2 = - expectedRequest.settingsAlign2; - expectedState.diagnostic = QStringLiteral("display status"); + QString error; + QVERIFY(!manager->renewDeviceMediaArtifactLease( + artifactId, QString(), owner, &error)); + QVERIFY(!manager->releaseDeviceMediaArtifact( + artifactId, QString(), owner, &error)); + QVERIFY(QFileInfo::exists(artifactPath)); + + QVERIFY(manager->claimDeviceMediaArtifact( + operationId, artifactId, + QStringLiteral(":1.99"), &error) + .artifactId.isEmpty()); + QVERIFY(!error.isEmpty()); - QDBusConnection bus = QDBusConnection::sessionBus(); - QVERIFY2(bus.isConnected(), qPrintable(bus.lastError().message())); - RuntimeRoundTripObject serviceObject; - const QString objectPath = - QStringLiteral("/org/tryx/Panorama/DisplayTest/%1") - .arg(QCoreApplication::applicationPid()); - QVERIFY2(bus.registerObject(objectPath, &serviceObject, - QDBusConnection::ExportAllSlots), - qPrintable(bus.lastError().message())); - QDBusInterface interface(bus.baseService(), objectPath, - QStringLiteral("org.tryx.Panorama.Test"), bus); - - const QDBusReply requestReply = - interface.call(QStringLiteral("EchoApplyRequest"), - QVariant::fromValue(expectedRequest)); - QVERIFY2(requestReply.isValid(), - qPrintable(requestReply.error().message())); - const TryxRuntimeApplyRequest actualRequest = requestReply.value(); - QCOMPARE(actualRequest.media, expectedRequest.media); - QCOMPARE(actualRequest.ratio, expectedRequest.ratio); - QCOMPARE(actualRequest.screenMode, expectedRequest.screenMode); - QCOMPARE(actualRequest.playMode, expectedRequest.playMode); - QCOMPARE(actualRequest.sysinfoLabels, - expectedRequest.sysinfoLabels); - QCOMPARE(actualRequest.settingsPosition, - expectedRequest.settingsPosition); - QCOMPARE(actualRequest.settingsColor, - expectedRequest.settingsColor); - QCOMPARE(actualRequest.settingsAlign, - expectedRequest.settingsAlign); - QCOMPARE(actualRequest.settingsBadges, - expectedRequest.settingsBadges); - QCOMPARE(actualRequest.filterOpacity, - expectedRequest.filterOpacity); - QCOMPARE(actualRequest.presetId, expectedRequest.presetId); - QCOMPARE(actualRequest.sysinfoLabels2, - expectedRequest.sysinfoLabels2); - QCOMPARE(actualRequest.settingsBadges2, - expectedRequest.settingsBadges2); - QCOMPARE(actualRequest.settingsPosition2, - expectedRequest.settingsPosition2); - QCOMPARE(actualRequest.settingsColor2, - expectedRequest.settingsColor2); - QCOMPARE(actualRequest.settingsAlign2, - expectedRequest.settingsAlign2); - QCOMPARE(actualRequest.waterfallMode, - expectedRequest.waterfallMode); - QCOMPARE(actualRequest.replaceOverlay, - expectedRequest.replaceOverlay); - QCOMPARE(actualRequest.display.brightnessPresent, - expectedRequest.display.brightnessPresent); - QCOMPARE(actualRequest.display.brightness, - expectedRequest.display.brightness); - QCOMPARE(actualRequest.display.standbyPresent, - expectedRequest.display.standbyPresent); - QCOMPARE(actualRequest.display.standbyEnabled, - expectedRequest.display.standbyEnabled); - QCOMPARE(actualRequest.display.orientationPresent, - expectedRequest.display.orientationPresent); - QCOMPARE(actualRequest.display.mirrorMode, - expectedRequest.display.mirrorMode); - QCOMPARE(actualRequest.display.waterfallMode, - expectedRequest.display.waterfallMode); - QCOMPARE(actualRequest.display.backlightPresent, - expectedRequest.display.backlightPresent); - QCOMPARE(actualRequest.display.backlightEnabled, - expectedRequest.display.backlightEnabled); - - const QDBusReply stateReply = - interface.call(QStringLiteral("EchoDisplayState"), - QVariant::fromValue(expectedState)); - bus.unregisterObject(objectPath); - QVERIFY2(stateReply.isValid(), - qPrintable(stateReply.error().message())); - const TryxRuntimeDisplayState actualState = stateReply.value(); - QCOMPARE(actualState.revision, expectedState.revision); - QCOMPARE(actualState.deviceSerial, expectedState.deviceSerial); - QCOMPARE(actualState.valid, expectedState.valid); - QCOMPARE(actualState.backlightEnabled, - expectedState.backlightEnabled); - QCOMPARE(actualState.brightness, expectedState.brightness); - QCOMPARE(actualState.standbyEnabled, - expectedState.standbyEnabled); - QCOMPARE(actualState.standbyMedia, expectedState.standbyMedia); - QCOMPARE(actualState.mirrorMode, expectedState.mirrorMode); - QCOMPARE(actualState.waterfallMode, expectedState.waterfallMode); - QCOMPARE(actualState.screenMode, expectedState.screenMode); - QCOMPARE(actualState.playMode, expectedState.playMode); - QCOMPARE(actualState.media, expectedState.media); - QCOMPARE(actualState.sysinfoLabels, - expectedState.sysinfoLabels); - QCOMPARE(actualState.settingsBadges, - expectedState.settingsBadges); - QCOMPARE(actualState.settingsPosition, - expectedState.settingsPosition); - QCOMPARE(actualState.settingsColor, - expectedState.settingsColor); - QCOMPARE(actualState.settingsAlign, - expectedState.settingsAlign); - QCOMPARE(actualState.sysinfoLabels2, - expectedState.sysinfoLabels2); - QCOMPARE(actualState.settingsBadges2, - expectedState.settingsBadges2); - QCOMPARE(actualState.settingsPosition2, - expectedState.settingsPosition2); - QCOMPARE(actualState.settingsColor2, - expectedState.settingsColor2); - QCOMPARE(actualState.settingsAlign2, - expectedState.settingsAlign2); - QCOMPARE(actualState.diagnostic, expectedState.diagnostic); -} + error.clear(); + const TryxRuntimeDeviceMediaArtifact claimed = + manager->claimDeviceMediaArtifact( + operationId, artifactId, owner, &error); + QVERIFY2(!claimed.artifactId.isEmpty(), + qPrintable(error)); + QCOMPARE(claimed.localPath, artifact.canonicalPath); + QVERIFY(!claimed.leaseId.isEmpty()); + QVERIFY(claimed.leaseExpiresUtcMs > + QDateTime::currentMSecsSinceEpoch()); + const TryxRuntimeDeviceMediaArtifact claimedAgain = + manager->claimDeviceMediaArtifact( + operationId, artifactId, owner, &error); + QCOMPARE(claimedAgain.artifactId, claimed.artifactId); + QCOMPARE(claimedAgain.leaseId, claimed.leaseId); + QCOMPARE(claimedAgain.localPath, claimed.localPath); + + QVERIFY(!manager->renewDeviceMediaArtifactLease( + artifactId, QStringLiteral("wrong-lease"), owner, + &error)); + QVERIFY(manager->renewDeviceMediaArtifactLease( + artifactId, claimed.leaseId, owner, &error)); + + manager->deviceMediaArtifacts_[artifactId] + .inUseOperationId = QStringLiteral("busy"); + QVERIFY(!manager->releaseDeviceMediaArtifact( + artifactId, claimed.leaseId, owner, &error)); + QVERIFY(QFileInfo::exists(artifactPath)); + + manager->deviceMediaArtifacts_[artifactId] + .inUseOperationId.clear(); + QVERIFY(manager->releaseDeviceMediaArtifact( + artifactId, claimed.leaseId, owner, &error)); + QVERIFY(!manager->deviceMediaArtifacts_.contains(artifactId)); + QVERIFY(!QFileInfo::exists(artifactPath)); +} void PrinterProtocolTests:: -remoteDisplayStateRequiresStrictlyIncreasingRevision() { + replaceJournalTerminalAndUnknownBoundaries() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = @@ -1067,41 +1153,149 @@ remoteDisplayStateRequiresStrictlyIncreasingRevision() { const QString devRoot = QDir(temporaryDirectory.path()).filePath( QStringLiteral("dev")); - QDir().mkpath(sysRoot); - QDir().mkpath(devRoot); - std::unique_ptr manager( DeviceManager::createForTesting(sysRoot, devRoot)); - manager->remoteMode_ = true; - manager->remoteApiCompatible_ = true; - QSignalSpy displaySpy( - manager.get(), &DeviceManager::displayStateUpdated); - TryxRuntimeDisplayState initial; - initial.revision = 0; - initial.valid = true; - initial.brightness = 41; - manager->handleRemoteDisplayStateUpdated(initial); - QCOMPARE(displaySpy.count(), 1); - QCOMPARE(manager->displayState().brightness, 41); + const auto makeRecord = [](const QString &operationId, + const QString &artifactId) { + DeviceManager::OperationRecord record; + record.info.id = operationId; + record.info.kind = + QStringLiteral("ReplaceDeviceMedia"); + record.info.state = QStringLiteral("Preflight"); + record.info.terminalOutcome = + QStringLiteral("NewCopyReady"); + record.replaceOperation = true; + record.replaceJournalActive = true; + record.replaceJournal.operationId = operationId; + record.replaceJournal.deviceIdentity = + QStringLiteral("PASE-REPLACE"); + record.replaceJournal.deviceGeneration = 1; + record.replaceJournal.originalMediaId = + QString(64, QLatin1Char('a')); + record.replaceJournal.originalRemoteName = + QStringLiteral("old.mp4.h264_2240x1080"); + record.replaceJournal.originalSize = 1024; + record.replaceJournal.artifactId = artifactId; + record.replaceJournal.decodedSha256 = + QString(64, QLatin1Char('b')); + record.replaceJournal.transformFingerprint = + QString(64, QLatin1Char('c')); + record.replaceJournal.applyFingerprint = + QString(64, QLatin1Char('d')); + record.replaceJournal.referenceNames = { + record.replaceJournal.originalRemoteName}; + return record; + }; - TryxRuntimeDisplayState duplicate = initial; - duplicate.brightness = 99; - manager->handleRemoteDisplayStateUpdated(duplicate); - QCOMPARE(displaySpy.count(), 1); - QCOMPARE(manager->displayState().brightness, 41); + const QString completedId = + QStringLiteral("66666666-6666-4666-8666-666666666666"); + manager->operations_.insert( + completedId, + makeRecord( + completedId, + QStringLiteral( + "77777777-7777-4777-8777-777777777777"))); + manager->activeOperationId_ = completedId; + QString error; + QVERIFY2(manager->writeReplaceJournal( + completedId, QStringLiteral("Preflight"), + &error), + qPrintable(error)); + auto &completed = manager->operations_[completedId]; + completed.replaceJournal.newRemoteName = + QStringLiteral("new.mp4.h264_2240x1080"); + completed.replaceJournal.newSize = 2048; + completed.replaceJournal.uploadVerified = true; + completed.replaceJournal.disposition = + QStringLiteral("NewCopyReady"); + QVERIFY2(manager->writeReplaceJournal( + completedId, + QStringLiteral("UploadVerified"), + &error), + qPrintable(error)); + manager->finishOperation( + completedId, QStringLiteral("Succeeded"), + QStringLiteral("OriginalRetained"), QString(), + QStringLiteral("new copy ready")); + QCOMPARE(manager->operationInfo(completedId).state, + QStringLiteral("Succeeded")); + QVERIFY(manager->pendingReplaceJournalOperationId_.isEmpty()); + QVERIFY(!QFileInfo::exists(manager->replaceIntentPath())); - TryxRuntimeDisplayState newer = initial; - newer.revision = 1; - newer.brightness = 73; - manager->handleRemoteDisplayStateUpdated(newer); - QCOMPARE(displaySpy.count(), 2); - QCOMPARE(manager->displayState().brightness, 73); - manager->remoteMode_ = false; + const QString unknownId = + QStringLiteral("88888888-8888-4888-8888-888888888888"); + manager->operations_.insert( + unknownId, + makeRecord( + unknownId, + QStringLiteral( + "99999999-9999-4999-8999-999999999999"))); + manager->activeOperationId_ = unknownId; + QVERIFY2(manager->writeReplaceJournal( + unknownId, QStringLiteral("Preflight"), + &error), + qPrintable(error)); + auto &unknown = manager->operations_[unknownId]; + unknown.replaceJournal.newRemoteName = + QStringLiteral("newer.mp4.h264_2240x1080"); + unknown.replaceJournal.newSize = 4096; + unknown.replaceJournal.uploadVerified = true; + unknown.replaceJournal.disposition = + QStringLiteral("NewCopyReady"); + QVERIFY2(manager->writeReplaceJournal( + unknownId, + QStringLiteral("UploadVerified"), + &error), + qPrintable(error)); + unknown.replaceJournal.applyMayHaveStarted = true; + QVERIFY2(manager->writeReplaceJournal( + unknownId, QStringLiteral("Applying"), + &error), + qPrintable(error)); + manager->finishOperation( + unknownId, QStringLiteral("RetryAvailable"), + QStringLiteral("PartialOrUnknown"), + QStringLiteral("ReconcileOnly"), + QStringLiteral("apply outcome unknown")); + + TryxReplaceJournal journal(manager->replaceIntentPath()); + const TryxReplaceJournalLoadResult loaded = journal.load(); + QCOMPARE(loaded.status, + TryxReplaceJournalLoadStatus::Loaded); + QCOMPARE(loaded.record.stage, + QStringLiteral("ApplyVerification")); + QCOMPARE(loaded.record.disposition, + QStringLiteral("PartialOrUnknown")); + QVERIFY2(manager->clearReplaceJournal(&error), + qPrintable(error)); + + const QString uploadOnlyId = + QStringLiteral("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + manager->operations_.insert( + uploadOnlyId, + makeRecord( + uploadOnlyId, + QStringLiteral( + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"))); + manager->activeOperationId_ = uploadOnlyId; + QVERIFY2(manager->writeReplaceJournal( + uploadOnlyId, QStringLiteral("Preflight"), + &error), + qPrintable(error)); + manager->finishOperation( + uploadOnlyId, QStringLiteral("RetryAvailable"), + QStringLiteral("PartialOrUnknown"), + QStringLiteral("ReconcileOnly"), + QStringLiteral("upload outcome unknown")); + QCOMPARE(manager->operationInfo(uploadOnlyId).state, + QStringLiteral("RetryAvailable")); + QVERIFY(manager->pendingReplaceJournalOperationId_.isEmpty()); + QVERIFY(!QFileInfo::exists(manager->replaceIntentPath())); } void PrinterProtocolTests:: -panoramaPageRestoresDisplayAndSplitState() { + recoveredOperationIdempotencySurvivesActiveHold() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = @@ -1110,130 +1304,181 @@ panoramaPageRestoresDisplayAndSplitState() { const QString devRoot = QDir(temporaryDirectory.path()).filePath( QStringLiteral("dev")); - QDir().mkpath(sysRoot); - QDir().mkpath(devRoot); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); std::unique_ptr manager( DeviceManager::createForTesting(sysRoot, devRoot)); - PanoramaPage page(manager.get()); - - auto *brightness = page.findChild( - QStringLiteral("displayBrightnessSlider")); - auto *displayOff = page.findChild( - QStringLiteral("displayOffCheckBox")); - auto *mirror = page.findChild( - QStringLiteral("displayMirrorCheckBox")); - auto *waterfall = page.findChild( - QStringLiteral("displayWaterfallCheckBox")); - auto *fullScreen = page.findChild( - QStringLiteral("fullScreenRadioButton")); - auto *splitScreen = page.findChild( - QStringLiteral("splitScreenRadioButton")); - auto *cpuBadge = page.findChild( - QStringLiteral("customCpuBadgeCheckBox")); - auto *gpuBadge = page.findChild( - QStringLiteral("customGpuBadgeCheckBox")); - auto *colorButton = page.findChild( - QStringLiteral("customTextColorButton")); - auto *splitConfig = - page.findChild(); - QVERIFY(brightness); - QVERIFY(displayOff); - QVERIFY(mirror); - QVERIFY(waterfall); - QVERIFY(fullScreen); - QVERIFY(splitScreen); - QVERIFY(cpuBadge); - QVERIFY(gpuBadge); - QVERIFY(colorButton); - QVERIFY(splitConfig); - - TryxRuntimeDisplayState state; - state.revision = 7; - state.deviceSerial = QStringLiteral("PASE-UI"); - state.valid = true; - state.backlightEnabled = false; - state.brightness = 64; - state.standbyEnabled = false; - state.standbyMedia = - QStringLiteral("screensaver.h264"); - state.mirrorMode = true; - state.waterfallMode = true; - state.screenMode = - QStringLiteral("Screen Splitting"); - state.playMode = QStringLiteral("Single"); - state.media = { - QStringLiteral("left.h264"), - QStringLiteral("right.h264")}; - state.sysinfoLabels = { - QStringLiteral("CPU Temperature"), - QStringLiteral("CPU Power")}; - state.settingsBadges = { - QStringLiteral("CPU Badge")}; - state.settingsPosition = QStringLiteral("Bottom"); - state.settingsColor = QStringLiteral("#ff0000"); - state.settingsAlign = QStringLiteral("Right"); - state.sysinfoLabels2 = { - QStringLiteral("GPU Temperature"), - QStringLiteral("GPU Power")}; - state.settingsBadges2 = { - QStringLiteral("GPU Badge")}; - state.settingsPosition2 = QStringLiteral("Top"); - state.settingsColor2 = QStringLiteral("#00ff00"); - state.settingsAlign2 = QStringLiteral("Left"); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + manager->printerDisplaySessionActive_ = true; + QVERIFY(manager->ensureDeviceMediaOutbox()); + + PrinterProtocol::MediaFile original; + original.name = + QStringLiteral("original.mp4.h264_2240x1080"); + original.size = 4096; + original.source = PrinterProtocol::MediaSource::User; + original.readOnly = false; + manager->updateMediaCatalog({original}); + QCOMPARE(manager->mediaCatalog_.entries.size(), 1); + const TryxRuntimeMediaEntry originalEntry = + manager->mediaCatalog_.entries.constFirst(); + + const QString artifactId = + QStringLiteral("12121212-1212-4212-8212-121212121212"); + const QString stageOperationId = + QStringLiteral("13131313-1313-4313-8313-131313131313"); + const QString owner = QStringLiteral(":1.5151"); + const QString leaseId = + QStringLiteral("14141414-1414-4414-8414-141414141414"); + const QByteArray payload(4096, 'r'); + const QString artifactPath = + QDir(manager->deviceMediaOutboxDirectory()) + .filePath(artifactId + QStringLiteral(".h264")); + QVERIFY(writeTextFile(artifactPath, payload)); + QVERIFY(QFile::setPermissions( + artifactPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + struct stat status {}; + QVERIFY(::lstat( + QFile::encodeName(artifactPath).constData(), + &status) == 0); + + DeviceManager::DeviceMediaArtifactRecord artifact; + artifact.metadata.schemaVersion = 1; + artifact.metadata.operationId = stageOperationId; + artifact.metadata.artifactId = artifactId; + artifact.metadata.mediaId = originalEntry.mediaId; + artifact.metadata.deviceIdentity = + manager->printerDeviceSerial_.trimmed(); + artifact.metadata.remoteName = original.name; + artifact.metadata.size = + static_cast(payload.size()); + artifact.metadata.decodedSha256 = + QString::fromLatin1( + QCryptographicHash::hash( + payload, QCryptographicHash::Sha256) + .toHex()); + artifact.metadata.logicalType = + QStringLiteral("Video"); + artifact.metadata.localPath = + QFileInfo(artifactPath).canonicalFilePath(); + artifact.metadata.leaseId = leaseId; + artifact.ownerUniqueName = owner; + artifact.canonicalPath = + artifact.metadata.localPath; + artifact.leaseId = leaseId; + artifact.claimed = true; + artifact.expiresUtcMs = + QDateTime::currentMSecsSinceEpoch() + 60000; + artifact.metadata.leaseExpiresUtcMs = + artifact.expiresUtcMs; + artifact.deviceNumber = + static_cast(status.st_dev); + artifact.inodeNumber = + static_cast(status.st_ino); + manager->deviceMediaArtifacts_.insert( + artifactId, artifact); + + QObject::disconnect( + manager.get(), + &DeviceManager::requestPrinterReplacePreflight, + manager->worker_, + &DeviceWorker::preflightReplacePrinterMedia); + QObject::disconnect( + manager.get(), + &DeviceManager::requestPrepareRecoveredPrinterMedia, + manager->printerMediaPreparer_, + &PrinterMediaPreparer::prepareRecovered); - manager->displayStateUpdated(state); - QCoreApplication::processEvents(); + TryxRuntimeApplyRequest applyRequest; + applyRequest.media = {original.name}; + applyRequest.ratio = QStringLiteral("2:1"); + applyRequest.screenMode = + QStringLiteral("Full Screen"); + applyRequest.playMode = QStringLiteral("Single"); + const TryxRuntimeMediaTransform transform; + const QString replaceOperationId = + QStringLiteral("15151515-1515-4515-8515-151515151515"); + QCOMPARE( + manager->queueReplaceDeviceMediaOperation( + replaceOperationId, artifactId, leaseId, + originalEntry.mediaId, applyRequest, + transform, owner), + replaceOperationId); + manager->operations_[replaceOperationId] + .applyRequest.media = { + QStringLiteral("new.mp4.h264_2240x1080")}; + QCOMPARE( + manager->queueReplaceDeviceMediaOperation( + replaceOperationId, artifactId, leaseId, + originalEntry.mediaId, applyRequest, + transform, owner), + replaceOperationId); + QVERIFY(manager->queueReplaceDeviceMediaOperation( + replaceOperationId, artifactId, + QStringLiteral("wrong-lease"), + originalEntry.mediaId, applyRequest, + transform, owner) + .isEmpty()); - QCOMPARE(brightness->value(), 64); - QVERIFY(!brightness->isEnabled()); - QSignalSpy legacyBrightnessSpy( - manager.get(), &DeviceManager::requestBrightness); - QVERIFY(QMetaObject::invokeMethod( - &page, "onBrightnessChanged", - Qt::DirectConnection, Q_ARG(int, 88))); - QCoreApplication::processEvents(); - QCOMPARE(legacyBrightnessSpy.count(), 0); - QVERIFY(displayOff->isChecked()); - QVERIFY(mirror->isChecked()); - QVERIFY(waterfall->isChecked()); - QVERIFY(!fullScreen->isChecked()); - QVERIFY(splitScreen->isChecked()); - QVERIFY(cpuBadge->isChecked()); - QVERIFY(!gpuBadge->isChecked()); - QVERIFY(colorButton->styleSheet().contains( - QStringLiteral("#ff0000"), - Qt::CaseInsensitive)); - QCOMPARE(splitConfig->leftMedia(), - QStringList{QStringLiteral("left.h264")}); - QCOMPARE(splitConfig->rightMedia(), - QStringList{QStringLiteral("right.h264")}); - QCOMPARE(splitConfig->leftMetrics(), - state.sysinfoLabels); - QCOMPARE(splitConfig->rightMetrics(), - state.sysinfoLabels2); - QCOMPARE(splitConfig->leftBadges(), - state.settingsBadges); - QCOMPARE(splitConfig->rightBadges(), - state.settingsBadges2); - QCOMPARE(splitConfig->leftPosition(), - QStringLiteral("Bottom")); - QCOMPARE(splitConfig->rightPosition(), - QStringLiteral("Top")); - QCOMPARE(splitConfig->leftColor(), - QStringLiteral("#ff0000")); - QCOMPARE(splitConfig->rightColor(), - QStringLiteral("#00ff00")); - QCOMPARE(splitConfig->leftAlignment(), - QStringLiteral("Right")); - QCOMPARE(splitConfig->rightAlignment(), - QStringLiteral("Left")); - QCOMPARE(splitConfig->playMode(), - QStringLiteral("Single")); + manager->finishOperation( + replaceOperationId, QStringLiteral("Cancelled"), + QStringLiteral("TestBoundary"), QString(), + QStringLiteral("test cleanup")); + QVERIFY(manager->activeOperationId_.isEmpty()); + QVERIFY(manager->deviceMediaArtifacts_ + .value(artifactId) + .inUseOperationId.isEmpty()); + + const QString recoveredOperationId = + QStringLiteral("16161616-1616-4616-8616-161616161616"); + QCOMPARE( + manager->queueRecoveredMediaUploadOperation( + recoveredOperationId, artifactId, leaseId, + owner, transform), + recoveredOperationId); + QCOMPARE( + manager->queueRecoveredMediaUploadOperation( + recoveredOperationId, artifactId, leaseId, + owner, transform), + recoveredOperationId); + manager->finishOperation( + recoveredOperationId, + QStringLiteral("Cancelled"), + QStringLiteral("TestBoundary"), QString(), + QStringLiteral("test cleanup")); + manager->printerDisplaySessionActive_ = false; + const QString rejectedOperationId = + QStringLiteral( + "26262626-2626-4626-8626-262626262626"); + QCOMPARE( + manager->queueRecoveredMediaUploadOperation( + rejectedOperationId, artifactId, leaseId, + owner, transform), + rejectedOperationId); + QCOMPARE( + manager->operationInfo( + rejectedOperationId).state, + QStringLiteral("Failed")); + QCOMPARE( + manager->queueRecoveredMediaUploadOperation( + rejectedOperationId, artifactId, leaseId, + owner, transform), + rejectedOperationId); + QVERIFY( + manager->queueRecoveredMediaUploadOperation( + QStringLiteral("not-a-uuid"), + artifactId, leaseId, owner, transform) + .isEmpty()); } void PrinterProtocolTests:: -panoramaPageUsesUnifiedMediaLibrary() { + staleReplacePreflightCannotAdvanceSaga() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = @@ -1242,77 +1487,76 @@ panoramaPageUsesUnifiedMediaLibrary() { const QString devRoot = QDir(temporaryDirectory.path()).filePath( QStringLiteral("dev")); - QDir().mkpath(sysRoot); - QDir().mkpath(devRoot); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); + std::unique_ptr manager( DeviceManager::createForTesting(sysRoot, devRoot)); - PanoramaPage page(manager.get()); - QVERIFY(page.findChildren().isEmpty()); - QVERIFY(!page.findChild( - QStringLiteral("presetCpuBadgeCheckBox"))); - QVERIFY(page.findChild( - QStringLiteral("customCpuBadgeCheckBox"))); - - const QString presetId = - QStringLiteral("device-preset.h264"); - PrinterProtocol::MediaFile presetEntry; - presetEntry.name = presetId; - presetEntry.source = PrinterProtocol::MediaSource::Preset; - presetEntry.readOnly = true; - PrinterProtocol::MediaFile userEntry = presetEntry; - userEntry.name = QStringLiteral("user-upload.h264"); - userEntry.source = PrinterProtocol::MediaSource::User; - userEntry.readOnly = false; - manager->updateMediaCatalog({presetEntry, userEntry}); - - const TryxRuntimeMediaCatalogSnapshot catalog = - manager->mediaCatalogSnapshot(); - QCOMPARE(catalog.entries.size(), 2); - const auto presetCatalogEntry = std::find_if( - catalog.entries.cbegin(), catalog.entries.cend(), - [&presetEntry](const TryxRuntimeMediaEntry &entry) { - return entry.name == presetEntry.name; - }); - QVERIFY(presetCatalogEntry != catalog.entries.cend()); - const auto userCatalogEntry = std::find_if( - catalog.entries.cbegin(), catalog.entries.cend(), - [&userEntry](const TryxRuntimeMediaEntry &entry) { - return entry.name == userEntry.name; - }); - QVERIFY(userCatalogEntry != catalog.entries.cend()); - QCOMPARE(page.fileList_->count(), 2); - QListWidgetItem *presetItem = page.fileList_->item(0); - QVERIFY(presetItem); - QCOMPARE( - presetItem->data(Qt::UserRole).toString(), - presetEntry.name); - QCOMPARE( - presetItem->data(Qt::UserRole + 2).toUInt(), - presetCatalogEntry->source); - QVERIFY(presetItem->data(Qt::UserRole + 3).toBool()); - QVERIFY(!presetItem->data(Qt::UserRole + 6).toBool()); - QCOMPARE( - presetItem->data(Qt::UserRole + 7).toString(), - QStringLiteral("Preset")); - presetItem->setSelected(true); - QCOMPARE( - page.selectedDeviceMediaNames(), - QStringList{presetEntry.name}); - presetItem->setSelected(false); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + manager->printerDisplaySessionActive_ = true; + QObject::disconnect( + manager.get(), + &DeviceManager::requestPrepareRecoveredPrinterMedia, + manager->printerMediaPreparer_, + &PrinterMediaPreparer::prepareRecovered); + QSignalSpy prepareSpy( + manager.get(), + &DeviceManager::requestPrepareRecoveredPrinterMedia); + + const QString operationId = + QStringLiteral("17171717-1717-4717-8717-171717171717"); + const QString original = + QStringLiteral("stale.mp4.h264_2240x1080"); + const quint64 staleGeneration = + manager->printerGeneration_; + DeviceManager::OperationRecord record; + record.info.id = operationId; + record.info.kind = + QStringLiteral("ReplaceDeviceMedia"); + record.info.state = QStringLiteral("Preflight"); + record.info.stage = + QStringLiteral("ReadingReferences"); + record.info.deviceGeneration = staleGeneration; + record.replaceOperation = true; + record.originalRemoteNameForReplace = original; + record.uploadDeviceIdentity = + manager->printerDeviceSerial_.trimmed(); + record.uploadDeviceGeneration = staleGeneration; + manager->operations_.insert(operationId, record); + manager->operationOrder_.append(operationId); + manager->activeOperationId_ = operationId; + manager->operations_[operationId] + .deviceChangePending = true; + manager->operations_[operationId] + .deviceChangeMessage = + QStringLiteral("simulated generation change"); + ++manager->printerGeneration_; + + emit manager->worker_-> + printerReplacePreflightFinished( + operationId, original, + QString(), 0, + QStringList(9, QString()), + QStringList{QStringLiteral("Single")}, + true, false, true, + QString(), staleGeneration); - QListWidgetItem *userItem = page.fileList_->item(1); - QVERIFY(userItem); + QCOMPARE(prepareSpy.count(), 0); QCOMPARE( - userItem->data(Qt::UserRole).toString(), - userEntry.name); - constexpr int mediaSourceRole = Qt::UserRole + 2; + manager->operationInfo(operationId).state, + QStringLiteral("Failed")); QCOMPARE( - userItem->data(mediaSourceRole).toUInt(), - userCatalogEntry->source); + manager->operationInfo(operationId).errorCategory, + QStringLiteral("DeviceChanged")); + QVERIFY(manager->activeOperationId_.isEmpty()); } void PrinterProtocolTests:: -panoramaBrightnessCoalescesUntilTransportReady() { + replaceDeleteCrashWindowsUseReadOnlyReconciliation() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = @@ -1329,1122 +1573,1858 @@ panoramaBrightnessCoalescesUntilTransportReady() { std::unique_ptr manager( DeviceManager::createForTesting(sysRoot, devRoot)); - QObject::disconnect( - manager.get(), &DeviceManager::requestPrinterApplyMedia, - manager->worker_, &DeviceWorker::applyPrinterMedia); - QObject::disconnect( - manager.get(), &DeviceManager::requestStartPrinterSession, - manager->worker_, - &DeviceWorker::startPrinterDisplaySession); manager->setAutoConnectModeForTesting(true); manager->rescanPrinterForTesting(); - QVERIFY(manager->isPrinterClassConnected()); manager->printerDisplaySessionActive_ = true; - manager->printerDisplaySessionLost_ = false; - manager->displayState_.revision = 10; - manager->displayState_.deviceSerial = - QStringLiteral("PASE-BRIGHTNESS"); - manager->displayState_.valid = true; - manager->displayState_.backlightEnabled = true; - manager->displayState_.brightness = 50; - manager->displayState_.standbyEnabled = true; - manager->displayState_.standbyMedia = - QStringLiteral("standby.h264"); - manager->displayState_.screenMode = - QStringLiteral("Full Screen"); - manager->displayState_.playMode = - QStringLiteral("Single"); - - PanoramaPage page(manager.get()); - auto *slider = page.findChild( - QStringLiteral("displayBrightnessSlider")); - QVERIFY(slider); - QSignalSpy applySpy( + QObject::disconnect( manager.get(), - &DeviceManager::requestPrinterApplyMedia); - QSignalSpy transportReadySpy( + &DeviceManager::requestPrinterDeleteMedia, + manager->worker_, + &DeviceWorker::deletePrinterMedia); + QSignalSpy deleteSpy( manager.get(), - &DeviceManager::printerTransportReady); - - QVERIFY(!page.displayMutationReady_); - emit manager->worker_->printerTransportReady( - manager->printerGeneration_); - QCoreApplication::processEvents(); - QCOMPARE(transportReadySpy.count(), 1); - QVERIFY(page.displayMutationReady_); - - QVERIFY(QMetaObject::invokeMethod( - &page, "onBrightnessChanged", - Qt::DirectConnection, Q_ARG(int, 60))); - QCoreApplication::processEvents(); - QCOMPARE(applySpy.count(), 1); - const QString firstOperation = - page.brightnessOperationId_; - QVERIFY(!firstOperation.isEmpty()); - const TryxRuntimeApplyRequest firstRequest = - qvariant_cast( - applySpy.at(0).at(2)); - QCOMPARE(firstRequest.display.brightness, 60); + &DeviceManager::requestPrinterDeleteMedia); + + const auto makeDeletingRecord = + [manager = manager.get()]( + const QString &operationId, + const QString &artifactId, + const QString &original, + const QString &replacement) { + DeviceManager::OperationRecord record; + record.info.id = operationId; + record.info.kind = + QStringLiteral("ReplaceDeviceMedia"); + record.info.state = + QStringLiteral("RetryAvailable"); + record.info.stage = + QStringLiteral("ReconcileOnly"); + record.info.deviceGeneration = + manager->printerGeneration_; + record.info.retryMode = + QStringLiteral("ReconcileOnly"); + record.info.terminalOutcome = + QStringLiteral("PartialOrUnknown"); + record.originalMediaId = + QString(64, QLatin1Char('a')); + record.originalRemoteNameForReplace = + original; + record.remoteName = replacement; + record.uploadDeviceIdentity = + manager->printerDeviceSerial_.trimmed(); + record.uploadDeviceGeneration = + manager->printerGeneration_; + record.replaceOperation = true; + record.replaceJournalActive = true; + record.replaceJournal.operationId = + operationId; + record.replaceJournal.deviceIdentity = + record.uploadDeviceIdentity; + record.replaceJournal.deviceGeneration = + record.uploadDeviceGeneration; + record.replaceJournal.originalMediaId = + record.originalMediaId; + record.replaceJournal.originalRemoteName = + original; + record.replaceJournal.originalSize = 4096; + record.replaceJournal.artifactId = + artifactId; + record.replaceJournal.decodedSha256 = + QString(64, QLatin1Char('b')); + record.replaceJournal.transformFingerprint = + QString(64, QLatin1Char('c')); + record.replaceJournal.applyFingerprint = + QString(64, QLatin1Char('d')); + record.replaceJournal.referenceNames = { + original}; + record.replaceJournal.newRemoteName = + replacement; + record.replaceJournal.newSize = 4096; + record.replaceJournal.uploadVerified = true; + record.replaceJournal.applyMayHaveStarted = + true; + record.replaceJournal.applyVerified = true; + record.replaceJournal.deleteIntentLinked = + true; + record.replaceJournal.fileRemoveMayHaveStarted = + true; + record.replaceJournal.stage = + QStringLiteral("Deleting"); + record.replaceJournal.disposition = + QStringLiteral("PartialOrUnknown"); + return record; + }; - QVERIFY(QMetaObject::invokeMethod( - &page, "onBrightnessChanged", - Qt::DirectConnection, Q_ARG(int, 70))); - QVERIFY(QMetaObject::invokeMethod( - &page, "onBrightnessChanged", - Qt::DirectConnection, Q_ARG(int, 80))); - QCoreApplication::processEvents(); - QCOMPARE(applySpy.count(), 1); - QCOMPARE(page.pendingBrightness_, 80); - QCOMPARE(slider->value(), 80); - emit manager->brightnessChanged(60); - QCoreApplication::processEvents(); - QCOMPARE(slider->value(), 80); + const auto installJournal = + [manager = manager.get()]( + const DeviceManager::OperationRecord &record) { + TryxReplaceJournal journal( + manager->replaceIntentPath()); + QString error; + QVERIFY2(journal.write( + record.replaceJournal, &error), + qPrintable(error)); + manager->operations_.insert( + record.info.id, record); + manager->operationOrder_.append( + record.info.id); + manager->pendingReplaceJournalOperationId_ = + record.info.id; + }; - emit manager->worker_->printerTransportReady( - manager->printerGeneration_); - QCoreApplication::processEvents(); - QCOMPARE(transportReadySpy.count(), 1); - QVERIFY(!page.displayMutationReady_); - emit manager->printerTransportReady(); - QCoreApplication::processEvents(); - QVERIFY(!page.displayMutationReady_); - - manager->displayState_.revision = 11; - manager->displayState_.brightness = 60; - emit manager->displayStateUpdated( - manager->displayState_); - manager->worker_->printerApplyFinished( - firstOperation, QString(), true, false, + const QString deletedOperationId = + QStringLiteral("18181818-1818-4818-8818-181818181818"); + const QString deletedOriginal = + QStringLiteral("deleted.mp4.h264_2240x1080"); + const QString deletedReplacement = + QStringLiteral("new-deleted.mp4.h264_2240x1080"); + installJournal(makeDeletingRecord( + deletedOperationId, + QStringLiteral("19191919-1919-4919-8919-191919191919"), + deletedOriginal, deletedReplacement)); + manager->resumePendingReplaceReconciliation(); + QCOMPARE(deleteSpy.count(), 1); + QCOMPARE( + deleteSpy.constLast().at(1).toStringList(), + QStringList{deletedOriginal}); + QCOMPARE(deleteSpy.constLast().at(4).toBool(), true); + QCOMPARE(deleteSpy.constLast().at(5).toLongLong(), + qint64(4096)); + PrinterProtocol::MediaFile deletedReplacementMedia; + deletedReplacementMedia.name = deletedReplacement; + deletedReplacementMedia.size = 4096; + deletedReplacementMedia.source = + PrinterProtocol::MediaSource::User; + deletedReplacementMedia.readOnly = false; + emit manager->worker_->printerDeleteFinished( + deletedOperationId, + QStringList{deletedOriginal}, + QStringList{deletedOriginal}, + QList{ + deletedReplacementMedia}, + true, PrinterProtocol::MutationOutcome::Succeeded, QString(), manager->printerGeneration_); - QCoreApplication::processEvents(); - QCOMPARE(applySpy.count(), 1); - QVERIFY(page.brightnessOperationId_.isEmpty()); - QCOMPARE(page.pendingBrightness_, 80); + QCOMPARE( + manager->operationInfo( + deletedOperationId).terminalOutcome, + QStringLiteral("Replaced")); + QVERIFY(!QFileInfo::exists( + manager->replaceIntentPath())); + QVERIFY(manager->pendingReplaceJournalOperationId_.isEmpty()); - emit manager->worker_->printerTransportReady( + const QString retainedOperationId = + QStringLiteral("20202020-2020-4020-8020-202020202020"); + const QString retainedOriginal = + QStringLiteral("retained.mp4.h264_2240x1080"); + const QString retainedReplacement = + QStringLiteral("new-retained.mp4.h264_2240x1080"); + installJournal(makeDeletingRecord( + retainedOperationId, + QStringLiteral("21212121-2121-4121-8121-212121212121"), + retainedOriginal, retainedReplacement)); + manager->resumePendingReplaceReconciliation(); + QCOMPARE(deleteSpy.count(), 2); + QCOMPARE(deleteSpy.constLast().at(4).toBool(), true); + QCOMPARE(deleteSpy.constLast().at(5).toLongLong(), + qint64(4096)); + PrinterProtocol::MediaFile retainedMedia; + retainedMedia.name = retainedOriginal; + retainedMedia.size = 4096; + retainedMedia.source = + PrinterProtocol::MediaSource::User; + retainedMedia.readOnly = false; + emit manager->worker_->printerDeleteFinished( + retainedOperationId, + QStringList{retainedOriginal}, {}, + QList{ + retainedMedia, + PrinterProtocol::MediaFile{ + retainedReplacement, 4096, false, + PrinterProtocol::MediaSource::User}}, + false, + PrinterProtocol::MutationOutcome::PartialOrUnknown, + QStringLiteral("still present"), manager->printerGeneration_); - QCoreApplication::processEvents(); - QCOMPARE(applySpy.count(), 2); - const QString secondOperation = - page.brightnessOperationId_; - QVERIFY(!secondOperation.isEmpty()); - const TryxRuntimeApplyRequest secondRequest = - qvariant_cast( - applySpy.at(1).at(2)); - QCOMPARE(secondRequest.display.brightness, 80); + QCOMPARE( + manager->operationInfo( + retainedOperationId).state, + QStringLiteral("Succeeded")); + QCOMPARE( + manager->operationInfo( + retainedOperationId).terminalOutcome, + QStringLiteral("NewCopyReady")); + QCOMPARE( + manager->operationInfo( + retainedOperationId).errorCategory, + QStringLiteral("OriginalRetained")); + QVERIFY(!QFileInfo::exists( + manager->replaceIntentPath())); + QVERIFY(manager->pendingReplaceJournalOperationId_.isEmpty()); - manager->displayState_.revision = 12; - manager->displayState_.brightness = 80; - emit manager->displayStateUpdated( - manager->displayState_); - manager->worker_->printerApplyFinished( - secondOperation, QString(), true, false, + const QString missingOperationId = + QStringLiteral("24242424-2424-4424-8424-242424242424"); + const QString missingOriginal = + QStringLiteral("missing-old.mp4.h264_2240x1080"); + const QString missingReplacement = + QStringLiteral("missing-new.mp4.h264_2240x1080"); + installJournal(makeDeletingRecord( + missingOperationId, + QStringLiteral("25252525-2525-4525-8525-252525252525"), + missingOriginal, missingReplacement)); + manager->resumePendingReplaceReconciliation(); + QCOMPARE(deleteSpy.count(), 3); + QCOMPARE(deleteSpy.constLast().at(4).toBool(), true); + emit manager->worker_->printerDeleteFinished( + missingOperationId, + QStringList{missingOriginal}, + QStringList{missingOriginal}, {}, true, PrinterProtocol::MutationOutcome::Succeeded, QString(), manager->printerGeneration_); - QCoreApplication::processEvents(); - QVERIFY(page.brightnessOperationId_.isEmpty()); - QVERIFY(!page.displayMutationReady_); + QCOMPARE( + manager->operationInfo(missingOperationId).state, + QStringLiteral("RetryAvailable")); + QCOMPARE( + manager->operationInfo(missingOperationId).retryMode, + QStringLiteral("ReconcileOnly")); + QCOMPARE( + manager->operationInfo( + missingOperationId).terminalOutcome, + QStringLiteral("PartialOrUnknown")); + QVERIFY(QFileInfo::exists( + manager->replaceIntentPath())); + QCOMPARE( + manager->pendingReplaceJournalOperationId_, + missingOperationId); + + manager->operations_[missingOperationId] + .deviceChangePending = true; + manager->operations_[missingOperationId] + .deviceChangeMessage = + QStringLiteral("simulated reconnect"); + manager->resumePendingReplaceReconciliation(); + QCOMPARE(deleteSpy.count(), 4); + QCOMPARE(deleteSpy.constLast().at(4).toBool(), true); + QVERIFY(!manager->operations_[missingOperationId] + .deviceChangePending); + QVERIFY(manager->operations_[missingOperationId] + .deviceChangeMessage.isEmpty()); +} - manager->printerDisplaySessionActive_ = false; - emit manager->printerDisplaySessionChanged(false); - emit manager->worker_->printerTransportReady( - manager->printerGeneration_); - emit manager->printerTransportReady(); - QCoreApplication::processEvents(); - QVERIFY(!page.displayMutationReady_); - manager->printerDisplaySessionActive_ = true; - emit manager->printerDisplaySessionChanged(true); +void PrinterProtocolTests:: + unknownApplyRecoveryKeepsOutcomeTruthful() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); - QVERIFY(QMetaObject::invokeMethod( - &page, "onBrightnessChanged", - Qt::DirectConnection, Q_ARG(int, 90))); - QCoreApplication::processEvents(); - QCOMPARE(applySpy.count(), 2); - QCOMPARE(page.pendingBrightness_, 90); - emit manager->worker_->printerTransportReady( - manager->printerGeneration_); - QCoreApplication::processEvents(); - QCOMPARE(applySpy.count(), 3); - const QString failedOperation = - page.brightnessOperationId_; - QVERIFY(!failedOperation.isEmpty()); - QVERIFY(QMetaObject::invokeMethod( - &page, "onBrightnessChanged", - Qt::DirectConnection, Q_ARG(int, 95))); - QCoreApplication::processEvents(); - QCOMPARE(page.pendingBrightness_, 95); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + manager->printerDisplaySessionActive_ = true; + QObject::disconnect( + manager.get(), + &DeviceManager::requestPrinterReplacePreflight, + manager->worker_, + &DeviceWorker::preflightReplacePrinterMedia); + QSignalSpy displaySpy( + manager.get(), + &DeviceManager::requestPrinterDisplayState); + QSignalSpy preflightSpy( + manager.get(), + &DeviceManager::requestPrinterReplacePreflight); - manager->worker_->printerApplyFinished( - failedOperation, QString(), false, false, - PrinterProtocol::MutationOutcome::PartialOrUnknown, - QStringLiteral("unknown"), manager->printerGeneration_); - QCoreApplication::processEvents(); - emit manager->worker_->printerTransportReady( - manager->printerGeneration_); - QCoreApplication::processEvents(); - QCOMPARE(applySpy.count(), 3); - QCOMPARE(page.pendingBrightness_, -1); - QVERIFY(page.brightnessOperationId_.isEmpty()); - QCOMPARE(slider->value(), 80); -} + const QString operationId = + QStringLiteral("22222222-2222-4222-8222-222222222222"); + const QString original = + QStringLiteral("apply-old.mp4.h264_2240x1080"); + DeviceManager::OperationRecord record; + record.info.id = operationId; + record.info.kind = + QStringLiteral("ReplaceDeviceMedia"); + record.info.state = + QStringLiteral("RetryAvailable"); + record.info.stage = + QStringLiteral("ReconcileOnly"); + record.info.deviceGeneration = + manager->printerGeneration_; + record.originalMediaId = + QString(64, QLatin1Char('a')); + record.originalRemoteNameForReplace = original; + record.remoteName = + QStringLiteral("apply-new.mp4.h264_2240x1080"); + record.uploadDeviceIdentity = + manager->printerDeviceSerial_.trimmed(); + record.uploadDeviceGeneration = + manager->printerGeneration_; + record.replaceOperation = true; + record.replaceJournalActive = true; + record.replaceJournal.operationId = operationId; + record.replaceJournal.deviceIdentity = + record.uploadDeviceIdentity; + record.replaceJournal.deviceGeneration = + record.uploadDeviceGeneration; + record.replaceJournal.originalMediaId = + record.originalMediaId; + record.replaceJournal.originalRemoteName = + original; + record.replaceJournal.originalSize = 4096; + record.replaceJournal.artifactId = + QStringLiteral("23232323-2323-4323-8323-232323232323"); + record.replaceJournal.decodedSha256 = + QString(64, QLatin1Char('b')); + record.replaceJournal.transformFingerprint = + QString(64, QLatin1Char('c')); + record.replaceJournal.applyFingerprint = + QString(64, QLatin1Char('d')); + record.replaceJournal.referenceNames = {original}; + record.replaceJournal.newRemoteName = + record.remoteName; + record.replaceJournal.newSize = 4096; + record.replaceJournal.uploadVerified = true; + record.replaceJournal.applyMayHaveStarted = true; + record.replaceJournal.stage = + QStringLiteral("ApplyVerification"); + record.replaceJournal.disposition = + QStringLiteral("PartialOrUnknown"); -void PrinterProtocolTests::paseRunConfigUsesWireLayout() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + TryxReplaceJournal journal( + manager->replaceIntentPath()); + QString error; + QVERIFY2(journal.write( + record.replaceJournal, &error), + qPrintable(error)); + manager->operations_.insert(operationId, record); + manager->operationOrder_.append(operationId); + manager->pendingReplaceJournalOperationId_ = + operationId; - PrinterProtocol protocol; - protocol.adoptFileDescriptorForTesting( - sockets[0], QStringLiteral("/dev/usb/lp-pase-layout")); - panorama::wire::v1::Request captured; - QString peerError; - std::thread peer([&]() { - if (!readRequest(sockets[1], &captured, &peerError)) { - return; - } - auto response = baseResponse(captured); - response.mutable_acknowledgement(); - writeResponse(sockets[1], response, &peerError); - }); + manager->resumePendingReplaceReconciliation(); + QCOMPARE(displaySpy.count(), 0); + QCOMPARE(preflightSpy.count(), 1); + QCOMPARE( + preflightSpy.constFirst().at(3).toString(), + record.replaceJournal.newRemoteName); + QCOMPARE( + preflightSpy.constFirst().at(4).toLongLong(), + static_cast( + record.replaceJournal.newSize)); + QStringList references(9, QString()); + references[2] = original; + emit manager->worker_-> + printerReplacePreflightFinished( + operationId, original, + record.replaceJournal.newRemoteName, + static_cast( + record.replaceJournal.newSize), + references, + QStringList{QStringLiteral("Single")}, + true, true, true, QString(), + manager->printerGeneration_); - PrinterProtocol::PaseOverlayConfig overlay; - overlay.left.metrics = {QStringLiteral("CPU Temperature")}; - overlay.left.alignment = QStringLiteral("Left"); - overlay.left.textColor = 0xFF0000U; - QString error; - const bool sent = protocol.sendPaseRunConfigForTesting( - QStringLiteral("/dev/usb/lp-pase-layout"), overlay, &error, - PrinterProtocol::OperationContext{}); - peer.join(); - ::close(sockets[1]); + const TryxRuntimeOperationInfo info = + manager->operationInfo(operationId); + QCOMPARE(info.state, QStringLiteral("Succeeded")); + QCOMPARE( + info.terminalOutcome, + QStringLiteral("NewCopyReady")); + QCOMPARE( + info.errorCategory, + QStringLiteral("OriginalRetained")); + QVERIFY(info.message.contains( + QStringLiteral("outcome remains unknown"))); + QVERIFY(!QFileInfo::exists( + manager->replaceIntentPath())); + QVERIFY(manager->pendingReplaceJournalOperationId_.isEmpty()); - QVERIFY2(sent, qPrintable(error)); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(captured.body_case(), - panorama::wire::v1::Request::kOverlayLayout); - QCOMPARE(captured.overlay_layout().label_groups_size(), 1); - const auto &group = captured.overlay_layout().label_groups(0); - QCOMPARE(group.group_id(), 100U); - QCOMPARE(group.group_x(), 60U); - QCOMPARE(group.group_y(), 440U); - QCOMPARE(group.group_width(), 2120U); - QCOMPARE(group.group_height(), 160U); - QCOMPARE(group.text_align(), panorama::wire::v1::OverlayGroup::ALIGN_LEFT); - QCOMPARE(group.line_gap(), -10); - QCOMPARE(group.labels_size(), 3); - QCOMPARE(group.labels(0).label_id(), 101U); - QCOMPARE(group.labels(0).line(), 1U); - QCOMPARE(group.labels(0).gap_left(), 13); - QCOMPARE(group.labels(0).text_size(), 30U); - QCOMPARE(group.labels(0).text_color(), 0xFF0000U); - QCOMPARE(QString::fromStdString(group.labels(0).text()), - QStringLiteral("CPU TEMP")); - QCOMPARE(group.labels(1).label_id(), 102U); - QCOMPARE(group.labels(1).text_size(), 160U); - QCOMPARE(QString::fromStdString(group.labels(1).text()), - QStringLiteral("--")); - QCOMPARE(group.labels(2).label_id(), 103U); - QCOMPARE(group.labels(2).text_size(), 36U); - QCOMPARE(QString::fromStdString(group.labels(2).text()), - QStringLiteral("°C")); + const QString unresolvedOperationId = + QStringLiteral( + "24242424-2424-4424-8424-242424242424"); + DeviceManager::OperationRecord unresolved = record; + unresolved.info.id = unresolvedOperationId; + unresolved.info.state = + QStringLiteral("RetryAvailable"); + unresolved.info.stage = + QStringLiteral("ReconcileOnly"); + unresolved.info.errorCategory.clear(); + unresolved.info.retryMode.clear(); + unresolved.info.message.clear(); + unresolved.info.terminalOutcome.clear(); + unresolved.replaceJournal.operationId = + unresolvedOperationId; + unresolved.replaceJournal.artifactId = + QStringLiteral( + "25252525-2525-4525-8525-252525252525"); + QVERIFY2( + journal.write( + unresolved.replaceJournal, &error), + qPrintable(error)); + manager->operations_.insert( + unresolvedOperationId, unresolved); + manager->operationOrder_.append( + unresolvedOperationId); + manager->pendingReplaceJournalOperationId_ = + unresolvedOperationId; + + manager->resumePendingReplaceReconciliation(); + QCOMPARE(preflightSpy.count(), 2); + emit manager->worker_-> + printerReplacePreflightFinished( + unresolvedOperationId, original, + unresolved.replaceJournal.newRemoteName, + static_cast( + unresolved.replaceJournal.newSize), + references, + QStringList{QStringLiteral("Single")}, + true, false, false, + QStringLiteral( + "replacement missing from fresh FileList"), + manager->printerGeneration_); + + const TryxRuntimeOperationInfo unresolvedInfo = + manager->operationInfo(unresolvedOperationId); + QCOMPARE( + unresolvedInfo.state, + QStringLiteral("RetryAvailable")); + QCOMPARE( + unresolvedInfo.terminalOutcome, + QStringLiteral("PartialOrUnknown")); + QCOMPARE( + unresolvedInfo.retryMode, + QStringLiteral("ReconcileOnly")); + QVERIFY(unresolvedInfo.message.contains( + QStringLiteral("did not prove"), + Qt::CaseInsensitive)); + QVERIFY(QFileInfo::exists( + manager->replaceIntentPath())); + QCOMPARE( + manager->pendingReplaceJournalOperationId_, + unresolvedOperationId); } -void PrinterProtocolTests:: -paseWaterfallFullScreenGeometry_data() { - QTest::addColumn("placement"); - QTest::addColumn("metricY"); - QTest::addColumn("badgeY"); +void PrinterProtocolTests::runtimeLegacyMediaCatalogDbusRoundTrip() { + registerTryxRuntimeMetaTypes(); - QTest::newRow("top") - << QStringLiteral("Top") << 440U << 70U; - QTest::newRow("bottom") - << QStringLiteral("Bottom") << 1560U << 1190U; + TryxRuntimeLegacyMediaEntry entry; + entry.name = QStringLiteral("legacy.mp4.h264_2240x1080"); + entry.size = 42; + entry.source = 1; + entry.readOnly = false; + entry.thumbnailKey = QString(64, QLatin1Char('c')); + TryxRuntimeLegacyMediaCatalogSnapshot expected; + expected.revision = 8; + expected.deviceIdentity = QStringLiteral("PASE-LEGACY"); + expected.entries.append(entry); + + QDBusConnection bus = QDBusConnection::sessionBus(); + QVERIFY2(bus.isConnected(), qPrintable(bus.lastError().message())); + RuntimeRoundTripObject serviceObject; + const QString objectPath = + QStringLiteral("/org/tryx/Panorama/LegacyMediaTest/%1") + .arg(QCoreApplication::applicationPid()); + QVERIFY2(bus.registerObject(objectPath, &serviceObject, + QDBusConnection::ExportAllSlots), + qPrintable(bus.lastError().message())); + QDBusInterface interface(bus.baseService(), objectPath, + QStringLiteral("org.tryx.Panorama.Test"), bus); + const QDBusReply reply = + interface.call(QStringLiteral("EchoLegacyMediaCatalog"), + QVariant::fromValue(expected)); + bus.unregisterObject(objectPath); + QVERIFY2(reply.isValid(), qPrintable(reply.error().message())); + const auto actual = reply.value(); + QCOMPARE(actual.revision, expected.revision); + QCOMPARE(actual.deviceIdentity, expected.deviceIdentity); + QCOMPARE(actual.entries.size(), 1); + QCOMPARE(actual.entries.first().name, entry.name); + QCOMPARE(actual.entries.first().size, entry.size); + QCOMPARE(actual.entries.first().source, entry.source); + QCOMPARE(actual.entries.first().readOnly, entry.readOnly); + QCOMPARE(actual.entries.first().thumbnailKey, entry.thumbnailKey); } -void PrinterProtocolTests:: -paseWaterfallFullScreenGeometry() { - QFETCH(QString, placement); - QFETCH(quint32, metricY); - QFETCH(quint32, badgeY); +void PrinterProtocolTests::runtimeMetricsDbusRoundTrip() { + registerTryxRuntimeMetaTypes(); + QCOMPARE(tryxRuntimeApiVersion(), 8U); - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); + TryxRuntimeMetricsConfigRequest expectedRequest; + expectedRequest.enabled = true; + expectedRequest.metrics = { + QStringLiteral("CPU Power"), + QStringLiteral("GPU Temperature"), + QStringLiteral("Date&Time")}; + expectedRequest.alignment = QStringLiteral("Right"); + expectedRequest.textColor = 0x000000U; - PrinterProtocol protocol; - const QString endpoint = - QStringLiteral("/dev/usb/lp-pase-waterfall-full"); - protocol.adoptFileDescriptorForTesting( - sockets[0], endpoint); - panorama::wire::v1::Request captured; - QString peerError; - std::thread peer([&]() { - if (!readRequest( - sockets[1], &captured, &peerError)) { - return; - } - auto response = baseResponse(captured); - response.mutable_acknowledgement(); - writeResponse(sockets[1], response, &peerError); - }); + TryxRuntimeMetricsState expectedState; + expectedState.revision = 27; + expectedState.deviceSerial = QStringLiteral("PASE-METRICS-001"); + expectedState.enabled = true; + expectedState.samplingActive = true; + expectedState.metrics = expectedRequest.metrics; + expectedState.availableMetrics = { + QStringLiteral("CPU Power"), QStringLiteral("Date&Time")}; + expectedState.alignment = expectedRequest.alignment; + expectedState.textColor = expectedRequest.textColor; + expectedState.diagnostic = QStringLiteral("sensor status"); - PrinterProtocol::PaseOverlayConfig overlay; - overlay.waterfallMode = true; - overlay.left.metrics = { - QStringLiteral("CPU Temperature")}; - overlay.left.badges = { - QStringLiteral("CPU Badge")}; - overlay.left.verticalPlacement = placement; - overlay.left.alignment = QStringLiteral("Right"); - overlay.left.textColor = 0xFF0000U; - overlay.cpuBadgeText = - QStringLiteral("AMD Ryzen 9 9950X3D"); - QString error; - const bool sent = protocol.sendPaseRunConfigForTesting( - endpoint, overlay, &error, - PrinterProtocol::OperationContext{}); - peer.join(); - ::close(sockets[1]); + QDBusConnection bus = QDBusConnection::sessionBus(); + QVERIFY2(bus.isConnected(), qPrintable(bus.lastError().message())); + RuntimeRoundTripObject serviceObject; + const QString objectPath = + QStringLiteral("/org/tryx/Panorama/MetricsTest/%1") + .arg(QCoreApplication::applicationPid()); + QVERIFY2(bus.registerObject(objectPath, &serviceObject, + QDBusConnection::ExportAllSlots), + qPrintable(bus.lastError().message())); + QDBusInterface interface(bus.baseService(), objectPath, + QStringLiteral("org.tryx.Panorama.Test"), bus); - QVERIFY2(sent, qPrintable(error)); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(captured.overlay_layout().label_groups_size(), 2); - const auto &metric = - captured.overlay_layout().label_groups(0); - const auto &badge = - captured.overlay_layout().label_groups(1); - QCOMPARE(metric.group_id(), 100U); - QCOMPARE(metric.group_x(), 60U); - QCOMPARE(metric.group_y(), metricY); - QCOMPARE(metric.group_width(), 950U); - QCOMPARE(metric.group_height(), 160U); - QCOMPARE(metric.text_align(), - panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); - QCOMPARE(metric.labels(0).text_color(), - 0xFF0000U); - QCOMPARE(badge.group_id(), 300U); - QCOMPARE(badge.group_x(), 70U); - QCOMPARE(badge.group_y(), badgeY); - QCOMPARE(badge.group_width(), 970U); - QCOMPARE(badge.text_align(), - panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); + const QDBusReply requestReply = + interface.call(QStringLiteral("EchoMetricsRequest"), + QVariant::fromValue(expectedRequest)); + QVERIFY2(requestReply.isValid(), + qPrintable(requestReply.error().message())); + const TryxRuntimeMetricsConfigRequest actualRequest = + requestReply.value(); + QCOMPARE(actualRequest.enabled, expectedRequest.enabled); + QCOMPARE(actualRequest.metrics, expectedRequest.metrics); + QCOMPARE(actualRequest.alignment, expectedRequest.alignment); + QCOMPARE(actualRequest.textColor, expectedRequest.textColor); + + const QDBusReply stateReply = + interface.call(QStringLiteral("EchoMetricsState"), + QVariant::fromValue(expectedState)); + bus.unregisterObject(objectPath); + QVERIFY2(stateReply.isValid(), qPrintable(stateReply.error().message())); + const TryxRuntimeMetricsState actualState = stateReply.value(); + QCOMPARE(actualState.revision, expectedState.revision); + QCOMPARE(actualState.deviceSerial, expectedState.deviceSerial); + QCOMPARE(actualState.enabled, expectedState.enabled); + QCOMPARE(actualState.samplingActive, expectedState.samplingActive); + QCOMPARE(actualState.metrics, expectedState.metrics); + QCOMPARE(actualState.availableMetrics, expectedState.availableMetrics); + QCOMPARE(actualState.alignment, expectedState.alignment); + QCOMPARE(actualState.textColor, expectedState.textColor); + QCOMPARE(actualState.diagnostic, expectedState.diagnostic); } -void PrinterProtocolTests:: -paseWaterfallSplitGeometryAndIndependentStyles() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); +void PrinterProtocolTests::runtimeMediaTransformDbusRoundTrip() { + registerTryxRuntimeMetaTypes(); - PrinterProtocol protocol; - const QString endpoint = - QStringLiteral("/dev/usb/lp-pase-waterfall-split"); - protocol.adoptFileDescriptorForTesting( - sockets[0], endpoint); - panorama::wire::v1::Request captured; - QString peerError; - std::thread peer([&]() { - if (!readRequest( - sockets[1], &captured, &peerError)) { - return; - } - auto response = baseResponse(captured); - response.mutable_acknowledgement(); - writeResponse(sockets[1], response, &peerError); - }); + TryxRuntimeMediaTransform expected; + expected.schemaVersion = 1; + expected.mode = QStringLiteral("Crop"); + expected.rotationQuarterTurns = 3; + expected.zoomPermille = 2750; + expected.focusX = 1234; + expected.focusY = 9876; + expected.backgroundRgb = 0; - PrinterProtocol::PaseOverlayConfig overlay; - overlay.dualMode = true; - overlay.waterfallMode = true; - overlay.left.metrics = { - QStringLiteral("CPU Temperature")}; - overlay.left.badges = { - QStringLiteral("CPU Badge")}; - overlay.left.verticalPlacement = - QStringLiteral("Bottom"); - overlay.left.alignment = QStringLiteral("Right"); - overlay.left.textColor = 0xFF0000U; - overlay.right.metrics = { - QStringLiteral("GPU Power")}; - overlay.right.badges = { - QStringLiteral("GPU Badge")}; - overlay.right.verticalPlacement = - QStringLiteral("Top"); - overlay.right.alignment = QStringLiteral("Left"); - overlay.right.textColor = 0x00FF00U; - overlay.cpuBadgeText = - QStringLiteral("AMD Ryzen 9 9950X3D"); - overlay.gpuBadgeText = - QStringLiteral("AMD Radeon RX 7900 XTX"); - QString error; - const bool sent = protocol.sendPaseRunConfigForTesting( - endpoint, overlay, &error, - PrinterProtocol::OperationContext{}); - peer.join(); - ::close(sockets[1]); + QDBusConnection bus = QDBusConnection::sessionBus(); + QVERIFY2(bus.isConnected(), qPrintable(bus.lastError().message())); + RuntimeRoundTripObject serviceObject; + const QString objectPath = + QStringLiteral("/org/tryx/Panorama/TransformTest/%1") + .arg(QCoreApplication::applicationPid()); + QVERIFY2(bus.registerObject(objectPath, &serviceObject, + QDBusConnection::ExportAllSlots), + qPrintable(bus.lastError().message())); + QDBusInterface interface(bus.baseService(), objectPath, + QStringLiteral("org.tryx.Panorama.Test"), bus); + const QDBusReply reply = + interface.call(QStringLiteral("EchoMediaTransform"), + QVariant::fromValue(expected)); + bus.unregisterObject(objectPath); + QVERIFY2(reply.isValid(), qPrintable(reply.error().message())); + const TryxRuntimeMediaTransform actual = reply.value(); + QCOMPARE(actual.schemaVersion, expected.schemaVersion); + QCOMPARE(actual.mode, expected.mode); + QCOMPARE(actual.rotationQuarterTurns, expected.rotationQuarterTurns); + QCOMPARE(actual.zoomPermille, expected.zoomPermille); + QCOMPARE(actual.focusX, expected.focusX); + QCOMPARE(actual.focusY, expected.focusY); + QCOMPARE(actual.backgroundRgb, expected.backgroundRgb); +} - QVERIFY2(sent, qPrintable(error)); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - const auto &run = captured.overlay_layout(); - QCOMPARE(run.label_groups_size(), 4); - const auto findGroup = [&run](quint32 groupId) - -> const panorama::wire::v1::OverlayGroup * { - for (int index = 0; - index < run.label_groups_size(); ++index) { - if (run.label_groups(index).group_id() == - groupId) { - return &run.label_groups(index); - } - } - return nullptr; - }; - const auto *leftMetric = findGroup(100); - const auto *leftBadge = findGroup(300); - const auto *rightMetric = findGroup(207); - const auto *rightBadge = findGroup(400); - QVERIFY(leftMetric); - QVERIFY(leftBadge); - QVERIFY(rightMetric); - QVERIFY(rightBadge); +void PrinterProtocolTests::mediaTransformValidationAndFilters() { + const TryxRuntimeMediaTransform legacy = tryxLegacyFitMediaTransform(); + QVERIFY(tryxMediaTransformIsValid(legacy)); + QVERIFY(tryxMediaTransformIsLegacyFit(legacy)); + const QString legacyFingerprint = + tryxMediaTransformFingerprint(legacy); + QCOMPARE(legacyFingerprint.size(), 64); + const QString legacyFilter = + tryxMediaTransformFfmpegFilter(legacy); + QVERIFY(!legacyFilter.startsWith(QStringLiteral("transpose="))); + QVERIFY(!legacyFilter.startsWith(QStringLiteral("hflip,"))); + QVERIFY(legacyFilter.startsWith( + QStringLiteral( + "scale='if(lte(sar,0),iw,max(1,round(iw*sar)))':ih," + "setsar=1,"))); + QVERIFY(legacyFilter.contains( + QStringLiteral( + "scale=2240:1080:force_original_aspect_ratio=decrease"))); + QVERIFY(legacyFilter.contains( + QStringLiteral( + "pad=2240:1080:(ow-iw)/2:(oh-ih)/2:color=0x000000"))); + QVERIFY(legacyFilter.endsWith( + QStringLiteral("setsar=1,format=yuv420p,fps=30"))); + + TryxRuntimeMediaTransform fit = legacy; + fit.backgroundRgb = 0x123ABC; + fit.rotationQuarterTurns = 1; + QVERIFY(tryxMediaTransformIsValid(fit)); + const QString fitFilter = tryxMediaTransformFfmpegFilter(fit); + QVERIFY(fitFilter.startsWith(QStringLiteral("transpose=clock,"))); + QVERIFY(fitFilter.contains(QStringLiteral("color=0x123abc"))); + QVERIFY(tryxMediaTransformFingerprint(fit) != legacyFingerprint); + + fit.rotationQuarterTurns = 3; + QVERIFY(tryxMediaTransformFfmpegFilter(fit).startsWith( + QStringLiteral("transpose=cclock,"))); + + TryxRuntimeMediaTransform fill = legacy; + fill.mode = QStringLiteral("Fill"); + QVERIFY(tryxMediaTransformIsValid(fill)); + const QString fillFilter = tryxMediaTransformFfmpegFilter(fill); + QVERIFY(fillFilter.contains( + QStringLiteral( + "scale=2240:1080:force_original_aspect_ratio=increase:" + "force_divisible_by=2"))); + QVERIFY(fillFilter.contains( + QStringLiteral( + "crop=2240:1080:" + "'trunc((iw-2240)*5000/10000/2)*2':" + "'trunc((ih-1080)*5000/10000/2)*2'"))); + QVERIFY(!fillFilter.contains(QStringLiteral("pad="))); - QCOMPARE(leftMetric->group_x(), 60U); - QCOMPARE(leftMetric->group_y(), 1560U); - QCOMPARE(leftMetric->group_width(), 950U); - QCOMPARE(leftMetric->text_align(), - panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); - QCOMPARE(leftMetric->labels(0).text_color(), - 0xFF0000U); - QCOMPARE(leftBadge->group_x(), 70U); - QCOMPARE(leftBadge->group_y(), 1190U); - QCOMPARE(leftBadge->group_width(), 970U); - QCOMPARE(leftBadge->text_align(), - panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); + TryxRuntimeMediaTransform neutralCrop = legacy; + neutralCrop.mode = QStringLiteral("Crop"); + QCOMPARE( + tryxMediaTransformFfmpegFilter(neutralCrop), + fillFilter); + + TryxRuntimeMediaTransform crop = legacy; + crop.mode = QStringLiteral("Crop"); + crop.rotationQuarterTurns = 2; + crop.zoomPermille = 2500; + crop.focusX = 2500; + crop.focusY = 7500; + QVERIFY(tryxMediaTransformIsValid(crop)); + const QString cropFilter = tryxMediaTransformFfmpegFilter(crop); + QVERIFY(cropFilter.startsWith(QStringLiteral("hflip,vflip,"))); + QVERIFY(cropFilter.contains(QStringLiteral("iw*2500/1000"))); + QVERIFY(cropFilter.contains(QStringLiteral("(iw-2240)*2500/10000"))); + QVERIFY(cropFilter.contains(QStringLiteral("(ih-1080)*7500/10000"))); + + TryxRuntimeMediaTransform stretch = legacy; + stretch.mode = QStringLiteral("Stretch"); + QVERIFY(tryxMediaTransformIsValid(stretch)); + QCOMPARE( + tryxMediaTransformFfmpegFilter(stretch), + QStringLiteral( + "scale='if(lte(sar,0),iw,max(1,round(iw*sar)))':ih," + "setsar=1,scale=2240:1080," + "setsar=1,format=yuv420p,fps=30")); + + TryxRuntimeMediaTransform invalid = crop; + invalid.schemaVersion = 2; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + QVERIFY(tryxMediaTransformFingerprint(invalid).isEmpty()); + QVERIFY(tryxMediaTransformFfmpegFilter(invalid).isEmpty()); + + invalid = fill; + invalid.mode = QStringLiteral("Unknown"); + QVERIFY(!tryxMediaTransformIsValid(invalid)); + invalid = crop; + invalid.zoomPermille = 999; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + invalid.zoomPermille = 4001; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + invalid = fill; + invalid.zoomPermille = 1001; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + invalid = crop; + invalid.focusX = 10001; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + invalid = crop; + invalid.focusY = 10001; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + invalid = stretch; + invalid.backgroundRgb = 1; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + invalid = legacy; + invalid.backgroundRgb = 0x01000000; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + invalid = legacy; + invalid.rotationQuarterTurns = 4; + QVERIFY(!tryxMediaTransformIsValid(invalid)); + QVERIFY(tryxMediaTransformFfmpegFilter(legacy, 2239, 1080).isEmpty()); +} - QCOMPARE(rightMetric->group_x(), 60U); - QCOMPARE(rightMetric->group_y(), 440U); - QCOMPARE(rightMetric->group_width(), 950U); - QCOMPARE(rightMetric->text_align(), - panorama::wire::v1::OverlayGroup::ALIGN_LEFT); - QCOMPARE(rightMetric->labels(0).text_color(), - 0x00FF00U); - QCOMPARE(rightBadge->group_x(), 70U); - QCOMPARE(rightBadge->group_y(), 70U); - QCOMPARE(rightBadge->group_width(), 970U); - QCOMPARE(rightBadge->text_align(), - panorama::wire::v1::OverlayGroup::ALIGN_LEFT); +void PrinterProtocolTests::mediaTransformChangesConversionProfile() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sourcePath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("profile-source.png")); + QVERIFY(writeTextFile(sourcePath, QByteArray("profile-source"))); + + PrinterMediaPreparer preparer; + QSignalSpy analyzedSpy(&preparer, + &PrinterMediaPreparer::sourceAnalyzed); + const TryxRuntimeMediaTransform legacy = + tryxLegacyFitMediaTransform(); + preparer.analyzeSource( + QStringLiteral("21212121-2121-4121-8121-212121212121"), + sourcePath, 1, legacy); + QCOMPARE(analyzedSpy.count(), 1); + const QString legacyProfile = + analyzedSpy.first().at(4).toString(); + QCOMPARE( + legacyProfile, + QStringLiteral( + "pase-h264-v1-image-60s-2240x1080-yuv420p-30fps-libx264-veryfast-crf23")); + + TryxRuntimeMediaTransform crop = legacy; + crop.mode = QStringLiteral("Crop"); + crop.zoomPermille = 1750; + crop.focusX = 2500; + crop.focusY = 7500; + preparer.analyzeSource( + QStringLiteral("23232323-2323-4323-8323-232323232323"), + sourcePath, 1, crop); + QCOMPARE(analyzedSpy.count(), 2); + const QString cropProfile = + analyzedSpy.at(1).at(4).toString(); + QVERIFY(cropProfile.startsWith( + QStringLiteral( + "pase-h264-v2-image-60s-2240x1080-yuv420p-30fps-libx264-veryfast-crf23-transform-"))); + QVERIFY(cropProfile.endsWith( + tryxMediaTransformFingerprint(crop))); + QVERIFY(cropProfile != legacyProfile); + QVERIFY(cropProfile.size() <= 256); } -void PrinterProtocolTests::paseSplitApplyBuildsDualUserConfigAndBadges() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); +void PrinterProtocolTests::runtimeUploadAdaptorsPreserveMediaTransform() { + registerTryxRuntimeMetaTypes(); - panorama::wire::v1::Request capturedUserConfig; - panorama::wire::v1::Request capturedRunConfig; - QString peerError; - std::thread peer([&]() { - panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, &peerError) || - getRequest.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral( - "split apply did not read user config"); - return; - } - auto getResponse = baseResponse(getRequest); - auto *userConfig = getResponse.mutable_user_configuration(); - userConfig->mutable_display_config() - ->set_backlight_brightness(55); - userConfig->mutable_work_config() - ->set_single_mode_media_file("old.h264"); - userConfig->mutable_standby_config() - ->set_media_file("standby.h264"); - if (!writeResponse(sockets[1], getResponse, &peerError)) { - return; - } + enum class UploadMethod { + LegacyUpload, + LegacyUploadAndApply, + LegacyEnsure, + TransformedUpload, + TransformedUploadAndApply, + TransformedEnsure + }; + const QList methods{ + UploadMethod::LegacyUpload, + UploadMethod::LegacyUploadAndApply, + UploadMethod::LegacyEnsure, + UploadMethod::TransformedUpload, + UploadMethod::TransformedUploadAndApply, + UploadMethod::TransformedEnsure + }; - if (!readRequest(sockets[1], &capturedUserConfig, - &peerError) || - capturedUserConfig.body_case() != - panorama::wire::v1::Request::kUserConfiguration) { - peerError = QStringLiteral( - "split apply did not send user config"); - return; - } - auto userResponse = baseResponse(capturedUserConfig); - userResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], userResponse, &peerError)) { - return; - } + for (const UploadMethod method : methods) { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + manager->printerDisplaySessionActive_ = true; - if (!readRequest(sockets[1], &capturedRunConfig, - &peerError) || - capturedRunConfig.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { - peerError = QStringLiteral( - "split apply did not send run config"); - return; - } + const QString sourcePath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("transform.png")); + QVERIFY(writeTextFile(sourcePath, QByteArray("transform"))); - panorama::wire::v1::Request readbackRequest; - if (!readRequest(sockets[1], &readbackRequest, - &peerError) || - readbackRequest.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral( - "split apply did not verify user config"); - return; + QObject::disconnect( + manager.get(), &DeviceManager::requestAnalyzePrinterSource, + manager->printerMediaPreparer_, + &PrinterMediaPreparer::analyzeSource); + QObject::disconnect( + manager.get(), &DeviceManager::requestPreparePrinterMedia, + manager->printerMediaPreparer_, + &PrinterMediaPreparer::prepare); + QSignalSpy analyzeSpy( + manager.get(), &DeviceManager::requestAnalyzePrinterSource); + QSignalSpy prepareSpy( + manager.get(), &DeviceManager::requestPreparePrinterMedia); + + TryxRuntimeExportedObject exportedObject; + TryxRuntimeManagerAdaptor connectionAdaptor( + &exportedObject, manager.get()); + TryxRuntimeOperationsAdaptor operationsAdaptor( + &exportedObject, manager.get(), &connectionAdaptor); + + TryxRuntimeApplyRequest applyRequest; + applyRequest.screenMode = QStringLiteral("Full Screen"); + applyRequest.playMode = QStringLiteral("Single"); + applyRequest.ratio = QStringLiteral("2:1"); + TryxRuntimeMediaTransform transformed = + tryxLegacyFitMediaTransform(); + transformed.mode = QStringLiteral("Crop"); + transformed.rotationQuarterTurns = 3; + transformed.zoomPermille = 2200; + transformed.focusX = 3000; + transformed.focusY = 7000; + + const QString operationId = + QUuid::createUuid().toString(QUuid::WithoutBraces); + switch (method) { + case UploadMethod::LegacyUpload: + operationsAdaptor.QueueUpload(operationId, sourcePath, false); + break; + case UploadMethod::LegacyUploadAndApply: + operationsAdaptor.QueueUploadWithApply( + operationId, sourcePath, applyRequest); + break; + case UploadMethod::LegacyEnsure: + operationsAdaptor.QueueEnsureMediaAndApply( + operationId, sourcePath, applyRequest); + break; + case UploadMethod::TransformedUpload: + operationsAdaptor.QueueUploadWithTransform( + operationId, sourcePath, transformed); + break; + case UploadMethod::TransformedUploadAndApply: + operationsAdaptor.QueueUploadWithApplyAndTransform( + operationId, sourcePath, applyRequest, transformed); + break; + case UploadMethod::TransformedEnsure: + operationsAdaptor.QueueEnsureMediaAndApplyWithTransform( + operationId, sourcePath, applyRequest, transformed); + break; } - auto readbackResponse = - baseResponse(readbackRequest); - *readbackResponse.mutable_user_configuration() = - capturedUserConfig.user_configuration(); - writeResponse( - sockets[1], readbackResponse, &peerError); - }); - PrinterProtocol protocol(500); - const QString devicePath = - QStringLiteral("/dev/usb/lp-pase-split"); - protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); - PrinterProtocol::PaseApplyConfig config; - config.media = {QStringLiteral("left.h264"), - QStringLiteral("right.h264")}; - config.screenMode = QStringLiteral("Screen Splitting"); - config.playMode = QStringLiteral("Single"); - config.mediaPresent = true; - config.replaceOverlay = true; - config.overlay.dualMode = true; - config.overlay.left.metrics = { - QStringLiteral("CPU Temperature")}; - config.overlay.left.badges = { - QStringLiteral("CPU Badge")}; - config.overlay.right.metrics = { - QStringLiteral("GPU Power")}; - config.overlay.right.badges = { - QStringLiteral("GPU Badge")}; - config.overlay.cpuBadgeText = - QStringLiteral("AMD Ryzen 9 9950X3D"); - config.overlay.gpuBadgeText = - QStringLiteral("NVIDIA GeForce RTX"); - QString error; - PrinterProtocol::PaseDisplayState appliedState; - const bool applied = protocol.applyPaseConfiguration( - devicePath, config, &error, - PrinterProtocol::OperationContext{}, nullptr, - &appliedState); - peer.join(); - ::close(sockets[1]); + const bool ensure = + method == UploadMethod::LegacyEnsure || + method == UploadMethod::TransformedEnsure; + QCOMPARE(analyzeSpy.count(), ensure ? 1 : 0); + QCOMPARE(prepareSpy.count(), ensure ? 0 : 1); + const QList arguments = + ensure ? analyzeSpy.first() : prepareSpy.first(); + const int transformIndex = ensure ? 3 : 5; + const TryxRuntimeMediaTransform actual = + arguments.at(transformIndex) + .value(); + const bool transformedMethod = + method == UploadMethod::TransformedUpload || + method == UploadMethod::TransformedUploadAndApply || + method == UploadMethod::TransformedEnsure; + QCOMPARE( + tryxMediaTransformCanonicalValue(actual), + tryxMediaTransformCanonicalValue( + transformedMethod + ? transformed + : tryxLegacyFitMediaTransform())); + } +} - QVERIFY2(applied, qPrintable(error)); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - const auto &work = capturedUserConfig.user_configuration().work_config(); - QCOMPARE(work.media_mode(), - panorama::wire::v1::WorkConfiguration::MEDIA_DUAL); - QCOMPARE(work.loop_mode(), - panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); - QCOMPARE(QString::fromStdString( - work.dual_mode_left_media_file()), - QStringLiteral("left.h264")); - QCOMPARE(QString::fromStdString( - work.dual_mode_right_media_file()), - QStringLiteral("right.h264")); - QCOMPARE(appliedState.screenMode, - QStringLiteral("Screen Splitting")); - QCOMPARE(appliedState.media, config.media); +void PrinterProtocolTests::invalidMediaTransformDoesNotStartPreparation() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + QSignalSpy analyzeSpy( + manager.get(), &DeviceManager::requestAnalyzePrinterSource); + QSignalSpy prepareSpy( + manager.get(), &DeviceManager::requestPreparePrinterMedia); + const QString sourcePath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("invalid-transform.png")); + QVERIFY(writeTextFile(sourcePath, QByteArray("source"))); - const auto &run = capturedRunConfig.overlay_layout(); - QCOMPARE(run.label_groups_size(), 4); - const auto findGroup = [&run](quint32 groupId) - -> const panorama::wire::v1::OverlayGroup * { - for (int index = 0; index < run.label_groups_size(); ++index) { - if (run.label_groups(index).group_id() == groupId) { - return &run.label_groups(index); - } - } - return nullptr; - }; - const auto *leftMetric = findGroup(100); - const auto *leftBadge = findGroup(300); - const auto *rightMetric = findGroup(207); - const auto *rightBadge = findGroup(400); - QVERIFY(leftMetric); - QVERIFY(leftBadge); - QVERIFY(rightMetric); - QVERIFY(rightBadge); - QCOMPARE(leftMetric->labels(0).label_id(), 101U); - QCOMPARE(rightMetric->labels(0).label_id(), 222U); - QCOMPARE(leftBadge->labels_size(), 1); - QCOMPARE(leftBadge->labels(0).label_id(), 301U); - QCOMPARE(leftBadge->labels(0).background(), - panorama::wire::v1::OverlayLabel::BACKGROUND_GRADIENT_HORIZONTAL); - QCOMPARE(leftBadge->labels(0).background_color(), 0x00A92F2CU); - QCOMPARE(leftBadge->labels(0).gradient_color(), 0x00CB6236U); - QCOMPARE(rightBadge->labels_size(), 1); - QCOMPARE(rightBadge->labels(0).label_id(), 402U); - QCOMPARE(rightBadge->labels(0).background_color(), 0x00629A00U); - QCOMPARE(rightBadge->labels(0).gradient_color(), 0x0079AB51U); + TryxRuntimeMediaTransform invalid = + tryxLegacyFitMediaTransform(); + invalid.mode = QStringLiteral("Crop"); + invalid.zoomPermille = 999; + const QString uploadId = + manager->queueUploadOperation( + QStringLiteral("24242424-2424-4424-8424-242424242424"), + sourcePath, false, {}, false, false, invalid); + QCOMPARE( + manager->operationInfo(uploadId).errorCategory, + QStringLiteral("InvalidMediaTransform")); + QCOMPARE(analyzeSpy.count(), 0); + QCOMPARE(prepareSpy.count(), 0); + + TryxRuntimeApplyRequest applyRequest; + const QString ensureId = + manager->queueEnsureMediaAndApplyOperation( + QStringLiteral("25252525-2525-4525-8525-252525252525"), + sourcePath, applyRequest, invalid); + QCOMPARE( + manager->operationInfo(ensureId).errorCategory, + QStringLiteral("InvalidMediaTransform")); + QCOMPARE(analyzeSpy.count(), 0); + QCOMPARE(prepareSpy.count(), 0); +} + +void PrinterProtocolTests::pendingPreparationPreservesMediaTransform() { + PrinterMediaPreparer preparer; + preparer.active_ = true; + TryxRuntimeMediaTransform crop = + tryxLegacyFitMediaTransform(); + crop.mode = QStringLiteral("Crop"); + crop.rotationQuarterTurns = 1; + crop.zoomPermille = 3250; + crop.focusX = 1234; + crop.focusY = 8765; + + preparer.prepare( + QStringLiteral("26262626-2626-4626-8626-262626262626"), + QStringLiteral("/dev/usb/lp0"), + QStringLiteral("/tmp/pending.png"), QString(), 9, crop); + QVERIFY(preparer.hasPending_); + QCOMPARE( + tryxMediaTransformCanonicalValue(preparer.pendingTransform_), + tryxMediaTransformCanonicalValue(crop)); + preparer.active_ = false; + preparer.hasPending_ = false; +} + +void PrinterProtocolTests::mediaTransformProfilePreventsOriginReuse() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sourcePath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("origin.png")); + const QByteArray sourceBytes("origin-source"); + QVERIFY(writeTextFile(sourcePath, sourceBytes)); + const QString sourceSha = + QString::fromLatin1( + QCryptographicHash::hash( + sourceBytes, QCryptographicHash::Sha256).toHex()); + + PrinterMediaPreparer preparer; + QSignalSpy analyzedSpy( + &preparer, &PrinterMediaPreparer::sourceAnalyzed); + const TryxRuntimeMediaTransform legacy = + tryxLegacyFitMediaTransform(); + TryxRuntimeMediaTransform crop = legacy; + crop.mode = QStringLiteral("Crop"); + crop.zoomPermille = 1500; + preparer.analyzeSource( + QStringLiteral("27272727-2727-4727-8727-272727272727"), + sourcePath, 1, legacy); + preparer.analyzeSource( + QStringLiteral("28282828-2828-4828-8828-282828282828"), + sourcePath, 1, crop); + QCOMPARE(analyzedSpy.count(), 2); + const QString legacyProfile = + analyzedSpy.at(0).at(4).toString(); + const QString cropProfile = + analyzedSpy.at(1).at(4).toString(); + QVERIFY(legacyProfile != cropProfile); + + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->printerDeviceSerial_ = QStringLiteral("PASE-ORIGIN"); + PrinterProtocol::MediaFile remote; + remote.name = QStringLiteral("origin.png.h264_2240x1080"); + remote.size = 321; + remote.source = PrinterProtocol::MediaSource::User; + remote.readOnly = false; + TryxRuntimeMediaEntry entry; + entry.name = remote.name; + entry.size = remote.size; + entry.source = 1; + const QString key = manager->mediaThumbnailKey( + manager->printerDeviceSerial_, entry); + QJsonObject origin; + origin.insert(QStringLiteral("deviceIdentity"), + manager->printerDeviceSerial_); + origin.insert(QStringLiteral("name"), remote.name); + origin.insert(QStringLiteral("size"), + QString::number(remote.size)); + origin.insert(QStringLiteral("source"), 1); + origin.insert(QStringLiteral("readOnly"), false); + origin.insert(QStringLiteral("sourceContentSha256"), sourceSha); + origin.insert(QStringLiteral("sourceSize"), + QString::number(sourceBytes.size())); + origin.insert(QStringLiteral("preparedSha256"), + QString(64, QLatin1Char('a'))); + origin.insert(QStringLiteral("conversionProfile"), legacyProfile); + manager->mediaCatalogIndex_.insert(key, origin); + const QList mediaFiles{remote}; + QCOMPARE( + manager->findReusableMediaOrigin( + sourceSha, legacyProfile, mediaFiles), + remote.name); + QVERIFY( + manager->findReusableMediaOrigin( + sourceSha, cropProfile, mediaFiles).isEmpty()); +} + +void PrinterProtocolTests::quickStagedSourceIsClaimedBeforeAcceptance() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + manager->printerDisplaySessionActive_ = true; + manager->cleanupMediaRuntimeStaging(); + + QObject::disconnect( + manager.get(), &DeviceManager::requestPreparePrinterMedia, + manager->printerMediaPreparer_, + &PrinterMediaPreparer::prepare); + QSignalSpy prepareSpy( + manager.get(), &DeviceManager::requestPreparePrinterMedia); + + const QString stagedName = + QUuid::createUuid().toString(QUuid::WithoutBraces) + + QStringLiteral(".png"); + const QString inboxPath = + QDir(manager->mediaInboxDirectory()).filePath(stagedName); + QVERIFY(writeTextFile(inboxPath, QByteArray("staged-source"))); + QVERIFY(QFile::setPermissions( + inboxPath, QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + + const QString operationId = + QStringLiteral("31313131-3131-4131-8131-313131313131"); + QCOMPARE( + manager->queueUploadOperation( + operationId, inboxPath, false, {}, false, false, + tryxLegacyFitMediaTransform()), + operationId); + + const QString spoolPath = + QDir(manager->mediaSpoolDirectory()) + .filePath(operationId + QStringLiteral(".png")); + QVERIFY(!QFileInfo::exists(inboxPath)); + QVERIFY(QFileInfo::exists(spoolPath)); + QCOMPARE(prepareSpy.count(), 1); + QCOMPARE(prepareSpy.first().at(2).toString(), spoolPath); + QVERIFY(manager->operations_.value(operationId).ownsSourcePath); + QCOMPARE( + manager->operations_.value(operationId).sourcePath, + spoolPath); + + manager->finishOperation( + operationId, QStringLiteral("Failed"), + QStringLiteral("TestFailure"), QString(), + QStringLiteral("test terminal cleanup")); + QVERIFY(!QFileInfo::exists(spoolPath)); + QVERIFY(!manager->operations_.value(operationId).ownsSourcePath); } void PrinterProtocolTests:: -paseApplyRetriesDroppedReadOnlyConfigResponse_data() { - QTest::addColumn("dropPreflightResponse"); - QTest::addColumn("sendLateDroppedResponse"); - QTest::addColumn("sendLateBeforeRetryRequest"); + quickStagedSourceRejectionKeepsInboxOwnership() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + manager->printerDisplaySessionActive_ = true; + manager->cleanupMediaRuntimeStaging(); + + const QString stagedName = + QUuid::createUuid().toString(QUuid::WithoutBraces) + + QStringLiteral(".png"); + const QString inboxPath = + QDir(manager->mediaInboxDirectory()).filePath(stagedName); + QVERIFY(writeTextFile(inboxPath, QByteArray("rejected-source"))); + QVERIFY(QFile::setPermissions( + inboxPath, QFileDevice::ReadOwner | QFileDevice::WriteOwner)); - QTest::newRow("preflight-clean-timeout") - << true << false << false; - QTest::newRow("preflight-late-after-retry") - << true << true << false; - QTest::newRow("preflight-late-during-backoff") - << true << true << true; - QTest::newRow("verification-clean-timeout") - << false << false << false; + TryxRuntimeApplyRequest unsupported; + unsupported.screenMode = QStringLiteral("Screen Splitting"); + unsupported.playMode = QStringLiteral("Single"); + unsupported.ratio = QStringLiteral("2:1"); + const QString operationId = + QStringLiteral("32323232-3232-4232-8232-323232323232"); + QVERIFY( + manager->queueUploadOperation( + operationId, inboxPath, true, unsupported, false, false, + tryxLegacyFitMediaTransform()) + .isEmpty()); + QCOMPARE( + manager->operationInfo(operationId).errorCategory, + QStringLiteral("UnsupportedConfiguration")); + QVERIFY(QFileInfo::exists(inboxPath)); + QVERIFY(QDir(manager->mediaSpoolDirectory()) + .entryList(QDir::Files | QDir::NoDotAndDotDot) + .isEmpty()); } void PrinterProtocolTests:: -paseApplyRetriesDroppedReadOnlyConfigResponse() { - QFETCH(bool, dropPreflightResponse); - QFETCH(bool, sendLateDroppedResponse); - QFETCH(bool, sendLateBeforeRetryRequest); + quickStagedSourceValidationAndLegacyBoundary() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + manager->printerDisplaySessionActive_ = true; + manager->cleanupMediaRuntimeStaging(); - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); + QObject::disconnect( + manager.get(), &DeviceManager::requestPreparePrinterMedia, + manager->printerMediaPreparer_, + &PrinterMediaPreparer::prepare); + QSignalSpy prepareSpy( + manager.get(), &DeviceManager::requestPreparePrinterMedia); - quint64 droppedTrackId = 0; - quint64 retryTrackId = 0; - int userConfigurationWrites = 0; - int overlayWrites = 0; - QString peerError; - std::thread peer([&]() { - const auto writeInitialConfiguration = - [&](const panorama::wire::v1::Request &request) { - auto response = baseResponse(request); - auto *configuration = - response.mutable_user_configuration(); - configuration->mutable_display_config() - ->set_backlight_brightness(55); - auto *work = - configuration->mutable_work_config(); - work->set_media_mode( - panorama::wire::v1::WorkConfiguration:: - MEDIA_SINGLE); - work->set_loop_mode( - panorama::wire::v1::WorkConfiguration:: - LOOP_SINGLE); - work->set_single_mode_media_file( - "old.h264"); - return writeResponse( - sockets[1], response, &peerError); - }; + const auto stagedPath = [manager = manager.get()]() { + return QDir(manager->mediaInboxDirectory()) + .filePath( + QUuid::createUuid().toString( + QUuid::WithoutBraces) + + QStringLiteral(".png")); + }; - panorama::wire::v1::Request preflightRequest; - if (!readRequest( - sockets[1], &preflightRequest, &peerError) || - preflightRequest.body_case() != - panorama::wire::v1::Request:: - kUserConfigurationQuery) { - peerError = QStringLiteral( - "missing first user configuration preflight"); - return; - } - - if (dropPreflightResponse) { - droppedTrackId = - preflightRequest.header().track_id(); - if (sendLateBeforeRetryRequest) { - QElapsedTimer delay; - delay.start(); - while (delay.elapsed() < 175) { - const int remaining = - 175 - static_cast( - delay.elapsed()); - const int result = - ::poll( - nullptr, 0, - qMax(1, remaining)); - if (result < 0 && errno != EINTR) { - peerError = QStringLiteral( - "late response delay failed"); - return; - } - } - if (!writeInitialConfiguration( - preflightRequest)) { - return; - } - } - panorama::wire::v1::Request retryRequest; - if (!readRequest( - sockets[1], &retryRequest, &peerError) || - retryRequest.body_case() != - panorama::wire::v1::Request:: - kUserConfigurationQuery) { - peerError = QStringLiteral( - "missing retried user configuration preflight"); - return; - } - retryTrackId = retryRequest.header().track_id(); - if (sendLateDroppedResponse && - !sendLateBeforeRetryRequest && - !writeInitialConfiguration(preflightRequest)) { - return; - } - if (!writeInitialConfiguration(retryRequest)) { - return; - } - } else if (!writeInitialConfiguration( - preflightRequest)) { - return; - } + const QString wrongModePath = stagedPath(); + QVERIFY(writeTextFile(wrongModePath, QByteArray("wrong-mode"))); + QVERIFY(QFile::setPermissions( + wrongModePath, QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ReadGroup)); + const QString wrongModeId = + QStringLiteral("33333333-3333-4333-8333-333333333333"); + QVERIFY(manager->queueUploadOperation( + wrongModeId, wrongModePath) + .isEmpty()); + QCOMPARE( + manager->operationInfo(wrongModeId).errorCategory, + QStringLiteral("InvalidStagedSource")); + QVERIFY(QFileInfo::exists(wrongModePath)); + + const QString specialModePath = stagedPath(); + QVERIFY(writeTextFile(specialModePath, QByteArray("special-mode"))); + QVERIFY(::chmod( + QFile::encodeName(specialModePath).constData(), + S_ISUID | S_IRUSR | S_IWUSR) == 0); + const QString specialModeId = + QStringLiteral("38383838-3838-4838-8838-383838383838"); + QVERIFY(manager->queueUploadOperation( + specialModeId, specialModePath) + .isEmpty()); + QCOMPARE( + manager->operationInfo(specialModeId).errorCategory, + QStringLiteral("InvalidStagedSource")); + QVERIFY(QFileInfo::exists(specialModePath)); - panorama::wire::v1::Request userRequest; - if (!readRequest( - sockets[1], &userRequest, &peerError) || - userRequest.body_case() != - panorama::wire::v1::Request:: - kUserConfiguration) { - peerError = QStringLiteral( - "missing single user configuration mutation"); - return; - } - ++userConfigurationWrites; - auto acknowledgement = baseResponse(userRequest); - acknowledgement.mutable_acknowledgement(); - if (!writeResponse( - sockets[1], acknowledgement, &peerError)) { - return; - } + const QString symlinkTarget = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("symlink-target.png")); + QVERIFY(writeTextFile(symlinkTarget, QByteArray("target"))); + const QString symlinkPath = stagedPath(); + QVERIFY(::symlink( + QFile::encodeName(symlinkTarget).constData(), + QFile::encodeName(symlinkPath).constData()) == 0); + const QString symlinkId = + QStringLiteral("34343434-3434-4434-8434-343434343434"); + QVERIFY(manager->queueUploadOperation( + symlinkId, symlinkPath) + .isEmpty()); + QCOMPARE( + manager->operationInfo(symlinkId).errorCategory, + QStringLiteral("InvalidSource")); + QVERIFY(QFileInfo(symlinkPath).isSymLink()); + + const QString nestedDirectory = + QDir(manager->mediaInboxDirectory()) + .filePath(QStringLiteral("nested")); + QVERIFY(QDir().mkpath(nestedDirectory)); + const QString nestedPath = + QDir(nestedDirectory).filePath( + QUuid::createUuid().toString( + QUuid::WithoutBraces) + + QStringLiteral(".png")); + QVERIFY(writeTextFile(nestedPath, QByteArray("nested"))); + QVERIFY(QFile::setPermissions( + nestedPath, QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + const QString nestedId = + QStringLiteral("35353535-3535-4535-8535-353535353535"); + QVERIFY(manager->queueUploadOperation( + nestedId, nestedPath) + .isEmpty()); + QCOMPARE( + manager->operationInfo(nestedId).errorCategory, + QStringLiteral("InvalidStagedSource")); + QVERIFY(QFileInfo::exists(nestedPath)); - panorama::wire::v1::Request overlayRequest; - if (!readRequest( - sockets[1], &overlayRequest, &peerError) || - overlayRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { - peerError = QStringLiteral( - "missing single overlay activation"); - return; - } - ++overlayWrites; + const QString aliasedInbox = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("inbox-alias")); + QVERIFY(::symlink( + QFile::encodeName( + manager->mediaInboxDirectory()).constData(), + QFile::encodeName(aliasedInbox).constData()) == 0); + const QString aliasedFinalPath = stagedPath(); + QVERIFY(writeTextFile( + aliasedFinalPath, QByteArray("aliased-parent"))); + QVERIFY(QFile::setPermissions( + aliasedFinalPath, QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + const QString aliasRequestPath = + QDir(aliasedInbox).filePath( + QFileInfo(aliasedFinalPath).fileName()); + const QString aliasId = + QStringLiteral("36363636-3636-4636-8636-363636363636"); + QVERIFY(manager->queueUploadOperation( + aliasId, aliasRequestPath) + .isEmpty()); + QCOMPARE( + manager->operationInfo(aliasId).errorCategory, + QStringLiteral("InvalidStagedSource")); + QVERIFY(QFileInfo::exists(aliasedFinalPath)); - const auto writeAppliedConfiguration = - [&](const panorama::wire::v1::Request &request) { - auto response = baseResponse(request); - *response.mutable_user_configuration() = - userRequest.user_configuration(); - return writeResponse( - sockets[1], response, &peerError); - }; + const QString externalPath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("legacy-external.png")); + QVERIFY(writeTextFile(externalPath, QByteArray("legacy"))); + const QString externalId = + QStringLiteral("37373737-3737-4737-8737-373737373737"); + QCOMPARE( + manager->queueUploadOperation( + externalId, externalPath), + externalId); + QCOMPARE(prepareSpy.count(), 1); + QCOMPARE( + prepareSpy.first().at(2).toString(), + QFileInfo(externalPath).absoluteFilePath()); + QVERIFY(!manager->operations_.value(externalId).ownsSourcePath); + QVERIFY(QFileInfo::exists(externalPath)); + manager->finishOperation( + externalId, QStringLiteral("Failed"), + QStringLiteral("TestFailure"), QString(), + QStringLiteral("test terminal cleanup")); + QVERIFY(QFileInfo::exists(externalPath)); +} - panorama::wire::v1::Request verificationRequest; - if (!readRequest( - sockets[1], &verificationRequest, - &peerError) || - verificationRequest.body_case() != - panorama::wire::v1::Request:: - kUserConfigurationQuery) { - peerError = QStringLiteral( - "missing first user configuration verification"); - return; - } +void PrinterProtocolTests::mediaRuntimeStartupCleanupIsBounded() { + constexpr qint64 mediaInboxMaxAgeSeconds = 24 * 60 * 60; - if (!dropPreflightResponse) { - droppedTrackId = - verificationRequest.header().track_id(); - panorama::wire::v1::Request retryRequest; - if (!readRequest( - sockets[1], &retryRequest, &peerError) || - retryRequest.body_case() != - panorama::wire::v1::Request:: - kUserConfigurationQuery) { - peerError = QStringLiteral( - "missing retried user configuration verification"); - return; - } - retryTrackId = retryRequest.header().track_id(); - if (!writeAppliedConfiguration(retryRequest)) { - return; - } - } else { - if (!writeAppliedConfiguration(verificationRequest)) { - return; - } - } - }); + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->cleanupMediaRuntimeStaging(); + + const QString oldInbox = + QDir(manager->mediaInboxDirectory()) + .filePath(QStringLiteral("old.part")); + const QString freshInbox = + QDir(manager->mediaInboxDirectory()) + .filePath(QStringLiteral("fresh.part")); + const QString orphanSpool = + QDir(manager->mediaSpoolDirectory()) + .filePath(QStringLiteral("orphan.png")); + QVERIFY(writeTextFile(oldInbox, QByteArray("old"))); + QVERIFY(writeTextFile(freshInbox, QByteArray("fresh"))); + QVERIFY(writeTextFile(orphanSpool, QByteArray("orphan"))); + QVERIFY(QFile::setPermissions( + oldInbox, QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + QVERIFY(QFile::setPermissions( + freshInbox, QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + QVERIFY(QFile::setPermissions( + orphanSpool, QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + + QFile oldFile(oldInbox); + QVERIFY(oldFile.open(QIODevice::ReadWrite)); + QVERIFY(oldFile.setFileTime( + QDateTime::currentDateTimeUtc().addSecs( + -mediaInboxMaxAgeSeconds - 60), + QFileDevice::FileModificationTime)); + oldFile.close(); + + manager->cleanupMediaRuntimeStaging(); + QVERIFY(!QFileInfo::exists(oldInbox)); + QVERIFY(QFileInfo::exists(freshInbox)); + QVERIFY(!QFileInfo::exists(orphanSpool)); +} - PrinterProtocol protocol(75); - const QString devicePath = - QStringLiteral( - "/dev/usb/lp-pase-idempotent-query-retry"); - protocol.adoptFileDescriptorForTesting( - sockets[0], devicePath); +void PrinterProtocolTests::runtimeDisplayConfigDbusRoundTrip() { + registerTryxRuntimeMetaTypes(); - PrinterProtocol::PaseApplyConfig config; - config.media = { + TryxRuntimeApplyRequest expectedRequest; + expectedRequest.media = { QStringLiteral("left.h264"), QStringLiteral("right.h264")}; - config.screenMode = + expectedRequest.ratio = QStringLiteral("2:1"); + expectedRequest.screenMode = QStringLiteral("Screen Splitting"); - config.playMode = QStringLiteral("Single"); - config.mediaPresent = true; - config.replaceOverlay = true; - config.overlay.dualMode = true; + expectedRequest.playMode = QStringLiteral("Single"); + expectedRequest.sysinfoLabels = { + QStringLiteral("CPU Temperature")}; + expectedRequest.settingsPosition = QStringLiteral("Top"); + expectedRequest.settingsColor = QStringLiteral("#DCDCDC"); + expectedRequest.settingsAlign = QStringLiteral("Left"); + expectedRequest.settingsBadges = { + QStringLiteral("CPU Badge")}; + expectedRequest.filterOpacity = 33; + expectedRequest.presetId = QStringLiteral("preset"); + expectedRequest.sysinfoLabels2 = { + QStringLiteral("GPU Power")}; + expectedRequest.settingsBadges2 = { + QStringLiteral("GPU Badge")}; + expectedRequest.settingsPosition2 = QStringLiteral("Bottom"); + expectedRequest.settingsColor2 = QStringLiteral("#000000"); + expectedRequest.settingsAlign2 = QStringLiteral("Right"); + expectedRequest.waterfallMode = true; + expectedRequest.replaceOverlay = true; + expectedRequest.display.brightnessPresent = true; + expectedRequest.display.brightness = 64; + expectedRequest.display.standbyPresent = true; + expectedRequest.display.standbyEnabled = false; + expectedRequest.display.orientationPresent = true; + expectedRequest.display.mirrorMode = true; + expectedRequest.display.waterfallMode = true; + expectedRequest.display.backlightPresent = true; + expectedRequest.display.backlightEnabled = false; - QString error; - PrinterProtocol::MutationDetails mutation; - PrinterProtocol::PaseDisplayState appliedState; - const bool applied = - protocol.applyPaseConfiguration( - devicePath, config, &error, - PrinterProtocol::OperationContext{}, - &mutation, &appliedState); + TryxRuntimeDisplayState expectedState; + expectedState.revision = 41; + expectedState.deviceSerial = QStringLiteral("PASE-DISPLAY-001"); + expectedState.valid = true; + expectedState.backlightEnabled = true; + expectedState.brightness = 64; + expectedState.standbyEnabled = false; + expectedState.standbyMedia = QStringLiteral("standby.h264"); + expectedState.mirrorMode = true; + expectedState.waterfallMode = true; + expectedState.screenMode = expectedRequest.screenMode; + expectedState.playMode = expectedRequest.playMode; + expectedState.media = expectedRequest.media; + expectedState.sysinfoLabels = expectedRequest.sysinfoLabels; + expectedState.settingsBadges = expectedRequest.settingsBadges; + expectedState.settingsPosition = + expectedRequest.settingsPosition; + expectedState.settingsColor = expectedRequest.settingsColor; + expectedState.settingsAlign = expectedRequest.settingsAlign; + expectedState.sysinfoLabels2 = + expectedRequest.sysinfoLabels2; + expectedState.settingsBadges2 = + expectedRequest.settingsBadges2; + expectedState.settingsPosition2 = + expectedRequest.settingsPosition2; + expectedState.settingsColor2 = + expectedRequest.settingsColor2; + expectedState.settingsAlign2 = + expectedRequest.settingsAlign2; + expectedState.diagnostic = QStringLiteral("display status"); - peer.join(); - ::close(sockets[1]); + QDBusConnection bus = QDBusConnection::sessionBus(); + QVERIFY2(bus.isConnected(), qPrintable(bus.lastError().message())); + RuntimeRoundTripObject serviceObject; + const QString objectPath = + QStringLiteral("/org/tryx/Panorama/DisplayTest/%1") + .arg(QCoreApplication::applicationPid()); + QVERIFY2(bus.registerObject(objectPath, &serviceObject, + QDBusConnection::ExportAllSlots), + qPrintable(bus.lastError().message())); + QDBusInterface interface(bus.baseService(), objectPath, + QStringLiteral("org.tryx.Panorama.Test"), bus); - QVERIFY2(applied, qPrintable(error)); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QVERIFY(droppedTrackId != 0); - QVERIFY(retryTrackId != 0); - QVERIFY(droppedTrackId != retryTrackId); - QCOMPARE(userConfigurationWrites, 1); - QCOMPARE(overlayWrites, 1); - QCOMPARE( - mutation.outcome, - PrinterProtocol::MutationOutcome::Succeeded); - QCOMPARE(appliedState.screenMode, - QStringLiteral("Screen Splitting")); - QCOMPARE(appliedState.media, config.media); + const QDBusReply requestReply = + interface.call(QStringLiteral("EchoApplyRequest"), + QVariant::fromValue(expectedRequest)); + QVERIFY2(requestReply.isValid(), + qPrintable(requestReply.error().message())); + const TryxRuntimeApplyRequest actualRequest = requestReply.value(); + QCOMPARE(actualRequest.media, expectedRequest.media); + QCOMPARE(actualRequest.ratio, expectedRequest.ratio); + QCOMPARE(actualRequest.screenMode, expectedRequest.screenMode); + QCOMPARE(actualRequest.playMode, expectedRequest.playMode); + QCOMPARE(actualRequest.sysinfoLabels, + expectedRequest.sysinfoLabels); + QCOMPARE(actualRequest.settingsPosition, + expectedRequest.settingsPosition); + QCOMPARE(actualRequest.settingsColor, + expectedRequest.settingsColor); + QCOMPARE(actualRequest.settingsAlign, + expectedRequest.settingsAlign); + QCOMPARE(actualRequest.settingsBadges, + expectedRequest.settingsBadges); + QCOMPARE(actualRequest.filterOpacity, + expectedRequest.filterOpacity); + QCOMPARE(actualRequest.presetId, expectedRequest.presetId); + QCOMPARE(actualRequest.sysinfoLabels2, + expectedRequest.sysinfoLabels2); + QCOMPARE(actualRequest.settingsBadges2, + expectedRequest.settingsBadges2); + QCOMPARE(actualRequest.settingsPosition2, + expectedRequest.settingsPosition2); + QCOMPARE(actualRequest.settingsColor2, + expectedRequest.settingsColor2); + QCOMPARE(actualRequest.settingsAlign2, + expectedRequest.settingsAlign2); + QCOMPARE(actualRequest.waterfallMode, + expectedRequest.waterfallMode); + QCOMPARE(actualRequest.replaceOverlay, + expectedRequest.replaceOverlay); + QCOMPARE(actualRequest.display.brightnessPresent, + expectedRequest.display.brightnessPresent); + QCOMPARE(actualRequest.display.brightness, + expectedRequest.display.brightness); + QCOMPARE(actualRequest.display.standbyPresent, + expectedRequest.display.standbyPresent); + QCOMPARE(actualRequest.display.standbyEnabled, + expectedRequest.display.standbyEnabled); + QCOMPARE(actualRequest.display.orientationPresent, + expectedRequest.display.orientationPresent); + QCOMPARE(actualRequest.display.mirrorMode, + expectedRequest.display.mirrorMode); + QCOMPARE(actualRequest.display.waterfallMode, + expectedRequest.display.waterfallMode); + QCOMPARE(actualRequest.display.backlightPresent, + expectedRequest.display.backlightPresent); + QCOMPARE(actualRequest.display.backlightEnabled, + expectedRequest.display.backlightEnabled); + + const QDBusReply stateReply = + interface.call(QStringLiteral("EchoDisplayState"), + QVariant::fromValue(expectedState)); + bus.unregisterObject(objectPath); + QVERIFY2(stateReply.isValid(), + qPrintable(stateReply.error().message())); + const TryxRuntimeDisplayState actualState = stateReply.value(); + QCOMPARE(actualState.revision, expectedState.revision); + QCOMPARE(actualState.deviceSerial, expectedState.deviceSerial); + QCOMPARE(actualState.valid, expectedState.valid); + QCOMPARE(actualState.backlightEnabled, + expectedState.backlightEnabled); + QCOMPARE(actualState.brightness, expectedState.brightness); + QCOMPARE(actualState.standbyEnabled, + expectedState.standbyEnabled); + QCOMPARE(actualState.standbyMedia, expectedState.standbyMedia); + QCOMPARE(actualState.mirrorMode, expectedState.mirrorMode); + QCOMPARE(actualState.waterfallMode, expectedState.waterfallMode); + QCOMPARE(actualState.screenMode, expectedState.screenMode); + QCOMPARE(actualState.playMode, expectedState.playMode); + QCOMPARE(actualState.media, expectedState.media); + QCOMPARE(actualState.sysinfoLabels, + expectedState.sysinfoLabels); + QCOMPARE(actualState.settingsBadges, + expectedState.settingsBadges); + QCOMPARE(actualState.settingsPosition, + expectedState.settingsPosition); + QCOMPARE(actualState.settingsColor, + expectedState.settingsColor); + QCOMPARE(actualState.settingsAlign, + expectedState.settingsAlign); + QCOMPARE(actualState.sysinfoLabels2, + expectedState.sysinfoLabels2); + QCOMPARE(actualState.settingsBadges2, + expectedState.settingsBadges2); + QCOMPARE(actualState.settingsPosition2, + expectedState.settingsPosition2); + QCOMPARE(actualState.settingsColor2, + expectedState.settingsColor2); + QCOMPARE(actualState.settingsAlign2, + expectedState.settingsAlign2); + QCOMPARE(actualState.diagnostic, expectedState.diagnostic); } void PrinterProtocolTests:: -paseApplyReadOnlyRetryCancellationSendsNoMutation() { +remoteDisplayStateRequiresStrictlyIncreasingRevision() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + QDir().mkpath(sysRoot); + QDir().mkpath(devRoot); + + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->remoteMode_ = true; + manager->remoteApiCompatible_ = true; + QSignalSpy displaySpy( + manager.get(), &DeviceManager::displayStateUpdated); + + TryxRuntimeDisplayState initial; + initial.revision = 0; + initial.valid = true; + initial.brightness = 41; + manager->handleRemoteDisplayStateUpdated(initial); + QCOMPARE(displaySpy.count(), 1); + QCOMPARE(manager->displayState().brightness, 41); + + TryxRuntimeDisplayState duplicate = initial; + duplicate.brightness = 99; + manager->handleRemoteDisplayStateUpdated(duplicate); + QCOMPARE(displaySpy.count(), 1); + QCOMPARE(manager->displayState().brightness, 41); + + TryxRuntimeDisplayState newer = initial; + newer.revision = 1; + newer.brightness = 73; + manager->handleRemoteDisplayStateUpdated(newer); + QCOMPARE(displaySpy.count(), 2); + QCOMPARE(manager->displayState().brightness, 73); + manager->remoteMode_ = false; +} + +void PrinterProtocolTests::paseRunConfigUsesWireLayout() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); - const int cancellationFd = - ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); - QVERIFY(cancellationFd >= 0); + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - int queryCount = 0; + PrinterProtocol protocol; + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("/dev/usb/lp-pase-layout")); + panorama::wire::v1::Request captured; QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request request; - if (!readRequest( - sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request:: - kUserConfigurationQuery) { - peerError = QStringLiteral( - "missing user configuration query before retry cancellation"); - return; - } - ++queryCount; - - QElapsedTimer delay; - delay.start(); - while (delay.elapsed() < 150) { - const int remaining = - 150 - static_cast(delay.elapsed()); - const int result = - ::poll(nullptr, 0, qMax(1, remaining)); - if (result < 0 && errno != EINTR) { - peerError = QStringLiteral( - "retry cancellation delay failed"); - return; - } - } - - const uint64_t cancellationValue = 1; - if (::write( - cancellationFd, &cancellationValue, - sizeof(cancellationValue)) != - static_cast( - sizeof(cancellationValue))) { - peerError = QStringLiteral( - "failed to cancel read-only retry backoff"); - return; - } - - if (!waitForPeerClosureWithoutPayload( - sockets[1], 600, &peerError)) { + if (!readRequest(sockets[1], &captured, &peerError)) { return; } + auto response = baseResponse(captured); + response.mutable_acknowledgement(); + writeResponse(sockets[1], response, &peerError); }); - PrinterProtocol protocol(75); - const QString devicePath = - QStringLiteral( - "/dev/usb/lp-pase-idempotent-query-cancel"); - protocol.adoptFileDescriptorForTesting( - sockets[0], devicePath); - - PrinterProtocol::PaseApplyConfig config; - config.media = { - QStringLiteral("left.h264"), - QStringLiteral("right.h264")}; - config.screenMode = - QStringLiteral("Screen Splitting"); - config.playMode = QStringLiteral("Single"); - config.mediaPresent = true; - - PrinterProtocol::OperationContext context; - context.cancellationFd = cancellationFd; + PrinterProtocol::PaseOverlayConfig overlay; + overlay.left.metrics = {QStringLiteral("CPU Temperature")}; + overlay.left.alignment = QStringLiteral("Left"); + overlay.left.textColor = 0xFF0000U; QString error; - PrinterProtocol::MutationDetails mutation; - QElapsedTimer elapsed; - elapsed.start(); - const bool applied = - protocol.applyPaseConfiguration( - devicePath, config, &error, context, - &mutation); - + const bool sent = protocol.sendPaseRunConfigForTesting( + QStringLiteral("/dev/usb/lp-pase-layout"), overlay, &error, + PrinterProtocol::OperationContext{}); peer.join(); - ::close(cancellationFd); ::close(sockets[1]); - QVERIFY(!applied); + QVERIFY2(sent, qPrintable(error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(queryCount, 1); - QCOMPARE( - mutation.stage, - QStringLiteral("ReadingConfig")); - QCOMPARE( - mutation.outcome, - PrinterProtocol::MutationOutcome::Cancelled); - QVERIFY(error.contains( - QStringLiteral("cancel"), Qt::CaseInsensitive)); - QVERIFY(elapsed.elapsed() < 1000); -} - + QCOMPARE(captured.body_case(), + panorama::wire::v1::Request::kOverlayLayout); + QCOMPARE(captured.overlay_layout().label_groups_size(), 1); + const auto &group = captured.overlay_layout().label_groups(0); + QCOMPARE(group.group_id(), 100U); + QCOMPARE(group.group_x(), 60U); + QCOMPARE(group.group_y(), 440U); + QCOMPARE(group.group_width(), 2120U); + QCOMPARE(group.group_height(), 160U); + QCOMPARE(group.text_align(), panorama::wire::v1::OverlayGroup::ALIGN_LEFT); + QCOMPARE(group.line_gap(), -10); + QCOMPARE(group.labels_size(), 3); + QCOMPARE(group.labels(0).label_id(), 101U); + QCOMPARE(group.labels(0).line(), 1U); + QCOMPARE(group.labels(0).gap_left(), 13); + QCOMPARE(group.labels(0).text_size(), 30U); + QCOMPARE(group.labels(0).text_color(), 0xFF0000U); + QCOMPARE(QString::fromStdString(group.labels(0).text()), + QStringLiteral("CPU TEMP")); + QCOMPARE(group.labels(1).label_id(), 102U); + QCOMPARE(group.labels(1).text_size(), 160U); + QCOMPARE(QString::fromStdString(group.labels(1).text()), + QStringLiteral("--")); + QCOMPARE(group.labels(2).label_id(), 103U); + QCOMPARE(group.labels(2).text_size(), 36U); + QCOMPARE(QString::fromStdString(group.labels(2).text()), + QStringLiteral("°C")); +} + void PrinterProtocolTests:: -paseApplyReadOnlyRetryBudgetIsBounded() { +paseWaterfallFullScreenGeometry_data() { + QTest::addColumn("placement"); + QTest::addColumn("metricY"); + QTest::addColumn("badgeY"); + + QTest::newRow("top") + << QStringLiteral("Top") << 440U << 70U; + QTest::newRow("bottom") + << QStringLiteral("Bottom") << 1560U << 1190U; +} + +void PrinterProtocolTests:: +paseWaterfallFullScreenGeometry() { + QFETCH(QString, placement); + QFETCH(quint32, metricY); + QFETCH(quint32, badgeY); + int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - int queryCount = 0; - quint64 firstTrackId = 0; - quint64 secondTrackId = 0; + PrinterProtocol protocol; + const QString endpoint = + QStringLiteral("/dev/usb/lp-pase-waterfall-full"); + protocol.adoptFileDescriptorForTesting( + sockets[0], endpoint); + panorama::wire::v1::Request captured; QString peerError; std::thread peer([&]() { - for (int attempt = 0; attempt < 2; ++attempt) { - panorama::wire::v1::Request request; - if (!readRequest( - sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request:: - kUserConfigurationQuery) { - peerError = QStringLiteral( - "missing bounded read-only query attempt %1") - .arg(attempt + 1); - return; - } - ++queryCount; - if (attempt == 0) { - firstTrackId = - request.header().track_id(); - } else { - secondTrackId = - request.header().track_id(); - } - } - - if (!waitForPeerClosureWithoutPayload( - sockets[1], 600, &peerError)) { + if (!readRequest( + sockets[1], &captured, &peerError)) { return; } + auto response = baseResponse(captured); + response.mutable_acknowledgement(); + writeResponse(sockets[1], response, &peerError); }); - PrinterProtocol protocol(75); - const QString devicePath = - QStringLiteral( - "/dev/usb/lp-pase-idempotent-query-budget"); - protocol.adoptFileDescriptorForTesting( - sockets[0], devicePath); - - PrinterProtocol::PaseApplyConfig config; - config.media = { - QStringLiteral("left.h264"), - QStringLiteral("right.h264")}; - config.screenMode = - QStringLiteral("Screen Splitting"); - config.playMode = QStringLiteral("Single"); - config.mediaPresent = true; - + PrinterProtocol::PaseOverlayConfig overlay; + overlay.waterfallMode = true; + overlay.left.metrics = { + QStringLiteral("CPU Temperature")}; + overlay.left.badges = { + QStringLiteral("CPU Badge")}; + overlay.left.verticalPlacement = placement; + overlay.left.alignment = QStringLiteral("Right"); + overlay.left.textColor = 0xFF0000U; + overlay.cpuBadgeText = + QStringLiteral("AMD Ryzen 9 9950X3D"); QString error; - PrinterProtocol::MutationDetails mutation; - QElapsedTimer elapsed; - elapsed.start(); - const bool applied = - protocol.applyPaseConfiguration( - devicePath, config, &error, - PrinterProtocol::OperationContext{}, - &mutation); - + const bool sent = protocol.sendPaseRunConfigForTesting( + endpoint, overlay, &error, + PrinterProtocol::OperationContext{}); peer.join(); ::close(sockets[1]); - QVERIFY(!applied); + QVERIFY2(sent, qPrintable(error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(queryCount, 2); - QVERIFY(firstTrackId != 0); - QVERIFY(secondTrackId != 0); - QVERIFY(firstTrackId != secondTrackId); - QCOMPARE( - mutation.stage, - QStringLiteral("ReadingConfig")); - QCOMPARE( - mutation.outcome, - PrinterProtocol::MutationOutcome::NotStarted); - QVERIFY(error.contains( - QStringLiteral("Timed out"), Qt::CaseInsensitive)); - QVERIFY(elapsed.elapsed() < 1500); + QCOMPARE(captured.overlay_layout().label_groups_size(), 2); + const auto &metric = + captured.overlay_layout().label_groups(0); + const auto &badge = + captured.overlay_layout().label_groups(1); + QCOMPARE(metric.group_id(), 100U); + QCOMPARE(metric.group_x(), 60U); + QCOMPARE(metric.group_y(), metricY); + QCOMPARE(metric.group_width(), 950U); + QCOMPARE(metric.group_height(), 160U); + QCOMPARE(metric.text_align(), + panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); + QCOMPARE(metric.labels(0).text_color(), + 0xFF0000U); + QCOMPARE(badge.group_id(), 300U); + QCOMPARE(badge.group_x(), 70U); + QCOMPARE(badge.group_y(), badgeY); + QCOMPARE(badge.group_width(), 970U); + QCOMPARE(badge.text_align(), + panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); } void PrinterProtocolTests:: -paseApplyMismatchedReadOnlyResponseDoesNotRetry() { +paseWaterfallSplitGeometryAndIndependentStyles() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - int queryCount = 0; + PrinterProtocol protocol; + const QString endpoint = + QStringLiteral("/dev/usb/lp-pase-waterfall-split"); + protocol.adoptFileDescriptorForTesting( + sockets[0], endpoint); + panorama::wire::v1::Request captured; QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request request; if (!readRequest( - sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request:: - kUserConfigurationQuery) { - peerError = QStringLiteral( - "missing query before mismatched response"); + sockets[1], &captured, &peerError)) { return; } - ++queryCount; - auto response = baseResponse(request); + auto response = baseResponse(captured); response.mutable_acknowledgement(); - if (!writeResponse( - sockets[1], response, &peerError)) { - return; - } - - if (!waitForPeerClosureWithoutPayload( - sockets[1], 600, &peerError)) { - return; - } + writeResponse(sockets[1], response, &peerError); }); - PrinterProtocol protocol(75); - const QString devicePath = - QStringLiteral( - "/dev/usb/lp-pase-idempotent-query-invalid"); - protocol.adoptFileDescriptorForTesting( - sockets[0], devicePath); - - PrinterProtocol::PaseApplyConfig config; - config.media = { - QStringLiteral("left.h264"), - QStringLiteral("right.h264")}; - config.screenMode = - QStringLiteral("Screen Splitting"); - config.playMode = QStringLiteral("Single"); - config.mediaPresent = true; - + PrinterProtocol::PaseOverlayConfig overlay; + overlay.dualMode = true; + overlay.waterfallMode = true; + overlay.left.metrics = { + QStringLiteral("CPU Temperature")}; + overlay.left.badges = { + QStringLiteral("CPU Badge")}; + overlay.left.verticalPlacement = + QStringLiteral("Bottom"); + overlay.left.alignment = QStringLiteral("Right"); + overlay.left.textColor = 0xFF0000U; + overlay.right.metrics = { + QStringLiteral("GPU Power")}; + overlay.right.badges = { + QStringLiteral("GPU Badge")}; + overlay.right.verticalPlacement = + QStringLiteral("Top"); + overlay.right.alignment = QStringLiteral("Left"); + overlay.right.textColor = 0x00FF00U; + overlay.cpuBadgeText = + QStringLiteral("AMD Ryzen 9 9950X3D"); + overlay.gpuBadgeText = + QStringLiteral("AMD Radeon RX 7900 XTX"); QString error; - PrinterProtocol::MutationDetails mutation; - const bool applied = - protocol.applyPaseConfiguration( - devicePath, config, &error, - PrinterProtocol::OperationContext{}, - &mutation); - + const bool sent = protocol.sendPaseRunConfigForTesting( + endpoint, overlay, &error, + PrinterProtocol::OperationContext{}); peer.join(); ::close(sockets[1]); - QVERIFY(!applied); + QVERIFY2(sent, qPrintable(error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(queryCount, 1); - QCOMPARE( - mutation.stage, - QStringLiteral("ReadingConfig")); - QCOMPARE( - mutation.outcome, - PrinterProtocol::MutationOutcome::NotStarted); - QVERIFY(error.contains( - QStringLiteral("does not match"), - Qt::CaseInsensitive)); -} - -void PrinterProtocolTests::paseReadbackMismatchIsVerificationFailure() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), + const auto &run = captured.overlay_layout(); + QCOMPARE(run.label_groups_size(), 4); + const auto findGroup = [&run](quint32 groupId) + -> const panorama::wire::v1::OverlayGroup * { + for (int index = 0; + index < run.label_groups_size(); ++index) { + if (run.label_groups(index).group_id() == + groupId) { + return &run.label_groups(index); + } + } + return nullptr; + }; + const auto *leftMetric = findGroup(100); + const auto *leftBadge = findGroup(300); + const auto *rightMetric = findGroup(207); + const auto *rightBadge = findGroup(400); + QVERIFY(leftMetric); + QVERIFY(leftBadge); + QVERIFY(rightMetric); + QVERIFY(rightBadge); + + QCOMPARE(leftMetric->group_x(), 60U); + QCOMPARE(leftMetric->group_y(), 1560U); + QCOMPARE(leftMetric->group_width(), 950U); + QCOMPARE(leftMetric->text_align(), + panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); + QCOMPARE(leftMetric->labels(0).text_color(), + 0xFF0000U); + QCOMPARE(leftBadge->group_x(), 70U); + QCOMPARE(leftBadge->group_y(), 1190U); + QCOMPARE(leftBadge->group_width(), 970U); + QCOMPARE(leftBadge->text_align(), + panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); + + QCOMPARE(rightMetric->group_x(), 60U); + QCOMPARE(rightMetric->group_y(), 440U); + QCOMPARE(rightMetric->group_width(), 950U); + QCOMPARE(rightMetric->text_align(), + panorama::wire::v1::OverlayGroup::ALIGN_LEFT); + QCOMPARE(rightMetric->labels(0).text_color(), + 0x00FF00U); + QCOMPARE(rightBadge->group_x(), 70U); + QCOMPARE(rightBadge->group_y(), 70U); + QCOMPARE(rightBadge->group_width(), 970U); + QCOMPARE(rightBadge->text_align(), + panorama::wire::v1::OverlayGroup::ALIGN_LEFT); +} + +void PrinterProtocolTests::paseSplitApplyBuildsDualUserConfigAndBadges() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + panorama::wire::v1::Request capturedUserConfig; + panorama::wire::v1::Request capturedRunConfig; QString peerError; std::thread peer([&]() { panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, - &peerError) || + if (!readRequest(sockets[1], &getRequest, &peerError) || getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { peerError = QStringLiteral( - "verification test did not read initial user config"); + "split apply did not read user config"); return; } auto getResponse = baseResponse(getRequest); - auto *initial = - getResponse.mutable_user_configuration(); - initial->mutable_display_config() - ->set_backlight_brightness(50); - initial->mutable_standby_config() + auto *userConfig = getResponse.mutable_user_configuration(); + userConfig->mutable_display_config() + ->set_backlight_brightness(55); + userConfig->mutable_work_config() + ->set_single_mode_media_file("old.h264"); + userConfig->mutable_standby_config() ->set_media_file("standby.h264"); - auto *initialWork = - initial->mutable_work_config(); - initialWork->set_media_mode( - panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); - initialWork->set_loop_mode( - panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); - initialWork->set_single_mode_media_file( - "old.h264"); - if (!writeResponse(sockets[1], getResponse, - &peerError)) { + if (!writeResponse(sockets[1], getResponse, &peerError)) { return; } - panorama::wire::v1::Request userRequest; - if (!readRequest(sockets[1], &userRequest, + if (!readRequest(sockets[1], &capturedUserConfig, &peerError) || - userRequest.body_case() != + capturedUserConfig.body_case() != panorama::wire::v1::Request::kUserConfiguration) { peerError = QStringLiteral( - "verification test did not write user config"); + "split apply did not send user config"); return; } - auto userResponse = baseResponse(userRequest); + auto userResponse = baseResponse(capturedUserConfig); userResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], userResponse, - &peerError)) { + if (!writeResponse(sockets[1], userResponse, &peerError)) { return; } - panorama::wire::v1::Request runRequest; - if (!readRequest(sockets[1], &runRequest, + if (!readRequest(sockets[1], &capturedRunConfig, &peerError) || - runRequest.body_case() != + capturedRunConfig.body_case() != panorama::wire::v1::Request::kOverlayLayout) { peerError = QStringLiteral( - "verification test did not write run config"); + "split apply did not send run config"); return; } @@ -2454,346 +3434,444 @@ void PrinterProtocolTests::paseReadbackMismatchIsVerificationFailure() { readbackRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { peerError = QStringLiteral( - "verification test did not request readback"); + "split apply did not verify user config"); return; } auto readbackResponse = baseResponse(readbackRequest); - auto *readback = - readbackResponse.mutable_user_configuration(); - readback->mutable_display_config() - ->set_backlight_brightness(50); - readback->mutable_standby_config() - ->set_media_file("standby.h264"); - auto *readbackWork = - readback->mutable_work_config(); - readbackWork->set_media_mode( - panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); - readbackWork->set_loop_mode( - panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); - readbackWork->set_single_mode_media_file( - "old.h264"); + *readbackResponse.mutable_user_configuration() = + capturedUserConfig.user_configuration(); writeResponse( sockets[1], readbackResponse, &peerError); }); PrinterProtocol protocol(500); const QString devicePath = - QStringLiteral("/dev/usb/lp-pase-readback-mismatch"); - protocol.adoptFileDescriptorForTesting( - sockets[0], devicePath); + QStringLiteral("/dev/usb/lp-pase-split"); + protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); PrinterProtocol::PaseApplyConfig config; config.media = {QStringLiteral("left.h264"), QStringLiteral("right.h264")}; - config.screenMode = - QStringLiteral("Screen Splitting"); + config.screenMode = QStringLiteral("Screen Splitting"); config.playMode = QStringLiteral("Single"); config.mediaPresent = true; + config.replaceOverlay = true; + config.overlay.dualMode = true; + config.overlay.left.metrics = { + QStringLiteral("CPU Temperature")}; + config.overlay.left.badges = { + QStringLiteral("CPU Badge")}; + config.overlay.right.metrics = { + QStringLiteral("GPU Power")}; + config.overlay.right.badges = { + QStringLiteral("GPU Badge")}; + config.overlay.cpuBadgeText = + QStringLiteral("AMD Ryzen 9 9950X3D"); + config.overlay.gpuBadgeText = + QStringLiteral("NVIDIA GeForce RTX"); QString error; - PrinterProtocol::MutationDetails mutation; PrinterProtocol::PaseDisplayState appliedState; - appliedState.screenMode = - QStringLiteral("sentinel"); const bool applied = protocol.applyPaseConfiguration( devicePath, config, &error, - PrinterProtocol::OperationContext{}, &mutation, + PrinterProtocol::OperationContext{}, nullptr, &appliedState); peer.join(); ::close(sockets[1]); - QVERIFY(!applied); - QCOMPARE( - mutation.outcome, - PrinterProtocol::MutationOutcome::VerificationFailed); - QCOMPARE(mutation.stage, - QStringLiteral("VerifyingConfig")); - QVERIFY(error.contains( - QStringLiteral("screen mode"), - Qt::CaseInsensitive)); - QCOMPARE(appliedState.screenMode, - QStringLiteral("Full Screen")); - QCOMPARE( - appliedState.media, - QStringList{QStringLiteral("old.h264")}); + QVERIFY2(applied, qPrintable(error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + const auto &work = capturedUserConfig.user_configuration().work_config(); + QCOMPARE(work.media_mode(), + panorama::wire::v1::WorkConfiguration::MEDIA_DUAL); + QCOMPARE(work.loop_mode(), + panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); + QCOMPARE(QString::fromStdString( + work.dual_mode_left_media_file()), + QStringLiteral("left.h264")); + QCOMPARE(QString::fromStdString( + work.dual_mode_right_media_file()), + QStringLiteral("right.h264")); + QCOMPARE(appliedState.screenMode, + QStringLiteral("Screen Splitting")); + QCOMPARE(appliedState.media, config.media); + + const auto &run = capturedRunConfig.overlay_layout(); + QCOMPARE(run.label_groups_size(), 4); + const auto findGroup = [&run](quint32 groupId) + -> const panorama::wire::v1::OverlayGroup * { + for (int index = 0; index < run.label_groups_size(); ++index) { + if (run.label_groups(index).group_id() == groupId) { + return &run.label_groups(index); + } + } + return nullptr; + }; + const auto *leftMetric = findGroup(100); + const auto *leftBadge = findGroup(300); + const auto *rightMetric = findGroup(207); + const auto *rightBadge = findGroup(400); + QVERIFY(leftMetric); + QVERIFY(leftBadge); + QVERIFY(rightMetric); + QVERIFY(rightBadge); + QCOMPARE(leftMetric->labels(0).label_id(), 101U); + QCOMPARE(rightMetric->labels(0).label_id(), 222U); + QCOMPARE(leftBadge->labels_size(), 1); + QCOMPARE(leftBadge->labels(0).label_id(), 301U); + QCOMPARE(leftBadge->labels(0).background(), + panorama::wire::v1::OverlayLabel::BACKGROUND_GRADIENT_HORIZONTAL); + QCOMPARE(leftBadge->labels(0).background_color(), 0x00A92F2CU); + QCOMPARE(leftBadge->labels(0).gradient_color(), 0x00CB6236U); + QCOMPARE(rightBadge->labels_size(), 1); + QCOMPARE(rightBadge->labels(0).label_id(), 402U); + QCOMPARE(rightBadge->labels(0).background_color(), 0x00629A00U); + QCOMPARE(rightBadge->labels(0).gradient_color(), 0x0079AB51U); } void PrinterProtocolTests:: -runConfigRejectionIsVerificationFailureAndKeepsTransport() { +paseApplyRetriesDroppedReadOnlyConfigResponse_data() { + QTest::addColumn("dropPreflightResponse"); + QTest::addColumn("sendLateDroppedResponse"); + QTest::addColumn("sendLateBeforeRetryRequest"); + + QTest::newRow("preflight-clean-timeout") + << true << false << false; + QTest::newRow("preflight-late-after-retry") + << true << true << false; + QTest::newRow("preflight-late-during-backoff") + << true << true << true; + QTest::newRow("verification-clean-timeout") + << false << false << false; +} + +void PrinterProtocolTests:: +paseApplyRetriesDroppedReadOnlyConfigResponse() { + QFETCH(bool, dropPreflightResponse); + QFETCH(bool, sendLateDroppedResponse); + QFETCH(bool, sendLateBeforeRetryRequest); + int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - panorama::wire::v1::Request capturedUserConfig; + quint64 droppedTrackId = 0; + quint64 retryTrackId = 0; + int userConfigurationWrites = 0; + int overlayWrites = 0; QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request getRequest; - if (!readRequest( - sockets[1], &getRequest, &peerError) || - getRequest.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral( - "RunConfig rejection test did not read user config"); - return; - } - auto getResponse = baseResponse(getRequest); - auto *initial = - getResponse.mutable_user_configuration(); - initial->mutable_display_config() - ->set_backlight_brightness(50); - initial->mutable_standby_config() - ->set_media_file("standby.h264"); - auto *work = initial->mutable_work_config(); - work->set_media_mode( - panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); - work->set_loop_mode( - panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); - work->set_single_mode_media_file("old.h264"); - if (!writeResponse( - sockets[1], getResponse, &peerError)) { - return; - } + const auto writeInitialConfiguration = + [&](const panorama::wire::v1::Request &request) { + auto response = baseResponse(request); + auto *configuration = + response.mutable_user_configuration(); + configuration->mutable_display_config() + ->set_backlight_brightness(55); + auto *work = + configuration->mutable_work_config(); + work->set_media_mode( + panorama::wire::v1::WorkConfiguration:: + MEDIA_SINGLE); + work->set_loop_mode( + panorama::wire::v1::WorkConfiguration:: + LOOP_SINGLE); + work->set_single_mode_media_file( + "old.h264"); + return writeResponse( + sockets[1], response, &peerError); + }; + panorama::wire::v1::Request preflightRequest; if (!readRequest( - sockets[1], &capturedUserConfig, - &peerError) || - capturedUserConfig.body_case() != - panorama::wire::v1::Request::kUserConfiguration) { + sockets[1], &preflightRequest, &peerError) || + preflightRequest.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { peerError = QStringLiteral( - "RunConfig rejection test did not write user config"); + "missing first user configuration preflight"); return; } - auto userResponse = - baseResponse(capturedUserConfig); - userResponse.mutable_acknowledgement(); - if (!writeResponse( - sockets[1], userResponse, &peerError)) { + + if (dropPreflightResponse) { + droppedTrackId = + preflightRequest.header().track_id(); + if (sendLateBeforeRetryRequest) { + QElapsedTimer delay; + delay.start(); + while (delay.elapsed() < 175) { + const int remaining = + 175 - static_cast( + delay.elapsed()); + const int result = + ::poll( + nullptr, 0, + qMax(1, remaining)); + if (result < 0 && errno != EINTR) { + peerError = QStringLiteral( + "late response delay failed"); + return; + } + } + if (!writeInitialConfiguration( + preflightRequest)) { + return; + } + } + panorama::wire::v1::Request retryRequest; + if (!readRequest( + sockets[1], &retryRequest, &peerError) || + retryRequest.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { + peerError = QStringLiteral( + "missing retried user configuration preflight"); + return; + } + retryTrackId = retryRequest.header().track_id(); + if (sendLateDroppedResponse && + !sendLateBeforeRetryRequest && + !writeInitialConfiguration(preflightRequest)) { + return; + } + if (!writeInitialConfiguration(retryRequest)) { + return; + } + } else if (!writeInitialConfiguration( + preflightRequest)) { return; } - panorama::wire::v1::Request runRequest; + panorama::wire::v1::Request userRequest; if (!readRequest( - sockets[1], &runRequest, &peerError) || - runRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { + sockets[1], &userRequest, &peerError) || + userRequest.body_case() != + panorama::wire::v1::Request:: + kUserConfiguration) { peerError = QStringLiteral( - "RunConfig rejection test did not receive activation"); + "missing single user configuration mutation"); return; } - auto runResponse = baseResponse(runRequest); - runResponse.mutable_error()->set_code( - panorama::wire::v1::ProtocolError::FAILURE); - runResponse.mutable_error()->set_why( - "overlay rejected"); + ++userConfigurationWrites; + auto acknowledgement = baseResponse(userRequest); + acknowledgement.mutable_acknowledgement(); if (!writeResponse( - sockets[1], runResponse, &peerError)) { + sockets[1], acknowledgement, &peerError)) { return; } - panorama::wire::v1::Request readbackRequest; + panorama::wire::v1::Request overlayRequest; if (!readRequest( - sockets[1], &readbackRequest, &peerError) || - readbackRequest.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { + sockets[1], &overlayRequest, &peerError) || + overlayRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { peerError = QStringLiteral( - "RunConfig rejection test did not read back user config"); - return; - } - auto readbackResponse = - baseResponse(readbackRequest); - *readbackResponse.mutable_user_configuration() = - capturedUserConfig.user_configuration(); - if (!writeResponse( - sockets[1], readbackResponse, - &peerError)) { + "missing single overlay activation"); return; } + ++overlayWrites; - panorama::wire::v1::Request fileListRequest; + const auto writeAppliedConfiguration = + [&](const panorama::wire::v1::Request &request) { + auto response = baseResponse(request); + *response.mutable_user_configuration() = + userRequest.user_configuration(); + return writeResponse( + sockets[1], response, &peerError); + }; + + panorama::wire::v1::Request verificationRequest; if (!readRequest( - sockets[1], &fileListRequest, &peerError) || - fileListRequest.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery) { + sockets[1], &verificationRequest, + &peerError) || + verificationRequest.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { peerError = QStringLiteral( - "RunConfig rejection closed a healthy transport"); + "missing first user configuration verification"); return; } - auto fileListResponse = - baseResponse(fileListRequest); - fileListResponse.mutable_media_catalog(); - writeResponse( - sockets[1], fileListResponse, &peerError); + + if (!dropPreflightResponse) { + droppedTrackId = + verificationRequest.header().track_id(); + panorama::wire::v1::Request retryRequest; + if (!readRequest( + sockets[1], &retryRequest, &peerError) || + retryRequest.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { + peerError = QStringLiteral( + "missing retried user configuration verification"); + return; + } + retryTrackId = retryRequest.header().track_id(); + if (!writeAppliedConfiguration(retryRequest)) { + return; + } + } else { + if (!writeAppliedConfiguration(verificationRequest)) { + return; + } + } }); - PrinterProtocol protocol(500); - const QString endpoint = + PrinterProtocol protocol(75); + const QString devicePath = QStringLiteral( - "/dev/usb/lp-pase-runconfig-rejected"); + "/dev/usb/lp-pase-idempotent-query-retry"); protocol.adoptFileDescriptorForTesting( - sockets[0], endpoint); + sockets[0], devicePath); + PrinterProtocol::PaseApplyConfig config; - config.display.brightnessPresent = true; - config.display.brightness = 77; + config.media = { + QStringLiteral("left.h264"), + QStringLiteral("right.h264")}; + config.screenMode = + QStringLiteral("Screen Splitting"); + config.playMode = QStringLiteral("Single"); + config.mediaPresent = true; + config.replaceOverlay = true; + config.overlay.dualMode = true; + QString error; PrinterProtocol::MutationDetails mutation; PrinterProtocol::PaseDisplayState appliedState; - QVERIFY(!protocol.applyPaseConfiguration( - endpoint, config, &error, - PrinterProtocol::OperationContext{}, &mutation, - &appliedState)); - QCOMPARE( - mutation.outcome, - PrinterProtocol::MutationOutcome::VerificationFailed); - QCOMPARE(mutation.stage, - QStringLiteral("VerifyingConfig")); - QCOMPARE(appliedState.brightness, 77); - QVERIFY(error.contains( - QStringLiteral("overlay activation"), - Qt::CaseInsensitive)); - - const PrinterProtocol::MediaListResult fileList = - protocol.readMediaList( - endpoint, - PrinterProtocol::OperationContext{}); - QVERIFY2(fileList.success, - qPrintable(fileList.error)); + const bool applied = + protocol.applyPaseConfiguration( + devicePath, config, &error, + PrinterProtocol::OperationContext{}, + &mutation, &appliedState); peer.join(); ::close(sockets[1]); + + QVERIFY2(applied, qPrintable(error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY(droppedTrackId != 0); + QVERIFY(retryTrackId != 0); + QVERIFY(droppedTrackId != retryTrackId); + QCOMPARE(userConfigurationWrites, 1); + QCOMPARE(overlayWrites, 1); + QCOMPARE( + mutation.outcome, + PrinterProtocol::MutationOutcome::Succeeded); + QCOMPARE(appliedState.screenMode, + QStringLiteral("Screen Splitting")); + QCOMPARE(appliedState.media, config.media); } void PrinterProtocolTests:: -verificationFailureKeepsHealthySessionActive() { +paseApplyReadOnlyRetryCancellationSendsNoMutation() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + const int cancellationFd = + ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + QVERIFY(cancellationFd >= 0); + int queryCount = 0; QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, - &peerError)) { - return; - } - auto getResponse = baseResponse(getRequest); - auto *initial = - getResponse.mutable_user_configuration(); - initial->mutable_display_config(); - auto *initialWork = - initial->mutable_work_config(); - initialWork->set_media_mode( - panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); - initialWork->set_loop_mode( - panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); - initialWork->set_single_mode_media_file( - "old.h264"); - if (!writeResponse(sockets[1], getResponse, - &peerError)) { + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { + peerError = QStringLiteral( + "missing user configuration query before retry cancellation"); return; } + ++queryCount; - panorama::wire::v1::Request userRequest; - if (!readRequest(sockets[1], &userRequest, - &peerError)) { - return; - } - auto userResponse = baseResponse(userRequest); - userResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], userResponse, - &peerError)) { - return; + QElapsedTimer delay; + delay.start(); + while (delay.elapsed() < 150) { + const int remaining = + 150 - static_cast(delay.elapsed()); + const int result = + ::poll(nullptr, 0, qMax(1, remaining)); + if (result < 0 && errno != EINTR) { + peerError = QStringLiteral( + "retry cancellation delay failed"); + return; + } } - panorama::wire::v1::Request runRequest; - if (!readRequest(sockets[1], &runRequest, - &peerError)) { + const uint64_t cancellationValue = 1; + if (::write( + cancellationFd, &cancellationValue, + sizeof(cancellationValue)) != + static_cast( + sizeof(cancellationValue))) { + peerError = QStringLiteral( + "failed to cancel read-only retry backoff"); return; } - panorama::wire::v1::Request readbackRequest; - if (!readRequest(sockets[1], &readbackRequest, - &peerError)) { + if (!waitForPeerClosureWithoutPayload( + sockets[1], 600, &peerError)) { return; } - auto readbackResponse = - baseResponse(readbackRequest); - auto *readback = - readbackResponse.mutable_user_configuration(); - readback->mutable_display_config(); - auto *readbackWork = - readback->mutable_work_config(); - readbackWork->set_media_mode( - panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); - readbackWork->set_loop_mode( - panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); - readbackWork->set_single_mode_media_file( - "old.h264"); - writeResponse( - sockets[1], readbackResponse, &peerError); }); - constexpr quint64 generation = 44; - const QString endpoint = - QStringLiteral("test-endpoint"); - DeviceWorker worker; - worker.updatePrinterGenerationGate(generation, true); - worker.configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), - generation); - worker.adoptPrinterFileDescriptorForTesting( - sockets[0], endpoint); - worker.printerSessionState_ = - DeviceWorker::PrinterSessionState::Active; - worker.printerRecoveryTimer_->stop(); + PrinterProtocol protocol(75); + const QString devicePath = + QStringLiteral( + "/dev/usb/lp-pase-idempotent-query-cancel"); + protocol.adoptFileDescriptorForTesting( + sockets[0], devicePath); - TryxRuntimeApplyRequest request; - request.media = {QStringLiteral("left.h264"), - QStringLiteral("right.h264")}; - request.screenMode = + PrinterProtocol::PaseApplyConfig config; + config.media = { + QStringLiteral("left.h264"), + QStringLiteral("right.h264")}; + config.screenMode = QStringLiteral("Screen Splitting"); - request.playMode = QStringLiteral("Single"); - QSignalSpy applySpy( - &worker, &DeviceWorker::printerApplyFinished); - QSignalSpy displaySpy( - &worker, &DeviceWorker::printerDisplayStateReady); - worker.applyPrinterMedia( - endpoint, QString(), request, false, - QStringLiteral( - "44444444-4444-4444-8444-444444444444"), - generation); + config.playMode = QStringLiteral("Single"); + config.mediaPresent = true; + + PrinterProtocol::OperationContext context; + context.cancellationFd = cancellationFd; + QString error; + PrinterProtocol::MutationDetails mutation; + QElapsedTimer elapsed; + elapsed.start(); + const bool applied = + protocol.applyPaseConfiguration( + devicePath, config, &error, context, + &mutation); peer.join(); + ::close(cancellationFd); ::close(sockets[1]); + + QVERIFY(!applied); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(applySpy.count(), 1); - QCOMPARE(displaySpy.count(), 1); - const PrinterProtocol::PaseDisplayState actualState = - qvariant_cast( - displaySpy.first().at(0)); - QCOMPARE(actualState.screenMode, - QStringLiteral("Full Screen")); - QCOMPARE(applySpy.first().at(2).toBool(), false); + QCOMPARE(queryCount, 1); QCOMPARE( - qvariant_cast( - applySpy.first().at(4)), - PrinterProtocol::MutationOutcome::VerificationFailed); + mutation.stage, + QStringLiteral("ReadingConfig")); QCOMPARE( - worker.printerSessionState_, - DeviceWorker::PrinterSessionState::Active); - QVERIFY(!worker.printerRecoveryTimer_->isActive()); + mutation.outcome, + PrinterProtocol::MutationOutcome::Cancelled); + QVERIFY(error.contains( + QStringLiteral("cancel"), Qt::CaseInsensitive)); + QVERIFY(elapsed.elapsed() < 1000); } void PrinterProtocolTests:: -applyFailureResultPrecedesSessionLoss() { +paseApplyReadOnlyRetryBudgetIsBounded() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); int queryCount = 0; + quint64 firstTrackId = 0; + quint64 secondTrackId = 0; QString peerError; std::thread peer([&]() { for (int attempt = 0; attempt < 2; ++attempt) { @@ -2804,106 +3882,179 @@ applyFailureResultPrecedesSessionLoss() { panorama::wire::v1::Request:: kUserConfigurationQuery) { peerError = QStringLiteral( - "missing read-only apply query %1 before session loss") + "missing bounded read-only query attempt %1") .arg(attempt + 1); return; } ++queryCount; + if (attempt == 0) { + firstTrackId = + request.header().track_id(); + } else { + secondTrackId = + request.header().track_id(); + } } - }); - constexpr quint64 generation = 45; - const QString endpoint = - QStringLiteral("test-endpoint"); - DeviceWorker worker; - worker.updatePrinterGenerationGate(generation, true); - worker.configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), - generation); - worker.printerProtocol_ = - std::make_unique(75); - worker.adoptPrinterFileDescriptorForTesting( - sockets[0], endpoint); - worker.printerSessionState_ = - DeviceWorker::PrinterSessionState::Active; + if (!waitForPeerClosureWithoutPayload( + sockets[1], 600, &peerError)) { + return; + } + }); - QStringList terminalEvents; - connect( - &worker, &DeviceWorker::printerApplyFinished, - &worker, - [&terminalEvents]( - const QString &, const QString &, bool, bool, - PrinterProtocol::MutationOutcome, - const QString &, quint64) { - terminalEvents.append( - QStringLiteral("apply-finished")); - }); - connect( - &worker, &DeviceWorker::printerSessionLost, - &worker, - [&terminalEvents](quint64) { - terminalEvents.append( - QStringLiteral("session-lost")); - }); - QSignalSpy applySpy( - &worker, &DeviceWorker::printerApplyFinished); - QSignalSpy lostSpy( - &worker, &DeviceWorker::printerSessionLost); + PrinterProtocol protocol(75); + const QString devicePath = + QStringLiteral( + "/dev/usb/lp-pase-idempotent-query-budget"); + protocol.adoptFileDescriptorForTesting( + sockets[0], devicePath); - TryxRuntimeApplyRequest request; - request.media = { + PrinterProtocol::PaseApplyConfig config; + config.media = { QStringLiteral("left.h264"), QStringLiteral("right.h264")}; - request.screenMode = + config.screenMode = QStringLiteral("Screen Splitting"); - request.playMode = QStringLiteral("Single"); - worker.applyPrinterMedia( - endpoint, QString(), request, false, - QStringLiteral( - "45454545-4545-4545-8545-454545454545"), - generation); + config.playMode = QStringLiteral("Single"); + config.mediaPresent = true; + + QString error; + PrinterProtocol::MutationDetails mutation; + QElapsedTimer elapsed; + elapsed.start(); + const bool applied = + protocol.applyPaseConfiguration( + devicePath, config, &error, + PrinterProtocol::OperationContext{}, + &mutation); peer.join(); ::close(sockets[1]); + QVERIFY(!applied); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); QCOMPARE(queryCount, 2); - QCOMPARE(applySpy.count(), 1); - QCOMPARE(lostSpy.count(), 1); - QCOMPARE( - qvariant_cast( - applySpy.first().at(4)), - PrinterProtocol::MutationOutcome::NotStarted); + QVERIFY(firstTrackId != 0); + QVERIFY(secondTrackId != 0); + QVERIFY(firstTrackId != secondTrackId); QCOMPARE( - terminalEvents, - QStringList({ - QStringLiteral("apply-finished"), - QStringLiteral("session-lost")})); + mutation.stage, + QStringLiteral("ReadingConfig")); QCOMPARE( - worker.printerSessionState_, - DeviceWorker::PrinterSessionState::Lost); + mutation.outcome, + PrinterProtocol::MutationOutcome::NotStarted); + QVERIFY(error.contains( + QStringLiteral("Timed out"), Qt::CaseInsensitive)); + QVERIFY(elapsed.elapsed() < 1500); } -void PrinterProtocolTests::lateRunConfigDummyDoesNotBreakReadback() { +void PrinterProtocolTests:: +paseApplyMismatchedReadOnlyResponseDoesNotRetry() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + int queryCount = 0; QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request getRequest; + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { + peerError = QStringLiteral( + "missing query before mismatched response"); + return; + } + ++queryCount; + auto response = baseResponse(request); + response.mutable_acknowledgement(); + if (!writeResponse( + sockets[1], response, &peerError)) { + return; + } + + if (!waitForPeerClosureWithoutPayload( + sockets[1], 600, &peerError)) { + return; + } + }); + + PrinterProtocol protocol(75); + const QString devicePath = + QStringLiteral( + "/dev/usb/lp-pase-idempotent-query-invalid"); + protocol.adoptFileDescriptorForTesting( + sockets[0], devicePath); + + PrinterProtocol::PaseApplyConfig config; + config.media = { + QStringLiteral("left.h264"), + QStringLiteral("right.h264")}; + config.screenMode = + QStringLiteral("Screen Splitting"); + config.playMode = QStringLiteral("Single"); + config.mediaPresent = true; + + QString error; + PrinterProtocol::MutationDetails mutation; + const bool applied = + protocol.applyPaseConfiguration( + devicePath, config, &error, + PrinterProtocol::OperationContext{}, + &mutation); + + peer.join(); + ::close(sockets[1]); + + QVERIFY(!applied); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QCOMPARE(queryCount, 1); + QCOMPARE( + mutation.stage, + QStringLiteral("ReadingConfig")); + QCOMPARE( + mutation.outcome, + PrinterProtocol::MutationOutcome::NotStarted); + QVERIFY(error.contains( + QStringLiteral("does not match"), + Qt::CaseInsensitive)); +} + +void PrinterProtocolTests::paseReadbackMismatchIsVerificationFailure() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); + + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request getRequest; if (!readRequest(sockets[1], &getRequest, - &peerError)) { + &peerError) || + getRequest.body_case() != + panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral( + "verification test did not read initial user config"); return; } auto getResponse = baseResponse(getRequest); auto *initial = getResponse.mutable_user_configuration(); - initial->mutable_display_config(); - initial->mutable_standby_config(); - initial->mutable_work_config() - ->set_single_mode_media_file("old.h264"); + initial->mutable_display_config() + ->set_backlight_brightness(50); + initial->mutable_standby_config() + ->set_media_file("standby.h264"); + auto *initialWork = + initial->mutable_work_config(); + initialWork->set_media_mode( + panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); + initialWork->set_loop_mode( + panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); + initialWork->set_single_mode_media_file( + "old.h264"); if (!writeResponse(sockets[1], getResponse, &peerError)) { return; @@ -2911,7 +4062,11 @@ void PrinterProtocolTests::lateRunConfigDummyDoesNotBreakReadback() { panorama::wire::v1::Request userRequest; if (!readRequest(sockets[1], &userRequest, - &peerError)) { + &peerError) || + userRequest.body_case() != + panorama::wire::v1::Request::kUserConfiguration) { + peerError = QStringLiteral( + "verification test did not write user config"); return; } auto userResponse = baseResponse(userRequest); @@ -2923,46 +4078,60 @@ void PrinterProtocolTests::lateRunConfigDummyDoesNotBreakReadback() { panorama::wire::v1::Request runRequest; if (!readRequest(sockets[1], &runRequest, - &peerError)) { + &peerError) || + runRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + peerError = QStringLiteral( + "verification test did not write run config"); return; } + panorama::wire::v1::Request readbackRequest; if (!readRequest(sockets[1], &readbackRequest, &peerError) || readbackRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { peerError = QStringLiteral( - "late Dummy test did not receive readback request"); - return; - } - - auto lateDummy = baseResponse(runRequest); - lateDummy.mutable_acknowledgement(); - if (!writeResponse(sockets[1], lateDummy, - &peerError)) { + "verification test did not request readback"); return; } auto readbackResponse = baseResponse(readbackRequest); - *readbackResponse.mutable_user_configuration() = - userRequest.user_configuration(); + auto *readback = + readbackResponse.mutable_user_configuration(); + readback->mutable_display_config() + ->set_backlight_brightness(50); + readback->mutable_standby_config() + ->set_media_file("standby.h264"); + auto *readbackWork = + readback->mutable_work_config(); + readbackWork->set_media_mode( + panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); + readbackWork->set_loop_mode( + panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); + readbackWork->set_single_mode_media_file( + "old.h264"); writeResponse( sockets[1], readbackResponse, &peerError); }); PrinterProtocol protocol(500); const QString devicePath = - QStringLiteral("/dev/usb/lp-pase-late-dummy"); + QStringLiteral("/dev/usb/lp-pase-readback-mismatch"); protocol.adoptFileDescriptorForTesting( sockets[0], devicePath); PrinterProtocol::PaseApplyConfig config; - config.media = {QStringLiteral("new.h264")}; - config.screenMode = QStringLiteral("Full Screen"); + config.media = {QStringLiteral("left.h264"), + QStringLiteral("right.h264")}; + config.screenMode = + QStringLiteral("Screen Splitting"); config.playMode = QStringLiteral("Single"); config.mediaPresent = true; QString error; PrinterProtocol::MutationDetails mutation; PrinterProtocol::PaseDisplayState appliedState; + appliedState.screenMode = + QStringLiteral("sentinel"); const bool applied = protocol.applyPaseConfiguration( devicePath, config, &error, PrinterProtocol::OperationContext{}, &mutation, @@ -2970,35 +4139,25 @@ void PrinterProtocolTests::lateRunConfigDummyDoesNotBreakReadback() { peer.join(); ::close(sockets[1]); - QVERIFY2(applied, qPrintable(error)); - QCOMPARE(mutation.outcome, - PrinterProtocol::MutationOutcome::Succeeded); - QCOMPARE(appliedState.media, config.media); + QVERIFY(!applied); + QCOMPARE( + mutation.outcome, + PrinterProtocol::MutationOutcome::VerificationFailed); + QCOMPARE(mutation.stage, + QStringLiteral("VerifyingConfig")); + QVERIFY(error.contains( + QStringLiteral("screen mode"), + Qt::CaseInsensitive)); + QCOMPARE(appliedState.screenMode, + QStringLiteral("Full Screen")); + QCOMPARE( + appliedState.media, + QStringList{QStringLiteral("old.h264")}); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::paseDisplayMutationMatrix_data() { - QTest::addColumn("mirrorMode"); - QTest::addColumn("waterfallMode"); - QTest::addColumn("expectedUiRotation"); - QTest::addColumn("expectedMediaRotation"); - - QTest::newRow("normal") - << false << false << 0U << 0U; - QTest::newRow("mirror") - << true << false << 0U << 180U; - QTest::newRow("waterfall") - << false << true << 90U << 0U; - QTest::newRow("mirror-waterfall") - << true << true << 90U << 180U; -} - -void PrinterProtocolTests::paseDisplayMutationMatrix() { - QFETCH(bool, mirrorMode); - QFETCH(bool, waterfallMode); - QFETCH(quint32, expectedUiRotation); - QFETCH(quint32, expectedMediaRotation); - +void PrinterProtocolTests:: +runConfigRejectionIsVerificationFailureAndKeepsTransport() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), @@ -3008,136 +4167,143 @@ void PrinterProtocolTests::paseDisplayMutationMatrix() { QString peerError; std::thread peer([&]() { panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, &peerError) || + if (!readRequest( + sockets[1], &getRequest, &peerError) || getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { peerError = QStringLiteral( - "display mutation did not read user config"); + "RunConfig rejection test did not read user config"); return; } auto getResponse = baseResponse(getRequest); - auto *userConfig = getResponse.mutable_user_configuration(); - auto *display = userConfig->mutable_display_config(); - display->set_backlight_enable(true); - display->set_backlight_brightness(21); - display->set_mirror(true); - display->set_ui_rotation(270); - display->set_media_rotation(90); - auto *standby = userConfig->mutable_standby_config(); - standby->set_enable(true); - standby->set_media_file("keep-standby.h264"); - auto *work = userConfig->mutable_work_config(); + auto *initial = + getResponse.mutable_user_configuration(); + initial->mutable_display_config() + ->set_backlight_brightness(50); + initial->mutable_standby_config() + ->set_media_file("standby.h264"); + auto *work = initial->mutable_work_config(); work->set_media_mode( panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); work->set_loop_mode( - panorama::wire::v1::WorkConfiguration::LOOP_ALL); - work->set_single_mode_media_file("keep-media.h264"); - userConfig->GetReflection() - ->MutableUnknownFields(userConfig) - ->AddVarint(199, 42); - if (!writeResponse(sockets[1], getResponse, &peerError)) { + panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); + work->set_single_mode_media_file("old.h264"); + if (!writeResponse( + sockets[1], getResponse, &peerError)) { return; } - if (!readRequest(sockets[1], &capturedUserConfig, - &peerError) || + if (!readRequest( + sockets[1], &capturedUserConfig, + &peerError) || capturedUserConfig.body_case() != panorama::wire::v1::Request::kUserConfiguration) { peerError = QStringLiteral( - "display mutation did not send user config"); + "RunConfig rejection test did not write user config"); return; } - auto userResponse = baseResponse(capturedUserConfig); + auto userResponse = + baseResponse(capturedUserConfig); userResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], userResponse, &peerError)) { + if (!writeResponse( + sockets[1], userResponse, &peerError)) { return; } panorama::wire::v1::Request runRequest; - if (!readRequest(sockets[1], &runRequest, &peerError) || + if (!readRequest( + sockets[1], &runRequest, &peerError) || runRequest.body_case() != panorama::wire::v1::Request::kOverlayLayout) { peerError = QStringLiteral( - "display mutation did not activate run config"); + "RunConfig rejection test did not receive activation"); return; } auto runResponse = baseResponse(runRequest); - runResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], runResponse, - &peerError)) { + runResponse.mutable_error()->set_code( + panorama::wire::v1::ProtocolError::FAILURE); + runResponse.mutable_error()->set_why( + "overlay rejected"); + if (!writeResponse( + sockets[1], runResponse, &peerError)) { return; } panorama::wire::v1::Request readbackRequest; - if (!readRequest(sockets[1], &readbackRequest, - &peerError) || + if (!readRequest( + sockets[1], &readbackRequest, &peerError) || readbackRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { peerError = QStringLiteral( - "display mutation did not verify user config"); + "RunConfig rejection test did not read back user config"); return; } auto readbackResponse = baseResponse(readbackRequest); *readbackResponse.mutable_user_configuration() = capturedUserConfig.user_configuration(); - writeResponse( - sockets[1], readbackResponse, &peerError); - }); - + if (!writeResponse( + sockets[1], readbackResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request fileListRequest; + if (!readRequest( + sockets[1], &fileListRequest, &peerError) || + fileListRequest.body_case() != + panorama::wire::v1::Request::kMediaCatalogQuery) { + peerError = QStringLiteral( + "RunConfig rejection closed a healthy transport"); + return; + } + auto fileListResponse = + baseResponse(fileListRequest); + fileListResponse.mutable_media_catalog(); + writeResponse( + sockets[1], fileListResponse, &peerError); + }); + PrinterProtocol protocol(500); - const QString devicePath = - QStringLiteral("/dev/usb/lp-pase-display-mutation"); - protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); + const QString endpoint = + QStringLiteral( + "/dev/usb/lp-pase-runconfig-rejected"); + protocol.adoptFileDescriptorForTesting( + sockets[0], endpoint); PrinterProtocol::PaseApplyConfig config; config.display.brightnessPresent = true; - config.display.brightness = 68; - config.display.backlightPresent = true; - config.display.backlightEnabled = false; - config.display.orientationPresent = true; - config.display.mirrorMode = mirrorMode; - config.display.waterfallMode = waterfallMode; + config.display.brightness = 77; QString error; + PrinterProtocol::MutationDetails mutation; PrinterProtocol::PaseDisplayState appliedState; - const bool applied = protocol.applyPaseConfiguration( - devicePath, config, &error, - PrinterProtocol::OperationContext{}, nullptr, - &appliedState); + QVERIFY(!protocol.applyPaseConfiguration( + endpoint, config, &error, + PrinterProtocol::OperationContext{}, &mutation, + &appliedState)); + QCOMPARE( + mutation.outcome, + PrinterProtocol::MutationOutcome::VerificationFailed); + QCOMPARE(mutation.stage, + QStringLiteral("VerifyingConfig")); + QCOMPARE(appliedState.brightness, 77); + QVERIFY(error.contains( + QStringLiteral("overlay activation"), + Qt::CaseInsensitive)); + + const PrinterProtocol::MediaListResult fileList = + protocol.readMediaList( + endpoint, + PrinterProtocol::OperationContext{}); + QVERIFY2(fileList.success, + qPrintable(fileList.error)); + peer.join(); ::close(sockets[1]); - - QVERIFY2(applied, qPrintable(error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - const auto &userConfig = capturedUserConfig.user_configuration(); - QCOMPARE(userConfig.display_config().backlight_enable(), false); - QCOMPARE(userConfig.display_config().backlight_brightness(), 68U); - QCOMPARE(userConfig.display_config().mirror(), false); - QCOMPARE(userConfig.display_config().ui_rotation(), - expectedUiRotation); - QCOMPARE(userConfig.display_config().media_rotation(), - expectedMediaRotation); - QCOMPARE(userConfig.standby_config().enable(), true); - QCOMPARE(QString::fromStdString( - userConfig.standby_config().media_file()), - QStringLiteral("keep-standby.h264")); - QCOMPARE(QString::fromStdString( - userConfig.work_config().single_mode_media_file()), - QStringLiteral("keep-media.h264")); - QVERIFY(hasUnknownField( - userConfig.GetReflection()->GetUnknownFields(userConfig), 199)); - QCOMPARE(appliedState.backlightEnabled, false); - QCOMPARE(appliedState.brightness, 68); - QCOMPARE(appliedState.standbyEnabled, true); - QCOMPARE(appliedState.standbyMedia, - QStringLiteral("keep-standby.h264")); - QCOMPARE(appliedState.mirrorMode, mirrorMode); - QCOMPARE(appliedState.waterfallMode, waterfallMode); - QCOMPARE(appliedState.playMode, QStringLiteral("Loop")); - QCOMPARE(appliedState.media, - QStringList{QStringLiteral("keep-media.h264")}); } -void PrinterProtocolTests::readPaseDisplayStateDecodesDualConfiguration() { +void PrinterProtocolTests:: +verificationFailureKeepsHealthySessionActive() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), @@ -3145,196 +4311,471 @@ void PrinterProtocolTests::readPaseDisplayStateDecodesDualConfiguration() { QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral( - "display-state read did not request user config"); + panorama::wire::v1::Request getRequest; + if (!readRequest(sockets[1], &getRequest, + &peerError)) { return; } - auto response = baseResponse(request); - auto *userConfig = response.mutable_user_configuration(); - auto *display = userConfig->mutable_display_config(); - display->set_backlight_enable(true); - display->set_backlight_brightness(83); - display->set_ui_rotation(90); - display->set_media_rotation(180); - auto *standby = userConfig->mutable_standby_config(); - standby->set_enable(false); - standby->set_media_file("standby.h264"); - auto *work = userConfig->mutable_work_config(); - work->set_media_mode( - panorama::wire::v1::WorkConfiguration::MEDIA_DUAL); - work->set_loop_mode( + auto getResponse = baseResponse(getRequest); + auto *initial = + getResponse.mutable_user_configuration(); + initial->mutable_display_config(); + auto *initialWork = + initial->mutable_work_config(); + initialWork->set_media_mode( + panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); + initialWork->set_loop_mode( panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); - work->set_dual_mode_left_media_file("left.h264"); - work->set_dual_mode_right_media_file("right.h264"); - writeResponse(sockets[1], response, &peerError); + initialWork->set_single_mode_media_file( + "old.h264"); + if (!writeResponse(sockets[1], getResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request userRequest; + if (!readRequest(sockets[1], &userRequest, + &peerError)) { + return; + } + auto userResponse = baseResponse(userRequest); + userResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], userResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request runRequest; + if (!readRequest(sockets[1], &runRequest, + &peerError)) { + return; + } + + panorama::wire::v1::Request readbackRequest; + if (!readRequest(sockets[1], &readbackRequest, + &peerError)) { + return; + } + auto readbackResponse = + baseResponse(readbackRequest); + auto *readback = + readbackResponse.mutable_user_configuration(); + readback->mutable_display_config(); + auto *readbackWork = + readback->mutable_work_config(); + readbackWork->set_media_mode( + panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); + readbackWork->set_loop_mode( + panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); + readbackWork->set_single_mode_media_file( + "old.h264"); + writeResponse( + sockets[1], readbackResponse, &peerError); }); - PrinterProtocol protocol(500); - const QString devicePath = - QStringLiteral("/dev/usb/lp-pase-display-state"); - protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); - const PrinterProtocol::PaseDisplayStateResult result = - protocol.readPaseDisplayState( - devicePath, PrinterProtocol::OperationContext{}); + constexpr quint64 generation = 44; + const QString endpoint = + QStringLiteral("test-endpoint"); + DeviceWorker worker; + worker.updatePrinterGenerationGate(generation, true); + worker.configurePrinterDevice( + endpoint, QStringLiteral("test-serial"), + generation); + worker.adoptPrinterFileDescriptorForTesting( + sockets[0], endpoint); + worker.printerSessionState_ = + DeviceWorker::PrinterSessionState::Active; + worker.printerRecoveryTimer_->stop(); + + TryxRuntimeApplyRequest request; + request.media = {QStringLiteral("left.h264"), + QStringLiteral("right.h264")}; + request.screenMode = + QStringLiteral("Screen Splitting"); + request.playMode = QStringLiteral("Single"); + QSignalSpy applySpy( + &worker, &DeviceWorker::printerApplyFinished); + QSignalSpy displaySpy( + &worker, &DeviceWorker::printerDisplayStateReady); + worker.applyPrinterMedia( + endpoint, QString(), request, false, + QStringLiteral( + "44444444-4444-4444-8444-444444444444"), + generation); + peer.join(); ::close(sockets[1]); - - QVERIFY2(result.success, qPrintable(result.error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(result.state.backlightEnabled, true); - QCOMPARE(result.state.brightness, 83); - QCOMPARE(result.state.standbyEnabled, false); - QCOMPARE(result.state.standbyMedia, - QStringLiteral("standby.h264")); - QCOMPARE(result.state.mirrorMode, true); - QCOMPARE(result.state.waterfallMode, true); - QCOMPARE(result.state.screenMode, - QStringLiteral("Screen Splitting")); - QCOMPARE(result.state.playMode, QStringLiteral("Single")); - QCOMPARE(result.state.media, - QStringList({QStringLiteral("left.h264"), - QStringLiteral("right.h264")})); + QCOMPARE(applySpy.count(), 1); + QCOMPARE(displaySpy.count(), 1); + const PrinterProtocol::PaseDisplayState actualState = + qvariant_cast( + displaySpy.first().at(0)); + QCOMPARE(actualState.screenMode, + QStringLiteral("Full Screen")); + QCOMPARE(applySpy.first().at(2).toBool(), false); + QCOMPARE( + qvariant_cast( + applySpy.first().at(4)), + PrinterProtocol::MutationOutcome::VerificationFailed); + QCOMPARE( + worker.printerSessionState_, + DeviceWorker::PrinterSessionState::Active); + QVERIFY(!worker.printerRecoveryTimer_->isActive()); } -void PrinterProtocolTests::standalonePaseMetricsConfigurationIsAcknowledged() { +void PrinterProtocolTests:: +applyFailureResultPrecedesSessionLoss() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); - PrinterProtocol protocol; - const QString devicePath = - QStringLiteral("/dev/usb/lp-pase-metrics-config"); - protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); - panorama::wire::v1::Request captured; + int queryCount = 0; QString peerError; std::thread peer([&]() { - if (!readRequest(sockets[1], &captured, &peerError)) { - return; + for (int attempt = 0; attempt < 2; ++attempt) { + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { + peerError = QStringLiteral( + "missing read-only apply query %1 before session loss") + .arg(attempt + 1); + return; + } + ++queryCount; } - auto response = baseResponse(captured); - response.mutable_acknowledgement(); - writeResponse(sockets[1], response, &peerError); }); - PrinterProtocol::PaseOverlayConfig overlay; - overlay.left.metrics = {QStringLiteral("CPU Power")}; - overlay.left.alignment = QStringLiteral("Right"); - overlay.left.textColor = 0x000000U; - QString error; - PrinterProtocol::MutationDetails mutation; - const bool configured = protocol.configurePaseOverlay( - devicePath, overlay, &error, PrinterProtocol::OperationContext{}, - &mutation); + constexpr quint64 generation = 45; + const QString endpoint = + QStringLiteral("test-endpoint"); + DeviceWorker worker; + worker.updatePrinterGenerationGate(generation, true); + worker.configurePrinterDevice( + endpoint, QStringLiteral("test-serial"), + generation); + worker.printerProtocol_ = + std::make_unique(75); + worker.adoptPrinterFileDescriptorForTesting( + sockets[0], endpoint); + worker.printerSessionState_ = + DeviceWorker::PrinterSessionState::Active; + + QStringList terminalEvents; + connect( + &worker, &DeviceWorker::printerApplyFinished, + &worker, + [&terminalEvents]( + const QString &, const QString &, bool, bool, + PrinterProtocol::MutationOutcome, + const QString &, quint64) { + terminalEvents.append( + QStringLiteral("apply-finished")); + }); + connect( + &worker, &DeviceWorker::printerSessionLost, + &worker, + [&terminalEvents](quint64) { + terminalEvents.append( + QStringLiteral("session-lost")); + }); + QSignalSpy applySpy( + &worker, &DeviceWorker::printerApplyFinished); + QSignalSpy lostSpy( + &worker, &DeviceWorker::printerSessionLost); + + TryxRuntimeApplyRequest request; + request.media = { + QStringLiteral("left.h264"), + QStringLiteral("right.h264")}; + request.screenMode = + QStringLiteral("Screen Splitting"); + request.playMode = QStringLiteral("Single"); + worker.applyPrinterMedia( + endpoint, QString(), request, false, + QStringLiteral( + "45454545-4545-4545-8545-454545454545"), + generation); + peer.join(); ::close(sockets[1]); - QVERIFY2(configured, qPrintable(error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(mutation.outcome, - PrinterProtocol::MutationOutcome::Succeeded); - QCOMPARE(mutation.stage, QStringLiteral("ActivatingMetricsLayout")); - QCOMPARE(captured.body_case(), - panorama::wire::v1::Request::kOverlayLayout); - QCOMPARE(captured.overlay_layout().label_groups_size(), 1); - const auto &group = captured.overlay_layout().label_groups(0); - QCOMPARE(group.group_id(), 103U); - QCOMPARE(group.text_align(), panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); - QCOMPARE(group.labels_size(), 3); - QCOMPARE(group.labels(0).label_id(), 110U); - QCOMPARE(group.labels(1).label_id(), 111U); - QCOMPARE(group.labels(2).label_id(), 112U); - QCOMPARE(group.labels(0).text_color(), 0x000000U); + QCOMPARE(queryCount, 2); + QCOMPARE(applySpy.count(), 1); + QCOMPARE(lostSpy.count(), 1); + QCOMPARE( + qvariant_cast( + applySpy.first().at(4)), + PrinterProtocol::MutationOutcome::NotStarted); + QCOMPARE( + terminalEvents, + QStringList({ + QStringLiteral("apply-finished"), + QStringLiteral("session-lost")})); + QCOMPARE( + worker.printerSessionState_, + DeviceWorker::PrinterSessionState::Lost); } -void PrinterProtocolTests::displayKeepalivePreservesPaseOverlayValues() { +void PrinterProtocolTests::lateRunConfigDummyDoesNotBreakReadback() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); - PrinterProtocol protocol; - protocol.adoptFileDescriptorForTesting( - sockets[0], QStringLiteral("/dev/usb/lp-pase-keepalive")); - panorama::wire::v1::Request captured; QString peerError; std::thread peer([&]() { - if (!readRequest(sockets[1], &captured, &peerError)) { + panorama::wire::v1::Request getRequest; + if (!readRequest(sockets[1], &getRequest, + &peerError)) { return; } - auto response = baseResponse(captured); - response.mutable_acknowledgement(); - writeResponse(sockets[1], response, &peerError); + auto getResponse = baseResponse(getRequest); + auto *initial = + getResponse.mutable_user_configuration(); + initial->mutable_display_config(); + initial->mutable_standby_config(); + initial->mutable_work_config() + ->set_single_mode_media_file("old.h264"); + if (!writeResponse(sockets[1], getResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request userRequest; + if (!readRequest(sockets[1], &userRequest, + &peerError)) { + return; + } + auto userResponse = baseResponse(userRequest); + userResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], userResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request runRequest; + if (!readRequest(sockets[1], &runRequest, + &peerError)) { + return; + } + panorama::wire::v1::Request readbackRequest; + if (!readRequest(sockets[1], &readbackRequest, + &peerError) || + readbackRequest.body_case() != + panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral( + "late Dummy test did not receive readback request"); + return; + } + + auto lateDummy = baseResponse(runRequest); + lateDummy.mutable_acknowledgement(); + if (!writeResponse(sockets[1], lateDummy, + &peerError)) { + return; + } + auto readbackResponse = + baseResponse(readbackRequest); + *readbackResponse.mutable_user_configuration() = + userRequest.user_configuration(); + writeResponse( + sockets[1], readbackResponse, &peerError); }); - PrinterProtocol::PaseOverlayConfig overlay; - overlay.left.metrics = {QStringLiteral("CPU Temperature")}; - overlay.left.initialLabels = { - QStringLiteral("CPU Temperature")}; - overlay.left.initialValues = {QStringLiteral("56")}; - overlay.left.initialUnits = {QStringLiteral("°C")}; + PrinterProtocol protocol(500); + const QString devicePath = + QStringLiteral("/dev/usb/lp-pase-late-dummy"); + protocol.adoptFileDescriptorForTesting( + sockets[0], devicePath); + PrinterProtocol::PaseApplyConfig config; + config.media = {QStringLiteral("new.h264")}; + config.screenMode = QStringLiteral("Full Screen"); + config.playMode = QStringLiteral("Single"); + config.mediaPresent = true; QString error; - QCOMPARE(protocol.sendDisplayKeepalive( - QStringLiteral("/dev/usb/lp-pase-keepalive"), &error, - PrinterProtocol::OperationContext{}, &overlay), - PrinterProtocol::KeepaliveOutcome::Sent); + PrinterProtocol::MutationDetails mutation; + PrinterProtocol::PaseDisplayState appliedState; + const bool applied = protocol.applyPaseConfiguration( + devicePath, config, &error, + PrinterProtocol::OperationContext{}, &mutation, + &appliedState); peer.join(); ::close(sockets[1]); + QVERIFY2(applied, qPrintable(error)); + QCOMPARE(mutation.outcome, + PrinterProtocol::MutationOutcome::Succeeded); + QCOMPARE(appliedState.media, config.media); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(captured.body_case(), - panorama::wire::v1::Request::kOverlayLayout); - QCOMPARE(captured.overlay_layout().label_groups_size(), 1); - const auto &group = captured.overlay_layout().label_groups(0); - QCOMPARE(group.labels_size(), 3); - QCOMPARE(QString::fromStdString(group.labels(1).text()), - QStringLiteral("56")); - QCOMPARE(QString::fromStdString(group.labels(2).text()), - QStringLiteral("°C")); } -void PrinterProtocolTests::paseMetricBatchMatchesHeaderlessWireFrame() { +void PrinterProtocolTests::paseDisplayMutationMatrix_data() { + QTest::addColumn("mirrorMode"); + QTest::addColumn("waterfallMode"); + QTest::addColumn("expectedUiRotation"); + QTest::addColumn("expectedMediaRotation"); + + QTest::newRow("normal") + << false << false << 0U << 0U; + QTest::newRow("mirror") + << true << false << 0U << 180U; + QTest::newRow("waterfall") + << false << true << 90U << 0U; + QTest::newRow("mirror-waterfall") + << true << true << 90U << 180U; +} + +void PrinterProtocolTests::paseDisplayMutationMatrix() { + QFETCH(bool, mirrorMode); + QFETCH(bool, waterfallMode); + QFETCH(quint32, expectedUiRotation); + QFETCH(quint32, expectedMediaRotation); + int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); - PrinterProtocol protocol; - protocol.adoptFileDescriptorForTesting( - sockets[0], QStringLiteral("/dev/usb/lp-pase-batch")); - panorama::wire::v1::Request captured; + panorama::wire::v1::Request capturedUserConfig; QString peerError; std::thread peer([&]() { - readRequest(sockets[1], &captured, &peerError); - }); - - PrinterProtocol::PaseOverlayConfig overlay; - overlay.left.metrics = {QStringLiteral("CPU Temperature")}; - QString error; - const bool sent = protocol.sendPaseMetricBatch( - QStringLiteral("/dev/usb/lp-pase-batch"), overlay, - {QStringLiteral("CPU Temperature")}, {QStringLiteral("56")}, - {QStringLiteral("°C")}, &error, - PrinterProtocol::OperationContext{}); - peer.join(); + panorama::wire::v1::Request getRequest; + if (!readRequest(sockets[1], &getRequest, &peerError) || + getRequest.body_case() != + panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral( + "display mutation did not read user config"); + return; + } + auto getResponse = baseResponse(getRequest); + auto *userConfig = getResponse.mutable_user_configuration(); + auto *display = userConfig->mutable_display_config(); + display->set_backlight_enable(true); + display->set_backlight_brightness(21); + display->set_mirror(true); + display->set_ui_rotation(270); + display->set_media_rotation(90); + auto *standby = userConfig->mutable_standby_config(); + standby->set_enable(true); + standby->set_media_file("keep-standby.h264"); + auto *work = userConfig->mutable_work_config(); + work->set_media_mode( + panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE); + work->set_loop_mode( + panorama::wire::v1::WorkConfiguration::LOOP_ALL); + work->set_single_mode_media_file("keep-media.h264"); + userConfig->GetReflection() + ->MutableUnknownFields(userConfig) + ->AddVarint(199, 42); + if (!writeResponse(sockets[1], getResponse, &peerError)) { + return; + } + + if (!readRequest(sockets[1], &capturedUserConfig, + &peerError) || + capturedUserConfig.body_case() != + panorama::wire::v1::Request::kUserConfiguration) { + peerError = QStringLiteral( + "display mutation did not send user config"); + return; + } + auto userResponse = baseResponse(capturedUserConfig); + userResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], userResponse, &peerError)) { + return; + } + + panorama::wire::v1::Request runRequest; + if (!readRequest(sockets[1], &runRequest, &peerError) || + runRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + peerError = QStringLiteral( + "display mutation did not activate run config"); + return; + } + auto runResponse = baseResponse(runRequest); + runResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], runResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request readbackRequest; + if (!readRequest(sockets[1], &readbackRequest, + &peerError) || + readbackRequest.body_case() != + panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral( + "display mutation did not verify user config"); + return; + } + auto readbackResponse = + baseResponse(readbackRequest); + *readbackResponse.mutable_user_configuration() = + capturedUserConfig.user_configuration(); + writeResponse( + sockets[1], readbackResponse, &peerError); + }); + + PrinterProtocol protocol(500); + const QString devicePath = + QStringLiteral("/dev/usb/lp-pase-display-mutation"); + protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); + PrinterProtocol::PaseApplyConfig config; + config.display.brightnessPresent = true; + config.display.brightness = 68; + config.display.backlightPresent = true; + config.display.backlightEnabled = false; + config.display.orientationPresent = true; + config.display.mirrorMode = mirrorMode; + config.display.waterfallMode = waterfallMode; + QString error; + PrinterProtocol::PaseDisplayState appliedState; + const bool applied = protocol.applyPaseConfiguration( + devicePath, config, &error, + PrinterProtocol::OperationContext{}, nullptr, + &appliedState); + peer.join(); ::close(sockets[1]); - QVERIFY2(sent, qPrintable(error)); + QVERIFY2(applied, qPrintable(error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QVERIFY(!captured.has_header()); - QCOMPARE(captured.body_case(), - panorama::wire::v1::Request::kMetricBatch); - std::string serialized; - QVERIFY(captured.SerializeToString(&serialized)); - const QByteArray actual(serialized.data(), - static_cast(serialized.size())); - const QByteArray expected = QByteArray::fromHex( - "e212190a0a086412060866120235360a0b0864120708671203c2b043"); - QCOMPARE(actual, expected); + const auto &userConfig = capturedUserConfig.user_configuration(); + QCOMPARE(userConfig.display_config().backlight_enable(), false); + QCOMPARE(userConfig.display_config().backlight_brightness(), 68U); + QCOMPARE(userConfig.display_config().mirror(), false); + QCOMPARE(userConfig.display_config().ui_rotation(), + expectedUiRotation); + QCOMPARE(userConfig.display_config().media_rotation(), + expectedMediaRotation); + QCOMPARE(userConfig.standby_config().enable(), true); + QCOMPARE(QString::fromStdString( + userConfig.standby_config().media_file()), + QStringLiteral("keep-standby.h264")); + QCOMPARE(QString::fromStdString( + userConfig.work_config().single_mode_media_file()), + QStringLiteral("keep-media.h264")); + QVERIFY(hasUnknownField( + userConfig.GetReflection()->GetUnknownFields(userConfig), 199)); + QCOMPARE(appliedState.backlightEnabled, false); + QCOMPARE(appliedState.brightness, 68); + QCOMPARE(appliedState.standbyEnabled, true); + QCOMPARE(appliedState.standbyMedia, + QStringLiteral("keep-standby.h264")); + QCOMPARE(appliedState.mirrorMode, mirrorMode); + QCOMPARE(appliedState.waterfallMode, waterfallMode); + QCOMPARE(appliedState.playMode, QStringLiteral("Loop")); + QCOMPARE(appliedState.media, + QStringList{QStringLiteral("keep-media.h264")}); } -void PrinterProtocolTests::metricBatchExplicitErrorIsRejected() { +void PrinterProtocolTests::readPaseDisplayStateDecodesDualConfiguration() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), @@ -3345,92 +4786,289 @@ void PrinterProtocolTests::metricBatchExplicitErrorIsRejected() { panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != - panorama::wire::v1::Request:: - kMetricBatch) { + panorama::wire::v1::Request::kUserConfigurationQuery) { peerError = QStringLiteral( - "metric error test did not receive a batch update"); + "display-state read did not request user config"); return; } - panorama::wire::v1::Response response; - response.mutable_error()->set_code( - panorama::wire::v1::ProtocolError::FAILURE); - response.mutable_error()->set_why( - "label group is unavailable"); + auto response = baseResponse(request); + auto *userConfig = response.mutable_user_configuration(); + auto *display = userConfig->mutable_display_config(); + display->set_backlight_enable(true); + display->set_backlight_brightness(83); + display->set_ui_rotation(90); + display->set_media_rotation(180); + auto *standby = userConfig->mutable_standby_config(); + standby->set_enable(false); + standby->set_media_file("standby.h264"); + auto *work = userConfig->mutable_work_config(); + work->set_media_mode( + panorama::wire::v1::WorkConfiguration::MEDIA_DUAL); + work->set_loop_mode( + panorama::wire::v1::WorkConfiguration::LOOP_SINGLE); + work->set_dual_mode_left_media_file("left.h264"); + work->set_dual_mode_right_media_file("right.h264"); writeResponse(sockets[1], response, &peerError); }); - PrinterProtocol protocol; - const QString endpoint = - QStringLiteral("/dev/usb/lp-pase-batch-error"); - protocol.adoptFileDescriptorForTesting( - sockets[0], endpoint); - PrinterProtocol::PaseOverlayConfig overlay; - overlay.left.metrics = { - QStringLiteral("CPU Temperature")}; - QString error; - QVERIFY(!protocol.sendPaseMetricBatch( - endpoint, overlay, - {QStringLiteral("CPU Temperature")}, - {QStringLiteral("56")}, - {QStringLiteral("°C")}, &error, - PrinterProtocol::OperationContext{})); - + PrinterProtocol protocol(500); + const QString devicePath = + QStringLiteral("/dev/usb/lp-pase-display-state"); + protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); + const PrinterProtocol::PaseDisplayStateResult result = + protocol.readPaseDisplayState( + devicePath, PrinterProtocol::OperationContext{}); peer.join(); ::close(sockets[1]); + + QVERIFY2(result.success, qPrintable(result.error)); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QVERIFY(error.contains( - QStringLiteral("label group"), - Qt::CaseInsensitive)); + QCOMPARE(result.state.backlightEnabled, true); + QCOMPARE(result.state.brightness, 83); + QCOMPARE(result.state.standbyEnabled, false); + QCOMPARE(result.state.standbyMedia, + QStringLiteral("standby.h264")); + QCOMPARE(result.state.mirrorMode, true); + QCOMPARE(result.state.waterfallMode, true); + QCOMPARE(result.state.screenMode, + QStringLiteral("Screen Splitting")); + QCOMPARE(result.state.playMode, QStringLiteral("Single")); + QCOMPARE(result.state.media, + QStringList({QStringLiteral("left.h264"), + QStringLiteral("right.h264")})); } -void PrinterProtocolTests::metricBatchResponsesAreDrainedBeforeTrackedRequest() { +void PrinterProtocolTests::standalonePaseMetricsConfigurationIsAcknowledged() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int responseReadyFd = eventfd(0, EFD_CLOEXEC); - QVERIFY(responseReadyFd >= 0); - constexpr int kMetricResponseCount = 40; + PrinterProtocol protocol; + const QString devicePath = + QStringLiteral("/dev/usb/lp-pase-metrics-config"); + protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); + panorama::wire::v1::Request captured; QString peerError; std::thread peer([&]() { - QByteArray requestBuffer; - for (int index = 0; index < kMetricResponseCount; ++index) { - QByteArray payload; - panorama::wire::v1::Request request; - if (!readFrameFd(sockets[1], &payload, &peerError, - kPeerTimeoutMs, &requestBuffer) || - !request.ParseFromArray(payload.constData(), - static_cast(payload.size())) || - request.body_case() != - panorama::wire::v1::Request::kMetricBatch) { - if (peerError.isEmpty()) { - peerError = QStringLiteral( - "expected metric batch request %1").arg(index); - } - return; - } - panorama::wire::v1::Response response; - response.mutable_acknowledgement(); - if (!writeResponse(sockets[1], response, &peerError)) { - return; - } - } - const uint64_t ready = 1; - if (::write(responseReadyFd, &ready, sizeof(ready)) != - static_cast(sizeof(ready))) { - peerError = QStringLiteral( - "failed to signal queued metric response burst"); + if (!readRequest(sockets[1], &captured, &peerError)) { return; } + auto response = baseResponse(captured); + response.mutable_acknowledgement(); + writeResponse(sockets[1], response, &peerError); + }); - QByteArray trackedPayload; - panorama::wire::v1::Request request; - if (!readFrameFd(sockets[1], &trackedPayload, &peerError, - kPeerTimeoutMs, &requestBuffer) || - !request.ParseFromArray( - trackedPayload.constData(), - static_cast(trackedPayload.size())) || - request.body_case() != panorama::wire::v1::Request::kPing) { + PrinterProtocol::PaseOverlayConfig overlay; + overlay.left.metrics = {QStringLiteral("CPU Power")}; + overlay.left.alignment = QStringLiteral("Right"); + overlay.left.textColor = 0x000000U; + QString error; + PrinterProtocol::MutationDetails mutation; + const bool configured = protocol.configurePaseOverlay( + devicePath, overlay, &error, PrinterProtocol::OperationContext{}, + &mutation); + peer.join(); + ::close(sockets[1]); + + QVERIFY2(configured, qPrintable(error)); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QCOMPARE(mutation.outcome, + PrinterProtocol::MutationOutcome::Succeeded); + QCOMPARE(mutation.stage, QStringLiteral("ActivatingMetricsLayout")); + QCOMPARE(captured.body_case(), + panorama::wire::v1::Request::kOverlayLayout); + QCOMPARE(captured.overlay_layout().label_groups_size(), 1); + const auto &group = captured.overlay_layout().label_groups(0); + QCOMPARE(group.group_id(), 103U); + QCOMPARE(group.text_align(), panorama::wire::v1::OverlayGroup::ALIGN_RIGHT); + QCOMPARE(group.labels_size(), 3); + QCOMPARE(group.labels(0).label_id(), 110U); + QCOMPARE(group.labels(1).label_id(), 111U); + QCOMPARE(group.labels(2).label_id(), 112U); + QCOMPARE(group.labels(0).text_color(), 0x000000U); +} + +void PrinterProtocolTests::displayKeepalivePreservesPaseOverlayValues() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + PrinterProtocol protocol; + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("/dev/usb/lp-pase-keepalive")); + panorama::wire::v1::Request captured; + QString peerError; + std::thread peer([&]() { + if (!readRequest(sockets[1], &captured, &peerError)) { + return; + } + auto response = baseResponse(captured); + response.mutable_acknowledgement(); + writeResponse(sockets[1], response, &peerError); + }); + + PrinterProtocol::PaseOverlayConfig overlay; + overlay.left.metrics = {QStringLiteral("CPU Temperature")}; + overlay.left.initialLabels = { + QStringLiteral("CPU Temperature")}; + overlay.left.initialValues = {QStringLiteral("56")}; + overlay.left.initialUnits = {QStringLiteral("°C")}; + QString error; + QCOMPARE(protocol.sendDisplayKeepalive( + QStringLiteral("/dev/usb/lp-pase-keepalive"), &error, + PrinterProtocol::OperationContext{}, &overlay), + PrinterProtocol::KeepaliveOutcome::Sent); + peer.join(); + ::close(sockets[1]); + + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QCOMPARE(captured.body_case(), + panorama::wire::v1::Request::kOverlayLayout); + QCOMPARE(captured.overlay_layout().label_groups_size(), 1); + const auto &group = captured.overlay_layout().label_groups(0); + QCOMPARE(group.labels_size(), 3); + QCOMPARE(QString::fromStdString(group.labels(1).text()), + QStringLiteral("56")); + QCOMPARE(QString::fromStdString(group.labels(2).text()), + QStringLiteral("°C")); +} + +void PrinterProtocolTests::paseMetricBatchMatchesHeaderlessWireFrame() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + PrinterProtocol protocol; + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("/dev/usb/lp-pase-batch")); + panorama::wire::v1::Request captured; + QString peerError; + std::thread peer([&]() { + readRequest(sockets[1], &captured, &peerError); + }); + + PrinterProtocol::PaseOverlayConfig overlay; + overlay.left.metrics = {QStringLiteral("CPU Temperature")}; + QString error; + const bool sent = protocol.sendPaseMetricBatch( + QStringLiteral("/dev/usb/lp-pase-batch"), overlay, + {QStringLiteral("CPU Temperature")}, {QStringLiteral("56")}, + {QStringLiteral("°C")}, &error, + PrinterProtocol::OperationContext{}); + peer.join(); + ::close(sockets[1]); + + QVERIFY2(sent, qPrintable(error)); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY(!captured.has_header()); + QCOMPARE(captured.body_case(), + panorama::wire::v1::Request::kMetricBatch); + std::string serialized; + QVERIFY(captured.SerializeToString(&serialized)); + const QByteArray actual(serialized.data(), + static_cast(serialized.size())); + const QByteArray expected = QByteArray::fromHex( + "e212190a0a086412060866120235360a0b0864120708671203c2b043"); + QCOMPARE(actual, expected); +} + +void PrinterProtocolTests::metricBatchExplicitErrorIsRejected() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); + + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kMetricBatch) { + peerError = QStringLiteral( + "metric error test did not receive a batch update"); + return; + } + panorama::wire::v1::Response response; + response.mutable_error()->set_code( + panorama::wire::v1::ProtocolError::FAILURE); + response.mutable_error()->set_why( + "label group is unavailable"); + writeResponse(sockets[1], response, &peerError); + }); + + PrinterProtocol protocol; + const QString endpoint = + QStringLiteral("/dev/usb/lp-pase-batch-error"); + protocol.adoptFileDescriptorForTesting( + sockets[0], endpoint); + PrinterProtocol::PaseOverlayConfig overlay; + overlay.left.metrics = { + QStringLiteral("CPU Temperature")}; + QString error; + QVERIFY(!protocol.sendPaseMetricBatch( + endpoint, overlay, + {QStringLiteral("CPU Temperature")}, + {QStringLiteral("56")}, + {QStringLiteral("°C")}, &error, + PrinterProtocol::OperationContext{})); + + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY(error.contains( + QStringLiteral("label group"), + Qt::CaseInsensitive)); +} + +void PrinterProtocolTests::metricBatchResponsesAreDrainedBeforeTrackedRequest() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + const int responseReadyFd = eventfd(0, EFD_CLOEXEC); + QVERIFY(responseReadyFd >= 0); + + constexpr int kMetricResponseCount = 40; + QString peerError; + std::thread peer([&]() { + QByteArray requestBuffer; + for (int index = 0; index < kMetricResponseCount; ++index) { + QByteArray payload; + panorama::wire::v1::Request request; + if (!readFrameFd(sockets[1], &payload, &peerError, + kPeerTimeoutMs, &requestBuffer) || + !request.ParseFromArray(payload.constData(), + static_cast(payload.size())) || + request.body_case() != + panorama::wire::v1::Request::kMetricBatch) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "expected metric batch request %1").arg(index); + } + return; + } + panorama::wire::v1::Response response; + response.mutable_acknowledgement(); + if (!writeResponse(sockets[1], response, &peerError)) { + return; + } + } + const uint64_t ready = 1; + if (::write(responseReadyFd, &ready, sizeof(ready)) != + static_cast(sizeof(ready))) { + peerError = QStringLiteral( + "failed to signal queued metric response burst"); + return; + } + + QByteArray trackedPayload; + panorama::wire::v1::Request request; + if (!readFrameFd(sockets[1], &trackedPayload, &peerError, + kPeerTimeoutMs, &requestBuffer) || + !request.ParseFromArray( + trackedPayload.constData(), + static_cast(trackedPayload.size())) || + request.body_case() != panorama::wire::v1::Request::kPing) { if (peerError.isEmpty()) { peerError = QStringLiteral( "tracked request did not follow metric response burst"); @@ -4478,7 +6116,7 @@ void PrinterProtocolTests::ensureOriginMissReleasesForegroundBeforePreparation() QObject::disconnect(manager.get(), &DeviceManager::requestPreparePrinterMedia, manager->printerMediaPreparer_, - &PrinterMediaPreparer::startPreparation); + &PrinterMediaPreparer::prepare); QStringList transitions; connect(manager.get(), &DeviceManager::requestBeginPrinterForegroundOperation, manager.get(), [&transitions](const QString &, quint64) { @@ -5646,9 +7284,16 @@ void PrinterProtocolTests::retryCancellationRetainsOwnershipOnManifestRemovalFai &cacheError), qPrintable(cacheError)); - QVERIFY(QFile::setPermissions( - cacheDirectory, - QFileDevice::ReadOwner | QFileDevice::ExeOwner)); + const QString manifestPath = manager->retryCacheManifestPath(); + QVERIFY(QFile::remove(manifestPath)); + QVERIFY(QDir().mkpath(manifestPath)); + const QString sentinelPath = + QDir(manifestPath).filePath(QStringLiteral("sentinel")); + QFile sentinel(sentinelPath); + QVERIFY(sentinel.open(QIODevice::WriteOnly)); + QCOMPARE(sentinel.write(QByteArrayLiteral("keep")), 4); + sentinel.close(); + QSignalSpy errorSpy(manager.get(), &DeviceManager::deviceError); manager->cancelOperation(operationId); @@ -5657,14 +7302,12 @@ void PrinterProtocolTests::retryCancellationRetainsOwnershipOnManifestRemovalFai QCOMPARE(manager->operationInfo(operationId).errorCategory, QStringLiteral("RetryCacheCleanupFailed")); QCOMPARE(manager->retryCacheOperationId_, operationId); - QVERIFY(QFileInfo::exists(manager->retryCacheManifestPath())); + QVERIFY(QFileInfo(manifestPath).isDir()); + QVERIFY(QFileInfo::exists(sentinelPath)); QVERIFY(QFileInfo::exists(preparedPath)); QVERIFY(errorSpy.count() >= 1); - QVERIFY(QFile::setPermissions( - cacheDirectory, - QFileDevice::ReadOwner | QFileDevice::WriteOwner | - QFileDevice::ExeOwner)); + QVERIFY(QDir(manifestPath).removeRecursively()); } void PrinterProtocolTests::mediaPreparationBoundsAndThumbnailFallback() { @@ -6834,797 +8477,2518 @@ void PrinterProtocolTests::wireCriticalGoldenFixtures() { "0a0408011008ba1f2f0a150a0b2f75736572646174612f6112042e6d703418" "7b12160a0964656661756c745f7812042e6d703418c8032001")); - panorama::wire::v1::Response error; - error.mutable_header()->set_version(1); - error.mutable_header()->set_track_id(9); - error.mutable_error()->set_code( - panorama::wire::v1::ProtocolError::FAILURE); - error.mutable_error()->set_why("rejected"); - QCOMPARE(serializedHex(error), - QByteArray("0a0408011009120c0801120872656a6563746564")); + panorama::wire::v1::Response error; + error.mutable_header()->set_version(1); + error.mutable_header()->set_track_id(9); + error.mutable_error()->set_code( + panorama::wire::v1::ProtocolError::FAILURE); + error.mutable_error()->set_why("rejected"); + QCOMPARE(serializedHex(error), + QByteArray("0a0408011009120c0801120872656a6563746564")); + + panorama::wire::v1::Response transferStatus; + transferStatus.mutable_header()->set_track_id(17); + transferStatus.mutable_transfer_end_status()->set_status( + panorama::wire::v1::TransferStatus::CHECKSUM_FAILURE); + QCOMPARE(serializedHex(transferStatus), + QByteArray("0a0210119232020803")); + + panorama::wire::v1::Response asynchronousEvent; + asynchronousEvent.mutable_asynchronous_event()->set_play_finished(true); + QCOMPARE(serializedHex(asynchronousEvent), + QByteArray("da3d020801")); +} + +void PrinterProtocolTests::unknownFieldsSurviveMutation() { + panorama::wire::v1::UserConfiguration original; + original.mutable_work_config()->set_single_mode_media_file("old.h264"); + original.GetReflection()->MutableUnknownFields(&original)->AddVarint(99, 123456); + auto *workUnknown = original.mutable_work_config() + ->GetReflection() + ->MutableUnknownFields(original.mutable_work_config()); + workUnknown->AddLengthDelimited(77, "nested-unknown"); + + std::string fixture; + QVERIFY(original.SerializeToString(&fixture)); + panorama::wire::v1::UserConfiguration parsed; + QVERIFY(parsed.ParseFromString(fixture)); + parsed.mutable_work_config()->set_single_mode_media_file("new.h264"); + + std::string roundTrip; + QVERIFY(parsed.SerializeToString(&roundTrip)); + panorama::wire::v1::UserConfiguration verified; + QVERIFY(verified.ParseFromString(roundTrip)); + QCOMPARE(QString::fromStdString( + verified.work_config().single_mode_media_file()), + QStringLiteral("new.h264")); + QVERIFY(hasUnknownField( + verified.GetReflection()->GetUnknownFields(verified), 99)); + QVERIFY(hasUnknownField( + verified.work_config().GetReflection()->GetUnknownFields( + verified.work_config()), + 77)); +} + +void PrinterProtocolTests::discoveryStateSequence() { + QTemporaryDir fixture; + QVERIFY(fixture.isValid()); + const QString sysRoot = QDir(fixture.path()).filePath(QStringLiteral("sys")); + const QString devRoot = QDir(fixture.path()).filePath(QStringLiteral("dev")); + + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "0006")); + auto snapshot = PrinterProtocol::discover(sysRoot, devRoot); + QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::RockchipGadget391a0006); + QVERIFY(snapshot.blocksLegacyTransport()); + + QVERIFY(writeTextFile( + QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1/idProduct")), + "1021\n")); + snapshot = PrinterProtocol::discover(sysRoot, devRoot); + QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Enumerating391a1021); + + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-1"), QStringLiteral("lp0"))); + snapshot = PrinterProtocol::discover(sysRoot, devRoot); + QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Ready); + QCOMPARE(snapshot.devices.size(), 1); + QVERIFY(snapshot.devices.first().accessible); + + QVERIFY(writeTextFile( + QDir(sysRoot).filePath( + QStringLiteral("bus/usb/devices/1-1/1-1:1.0/bInterfaceProtocol")), + "01\n")); + snapshot = PrinterProtocol::discover(sysRoot, devRoot); + QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Enumerating391a1021); + QVERIFY(writeTextFile( + QDir(sysRoot).filePath( + QStringLiteral("bus/usb/devices/1-1/1-1:1.0/bInterfaceProtocol")), + "02\n")); + + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-2"), "0006")); + snapshot = PrinterProtocol::discover(sysRoot, devRoot); + QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Ambiguous); + QVERIFY(QDir(QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-2"))) + .removeRecursively()); + + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-2"), "1021")); + snapshot = PrinterProtocol::discover(sysRoot, devRoot); + QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Ambiguous); + QCOMPARE(snapshot.devices.size(), 1); + + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-2"), QStringLiteral("lp1"))); + snapshot = PrinterProtocol::discover(sysRoot, devRoot); + QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Ambiguous); + QCOMPARE(snapshot.devices.size(), 2); + + QVERIFY(QDir(QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1"))) + .removeRecursively()); + QVERIFY(QDir(QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-2"))) + .removeRecursively()); + QVERIFY(QDir(QDir(sysRoot).filePath(QStringLiteral("class/usbmisc"))) + .removeRecursively()); + snapshot = PrinterProtocol::discover(sysRoot, devRoot); + QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Absent); + QVERIFY(!snapshot.blocksLegacyTransport()); +} + +void PrinterProtocolTests::productionEndpointValidationWithOfflineSysfs() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-1"), QStringLiteral("lp0"))); + + const QString endpointPath = QDir(devRoot).filePath(QStringLiteral("usb/lp0")); + QVERIFY(QFile::remove(endpointPath)); + const QByteArray encodedEndpoint = QFile::encodeName(endpointPath); + QVERIFY(::symlink("/dev/null", encodedEndpoint.constData()) == 0); + + struct stat nullStatus {}; + QVERIFY(::stat("/dev/null", &nullStatus) == 0); + QVERIFY(S_ISCHR(nullStatus.st_mode)); + const QString sysfsDevPath = QDir(sysRoot).filePath( + QStringLiteral("class/usbmisc/lp0/dev")); + const QByteArray expectedDeviceNumber = + QByteArray::number(major(nullStatus.st_rdev)) + ':' + + QByteArray::number(minor(nullStatus.st_rdev)) + '\n'; + QVERIFY(writeTextFile(sysfsDevPath, expectedDeviceNumber)); + + const int endpointFd = ::open(encodedEndpoint.constData(), + O_RDWR | O_CLOEXEC | O_NONBLOCK); + QVERIFY(endpointFd >= 0); + QString error; + QVERIFY2(PrinterProtocol::validateEndpointForTesting( + endpointPath, endpointFd, sysRoot, devRoot, &error), + qPrintable(error)); + + const QString productPath = QDir(sysRoot).filePath( + QStringLiteral("bus/usb/devices/1-1/idProduct")); + QVERIFY(writeTextFile(productPath, "0006\n")); + QVERIFY(!PrinterProtocol::validateEndpointForTesting( + endpointPath, endpointFd, sysRoot, devRoot, &error)); + QVERIFY(error.contains(QStringLiteral("391a:1021"))); + QVERIFY(writeTextFile(productPath, "1021\n")); + + const QString protocolPath = QDir(sysRoot).filePath( + QStringLiteral("bus/usb/devices/1-1/1-1:1.0/bInterfaceProtocol")); + QVERIFY(writeTextFile(protocolPath, "01\n")); + QVERIFY(!PrinterProtocol::validateEndpointForTesting( + endpointPath, endpointFd, sysRoot, devRoot, &error)); + QVERIFY(error.contains(QStringLiteral("printer interface"))); + QVERIFY(writeTextFile(protocolPath, "02\n")); + + QVERIFY(writeTextFile(sysfsDevPath, "1:1\n")); + QVERIFY(!PrinterProtocol::validateEndpointForTesting( + endpointPath, endpointFd, sysRoot, devRoot, &error)); + QVERIFY(error.contains(QStringLiteral("sysfs"))); + QVERIFY(writeTextFile(sysfsDevPath, expectedDeviceNumber)); + + QVERIFY(QFile::remove(endpointPath)); + QVERIFY(::symlink("/dev/zero", encodedEndpoint.constData()) == 0); + struct stat zeroStatus {}; + QVERIFY(::stat("/dev/zero", &zeroStatus) == 0); + const QByteArray replacementDeviceNumber = + QByteArray::number(major(zeroStatus.st_rdev)) + ':' + + QByteArray::number(minor(zeroStatus.st_rdev)) + '\n'; + QVERIFY(writeTextFile(sysfsDevPath, replacementDeviceNumber)); + QVERIFY2(PrinterProtocol::validateEndpointForTesting( + endpointPath, -1, sysRoot, devRoot, &error), + qPrintable(error)); + QVERIFY(!PrinterProtocol::validateEndpointForTesting( + endpointPath, endpointFd, sysRoot, devRoot, &error)); + QVERIFY(error.contains(QStringLiteral("changed"))); + + const QString unverifiedPath = QDir(devRoot).filePath(QStringLiteral("lp0")); + QVERIFY(!PrinterProtocol::validateEndpointForTesting( + unverifiedPath, endpointFd, sysRoot, devRoot, &error)); + QVERIFY(error.contains(QStringLiteral("unverified"))); + const QString signedEndpointPath = QDir(devRoot).filePath( + QStringLiteral("usb/lp+1")); + QVERIFY(!PrinterProtocol::validateEndpointForTesting( + signedEndpointPath, endpointFd, sysRoot, devRoot, &error)); + QVERIFY(error.contains(QStringLiteral("unverified"))); + ::close(endpointFd); +} + +void PrinterProtocolTests::samePathEndpointEventForcesNewEpochSignal() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-1"), QStringLiteral("lp0"))); + + PrinterDeviceMonitor monitor; + monitor.setDiscoveryRootsForTesting(sysRoot, devRoot); + QSignalSpy snapshotSpy(&monitor, &PrinterDeviceMonitor::snapshotChanged); + monitor.rescanForTesting(false); + QCOMPARE(snapshotSpy.count(), 1); + const PrinterProtocol::DiscoverySnapshot first = monitor.snapshot(); + QVERIFY(first.state == PrinterProtocol::DiscoveryState::Ready); + QCOMPARE(first.devices.size(), 1); + QCOMPARE(first.devices.first().devicePath, + QDir(devRoot).filePath(QStringLiteral("usb/lp0"))); + + monitor.rescanForTesting(false); + QCOMPARE(snapshotSpy.count(), 1); + monitor.injectUdevEventForTesting( + QByteArrayLiteral("usb"), + QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1")), + QStringLiteral("1-1")); + QCOMPARE(snapshotSpy.count(), 2); + QVERIFY(monitor.snapshot() == first); +} + +void PrinterProtocolTests::paseUdevReadinessUsesUsbDeviceEvents() { + const auto usbAdd = PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usb"), QByteArrayLiteral("add"), + QByteArrayLiteral("391a/1021/100")); + QCOMPARE(usbAdd, qMakePair(true, false)); + + const auto usbBind = PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usb"), QByteArrayLiteral("bind"), + QByteArrayLiteral("391A/1021/100")); + QCOMPARE(usbBind, qMakePair(false, false)); + + const auto usbmiscAdd = PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usbmisc"), QByteArrayLiteral("add"), QByteArray()); + QCOMPARE(usbmiscAdd, qMakePair(false, false)); + + const auto usbmiscRemove = PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usbmisc"), QByteArrayLiteral("remove"), QByteArray()); + QCOMPARE(usbmiscRemove, qMakePair(false, false)); + + const auto usbRemove = PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usb"), QByteArrayLiteral("remove"), + QByteArrayLiteral("391a/1021/100"), true); + QCOMPARE(usbRemove, qMakePair(true, true)); + + const auto unrelatedRemove = + PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usb"), QByteArrayLiteral("remove"), + QByteArrayLiteral("0db0/84df/0")); + QCOMPARE(unrelatedRemove, qMakePair(false, false)); + + const auto unknownUnrelatedRemove = + PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usb"), QByteArrayLiteral("remove"), + QByteArray()); + QCOMPARE(unknownUnrelatedRemove, qMakePair(false, false)); + + const auto currentPaseRemoveWithoutProduct = + PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usb"), QByteArrayLiteral("remove"), + QByteArray(), true); + QCOMPARE(currentPaseRemoveWithoutProduct, + qMakePair(true, true)); + + const auto transitionAdd = PrinterDeviceMonitor::eventPolicyForTesting( + QByteArrayLiteral("usb"), QByteArrayLiteral("add"), + QByteArrayLiteral("391a/0006/100")); + QCOMPARE(transitionAdd, qMakePair(true, false)); +} + +void PrinterProtocolTests::unrelatedUsbRemoveDoesNotRestartPaseSession() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-1"), + QStringLiteral("lp0"))); + + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + QVERIFY(manager->isPrinterClassConnected()); + const quint64 generation = + manager->printerGenerationForTesting(); + manager->printerMonitor_->snapshot_.devices.first().sysfsPath = + QDir(sysRoot).filePath( + QStringLiteral("bus/usb/devices/1-1")); + QSignalSpy sessionStartSpy( + manager.get(), &DeviceManager::requestStartPrinterSession); + + manager->injectPrinterUdevEventForTesting( + QByteArrayLiteral("usb"), + QDir(sysRoot).filePath( + QStringLiteral("bus/usb/devices/1-10.4")), + QStringLiteral("1-10.4")); + + QCOMPARE(manager->printerGenerationForTesting(), generation); + QCOMPARE(sessionStartSpy.count(), 0); +} + +void PrinterProtocolTests::passivePrinterReconnectDoesNotRequestSessionResume() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-1"), + QStringLiteral("lp0"))); + + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + QVERIFY(manager->isPrinterClassConnected()); + QSignalSpy resumeSpy(manager.get(), + &DeviceManager::requestStartPrinterSession); + + manager->injectPrinterUdevEventForTesting( + QByteArrayLiteral("usb"), + QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1")), + QStringLiteral("1-1")); + QCOMPARE(resumeSpy.count(), 1); + QCOMPARE(resumeSpy.first().at(1).toULongLong(), + manager->printerGenerationForTesting()); +} + +void PrinterProtocolTests::samePathReenumerationCancelsOldGeneration() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-1"), QStringLiteral("lp0"))); + const QString endpointPath = QDir(devRoot).filePath(QStringLiteral("usb/lp0")); + + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + QSignalSpy connectedSpy(manager.get(), &DeviceManager::deviceConnected); + QSignalSpy rawFailureSpy( + manager.get(), &DeviceManager::printerWorkerDeviceInfoFailedForTesting); + QSignalSpy deliveredFailureSpy( + manager.get(), &DeviceManager::printerDeviceInfoFailed); + QSignalSpy operationsCancelledSpy( + manager.get(), &DeviceManager::printerOperationsCancelled); + QSignalSpy resumeSpy(manager.get(), + &DeviceManager::requestStartPrinterSession); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + QCOMPARE(connectedSpy.count(), 1); + QVERIFY(manager->isPrinterClassConnected()); + const quint64 oldGeneration = manager->printerGenerationForTesting(); + QCOMPARE(operationsCancelledSpy.count(), 0); + + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QVERIFY(manager->adoptPrinterFileDescriptorForTesting(sockets[0], endpointPath)); + + std::atomic_bool requestSeen{false}; + QString peerError; + std::thread peer([&]() { + QByteArray requestBuffer; + if (!serveUdbBootstrap(sockets[1], &peerError, + &requestBuffer)) { + return; + } + panorama::wire::v1::Request sessionRequest; + if (!readRequest(sockets[1], &sessionRequest, &peerError, + kPeerTimeoutMs, &requestBuffer) || + sessionRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + peerError = QStringLiteral("same-path test did not receive session start"); + return; + } + auto sessionResponse = baseResponse(sessionRequest); + sessionResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], sessionResponse, &peerError)) { + return; + } + + panorama::wire::v1::Request request; + bool deviceInfoReceived = false; + for (int index = 0; index < 8; ++index) { + if (!readRequest(sockets[1], &request, &peerError, + kPeerTimeoutMs * 3, &requestBuffer)) { + return; + } + if (request.body_case() == + panorama::wire::v1::Request::kDeviceInformationQuery) { + deviceInfoReceived = true; + break; + } + if (request.body_case() == + panorama::wire::v1::Request::kPing) { + auto response = baseResponse(request); + response.mutable_pong()->set_payload( + request.ping().payload()); + if (!writeResponse(sockets[1], response, &peerError)) { + return; + } + continue; + } + if (request.body_case() == + panorama::wire::v1::Request::kUserConfigurationQuery) { + auto response = baseResponse(request); + auto *userConfig = response.mutable_user_configuration(); + userConfig->mutable_display_config() + ->set_backlight_brightness(75); + userConfig->mutable_work_config() + ->set_single_mode_media_file("default_01.mp4.h264_2240x1080"); + userConfig->mutable_standby_config()->set_enable(true); + if (!writeResponse(sockets[1], response, &peerError)) { + return; + } + continue; + } + if (request.body_case() != + panorama::wire::v1::Request::kOverlayLayout && + request.body_case() != + panorama::wire::v1::Request::kMetricBatch) { + peerError = QStringLiteral( + "same-path test received request body %1 before device info") + .arg(static_cast(request.body_case())); + return; + } + } + if (!deviceInfoReceived) { + peerError = QStringLiteral( + "same-path test did not receive device-info request"); + return; + } + requestSeen.store(true, std::memory_order_release); + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); + if (pollResult <= 0) { + peerError = QStringLiteral("old generation transport was not cancelled"); + return; + } + char byte = 0; + if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) != 0) { + peerError = QStringLiteral("old generation endpoint did not close cleanly"); + } + }); + + QElapsedTimer sessionTimer; + sessionTimer.start(); + while (!manager->printerDisplaySessionActiveForTesting() && + sessionTimer.elapsed() < kPeerTimeoutMs * 3) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + } + const bool sessionWasActive = + manager->printerDisplaySessionActiveForTesting(); + + manager->requestDeviceInfo(); + QElapsedTimer readinessTimer; + readinessTimer.start(); + while (!requestSeen.load(std::memory_order_acquire) && + readinessTimer.elapsed() < kPeerTimeoutMs * 3) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + } + const bool requestWasSeen = + requestSeen.load(std::memory_order_acquire); + resumeSpy.clear(); + + manager->injectPrinterUdevEventForTesting( + QByteArrayLiteral("usb"), + QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1")), + QStringLiteral("1-1")); + const quint64 newGeneration = manager->printerGenerationForTesting(); + const int operationsCancelledCount = operationsCancelledSpy.count(); + const int resumeCount = resumeSpy.count(); - panorama::wire::v1::Response transferStatus; - transferStatus.mutable_header()->set_track_id(17); - transferStatus.mutable_transfer_end_status()->set_status( - panorama::wire::v1::TransferStatus::CHECKSUM_FAILURE); - QCOMPARE(serializedHex(transferStatus), - QByteArray("0a0210119232020803")); + QElapsedTimer cancellationTimer; + cancellationTimer.start(); + while (rawFailureSpy.count() < 1 && + cancellationTimer.elapsed() < kPeerTimeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + } + const bool oldFailureObserved = rawFailureSpy.count() == 1; + const int staleDeliveredCount = deliveredFailureSpy.count(); - panorama::wire::v1::Response asynchronousEvent; - asynchronousEvent.mutable_asynchronous_event()->set_play_finished(true); - QCOMPARE(serializedHex(asynchronousEvent), - QByteArray("da3d020801")); + manager->emitPrinterDeviceInfoFailureForTesting( + QStringLiteral("current-generation-sentinel"), newGeneration); + QElapsedTimer deliveryTimer; + deliveryTimer.start(); + while (deliveredFailureSpy.count() < 1 && + deliveryTimer.elapsed() < kPeerTimeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + } + + peer.join(); + ::close(sockets[1]); + + QVERIFY2(sessionWasActive, qPrintable(peerError)); + QVERIFY2(requestWasSeen, qPrintable(peerError)); + QCOMPARE(newGeneration, oldGeneration + 1); + QCOMPARE(operationsCancelledCount, 1); + QCOMPARE(resumeCount, 1); + QCOMPARE(resumeSpy.first().at(0).toString(), endpointPath); + QCOMPARE(resumeSpy.first().at(1).toULongLong(), newGeneration); + QVERIFY(oldFailureObserved); + QCOMPARE(rawFailureSpy.at(0).at(1).toULongLong(), oldGeneration); + QVERIFY(rawFailureSpy.at(0).at(0).toString().contains( + QStringLiteral("cancel"), Qt::CaseInsensitive)); + QCOMPARE(staleDeliveredCount, 0); + QCOMPARE(deliveredFailureSpy.count(), 1); + QCOMPARE(deliveredFailureSpy.at(0).at(0).toString(), + QStringLiteral("current-generation-sentinel")); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::unknownFieldsSurviveMutation() { - panorama::wire::v1::UserConfiguration original; - original.mutable_work_config()->set_single_mode_media_file("old.h264"); - original.GetReflection()->MutableUnknownFields(&original)->AddVarint(99, 123456); - auto *workUnknown = original.mutable_work_config() - ->GetReflection() - ->MutableUnknownFields(original.mutable_work_config()); - workUnknown->AddLengthDelimited(77, "nested-unknown"); +void PrinterProtocolTests::printerSessionLossCancelsOperationsAndReportsStoppedState() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-1"), + QStringLiteral("lp0"))); - std::string fixture; - QVERIFY(original.SerializeToString(&fixture)); - panorama::wire::v1::UserConfiguration parsed; - QVERIFY(parsed.ParseFromString(fixture)); - parsed.mutable_work_config()->set_single_mode_media_file("new.h264"); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + QVERIFY(manager->isPrinterClassConnected()); + const quint64 activeGeneration = manager->printerGenerationForTesting(); - std::string roundTrip; - QVERIFY(parsed.SerializeToString(&roundTrip)); - panorama::wire::v1::UserConfiguration verified; - QVERIFY(verified.ParseFromString(roundTrip)); - QCOMPARE(QString::fromStdString( - verified.work_config().single_mode_media_file()), - QStringLiteral("new.h264")); - QVERIFY(hasUnknownField( - verified.GetReflection()->GetUnknownFields(verified), 99)); - QVERIFY(hasUnknownField( - verified.work_config().GetReflection()->GetUnknownFields( - verified.work_config()), - 77)); + QSignalSpy cancelledSpy(manager.get(), + &DeviceManager::printerOperationsCancelled); + QSignalSpy disconnectedSpy(manager.get(), &DeviceManager::deviceDisconnected); + QSignalSpy statusSpy(manager.get(), &DeviceManager::uploadStatus); + statusSpy.clear(); + manager->emitPrinterSessionLostForTesting(activeGeneration); + + QTRY_COMPARE(cancelledSpy.count(), 1); + QCOMPARE(disconnectedSpy.count(), 0); + QVERIFY(manager->isPrinterClassConnected()); + QCOMPARE(manager->printerGenerationForTesting(), activeGeneration); + QVERIFY(!manager->printerDisplaySessionActiveForTesting()); + QCOMPARE(statusSpy.count(), 1); + QVERIFY(statusSpy.first().first().toString().contains( + QStringLiteral("new USB endpoint generation"), Qt::CaseInsensitive)); } -void PrinterProtocolTests::discoveryStateSequence() { - QTemporaryDir fixture; - QVERIFY(fixture.isValid()); - const QString sysRoot = QDir(fixture.path()).filePath(QStringLiteral("sys")); - const QString devRoot = QDir(fixture.path()).filePath(QStringLiteral("dev")); +void PrinterProtocolTests::lostPrinterSessionRejectsMutationsBeforeDispatch() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, + QStringLiteral("1-1"), + QStringLiteral("lp0"))); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "0006")); - auto snapshot = PrinterProtocol::discover(sysRoot, devRoot); - QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::RockchipGadget391a0006); - QVERIFY(snapshot.blocksLegacyTransport()); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + const quint64 generation = manager->printerGenerationForTesting(); + manager->emitPrinterSessionLostForTesting(generation); + QTRY_VERIFY(manager->printerDisplaySessionLost_); - QVERIFY(writeTextFile( - QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1/idProduct")), - "1021\n")); - snapshot = PrinterProtocol::discover(sysRoot, devRoot); - QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Enumerating391a1021); + QTemporaryFile source; + QVERIFY(source.open()); + QCOMPARE(source.write(QByteArrayLiteral("source")), 6); + source.flush(); + QSignalSpy preparationSpy(manager.get(), + &DeviceManager::requestPreparePrinterMedia); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), QStringLiteral("lp0"))); - snapshot = PrinterProtocol::discover(sysRoot, devRoot); - QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Ready); - QCOMPARE(snapshot.devices.size(), 1); - QVERIFY(snapshot.devices.first().accessible); + const QString uploadId = + QStringLiteral("61616161-6161-4161-8161-616161616161"); + QCOMPARE(manager->queueUploadOperation(uploadId, source.fileName()), + uploadId); + QCOMPARE(manager->operationInfo(uploadId).state, + QStringLiteral("Failed")); + QCOMPARE(manager->operationInfo(uploadId).errorCategory, + QStringLiteral("SessionLost")); + QCOMPARE(preparationSpy.count(), 0); - QVERIFY(writeTextFile( - QDir(sysRoot).filePath( - QStringLiteral("bus/usb/devices/1-1/1-1:1.0/bInterfaceProtocol")), - "01\n")); - snapshot = PrinterProtocol::discover(sysRoot, devRoot); - QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Enumerating391a1021); - QVERIFY(writeTextFile( - QDir(sysRoot).filePath( - QStringLiteral("bus/usb/devices/1-1/1-1:1.0/bInterfaceProtocol")), - "02\n")); + TryxRuntimeApplyRequest applyRequest; + applyRequest.media = { + QStringLiteral("existing.mp4.h264_2240x1080")}; + applyRequest.ratio = QStringLiteral("2:1"); + applyRequest.screenMode = QStringLiteral("Full Screen"); + applyRequest.playMode = QStringLiteral("Single"); + const QString applyId = + QStringLiteral("62626262-6262-4262-8262-626262626262"); + QCOMPARE(manager->queueApplyOperation(applyId, applyRequest), applyId); + QCOMPARE(manager->operationInfo(applyId).errorCategory, + QStringLiteral("SessionLost")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-2"), "0006")); - snapshot = PrinterProtocol::discover(sysRoot, devRoot); - QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Ambiguous); - QVERIFY(QDir(QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-2"))) - .removeRecursively()); + TryxRuntimeMetricsConfigRequest metricsRequest; + metricsRequest.enabled = true; + metricsRequest.metrics = {QStringLiteral("GPU Temperature")}; + const QString metricsId = + QStringLiteral("63636363-6363-4363-8363-636363636363"); + QCOMPARE(manager->queueMetricsConfigOperation(metricsId, metricsRequest), + metricsId); + QCOMPARE(manager->operationInfo(metricsId).errorCategory, + QStringLiteral("SessionLost")); +} - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-2"), "1021")); - snapshot = PrinterProtocol::discover(sysRoot, devRoot); - QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Ambiguous); - QCOMPARE(snapshot.devices.size(), 1); +void PrinterProtocolTests:: + lostPrinterSessionRequiresObservedRemovalBeforeReconnect() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + const QString usbName = QStringLiteral("1-1"); + const QString lpName = QStringLiteral("lp0"); + QVERIFY(createUsbDevice(sysRoot, usbName, "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, usbName, lpName)); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-2"), QStringLiteral("lp1"))); - snapshot = PrinterProtocol::discover(sysRoot, devRoot); - QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Ambiguous); - QCOMPARE(snapshot.devices.size(), 2); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + QObject::disconnect( + manager.get(), &DeviceManager::requestStartPrinterSession, + manager->worker_, &DeviceWorker::startPrinterDisplaySession); + QSignalSpy startSpy( + manager.get(), &DeviceManager::requestStartPrinterSession); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + QVERIFY(manager->isPrinterClassConnected()); + startSpy.clear(); + + manager->emitPrinterSessionLostForTesting( + manager->printerGenerationForTesting()); + QTRY_VERIFY(manager->printerDisplaySessionLost_); + QVERIFY(!manager->printerSessionLossRemovalObserved_); + + manager->connectDevice(); + QCOMPARE(startSpy.count(), 0); + QVERIFY(manager->printerDisplaySessionLost_); + QVERIFY(!manager->printerSessionLossRemovalObserved_); + + const QString usbDevicePath = + QDir(sysRoot).filePath( + QStringLiteral("bus/usb/devices/") + usbName); + const QString classPath = + QDir(sysRoot).filePath( + QStringLiteral("class/usbmisc/") + lpName); + QVERIFY(QDir(usbDevicePath).removeRecursively()); + QVERIFY(QDir(classPath).removeRecursively()); + QVERIFY(QFile::remove( + QDir(devRoot).filePath( + QStringLiteral("usb/") + lpName))); + manager->rescanPrinterForTesting(); + QVERIFY(manager->printerDisplaySessionLost_); + QVERIFY(manager->printerSessionLossRemovalObserved_); + QCOMPARE(startSpy.count(), 0); - QVERIFY(QDir(QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1"))) - .removeRecursively()); - QVERIFY(QDir(QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-2"))) - .removeRecursively()); - QVERIFY(QDir(QDir(sysRoot).filePath(QStringLiteral("class/usbmisc"))) - .removeRecursively()); - snapshot = PrinterProtocol::discover(sysRoot, devRoot); - QVERIFY(snapshot.state == PrinterProtocol::DiscoveryState::Absent); - QVERIFY(!snapshot.blocksLegacyTransport()); + QVERIFY(createUsbDevice(sysRoot, usbName, "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, usbName, lpName)); + manager->rescanPrinterForTesting(); + QCOMPARE(startSpy.count(), 1); + QVERIFY(!manager->printerDisplaySessionLost_); + QVERIFY(!manager->printerSessionLossRemovalObserved_); } -void PrinterProtocolTests::productionEndpointValidationWithOfflineSysfs() { +void PrinterProtocolTests:: + sessionNotReadyRejectsMutationsBeforeDispatch() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); - const QString sysRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); - const QString devRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), QStringLiteral("lp0"))); - - const QString endpointPath = QDir(devRoot).filePath(QStringLiteral("usb/lp0")); - QVERIFY(QFile::remove(endpointPath)); - const QByteArray encodedEndpoint = QFile::encodeName(endpointPath); - QVERIFY(::symlink("/dev/null", encodedEndpoint.constData()) == 0); + QStringLiteral("1-1"), + QStringLiteral("lp0"))); - struct stat nullStatus {}; - QVERIFY(::stat("/dev/null", &nullStatus) == 0); - QVERIFY(S_ISCHR(nullStatus.st_mode)); - const QString sysfsDevPath = QDir(sysRoot).filePath( - QStringLiteral("class/usbmisc/lp0/dev")); - const QByteArray expectedDeviceNumber = - QByteArray::number(major(nullStatus.st_rdev)) + ':' + - QByteArray::number(minor(nullStatus.st_rdev)) + '\n'; - QVERIFY(writeTextFile(sysfsDevPath, expectedDeviceNumber)); + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + QObject::disconnect( + manager.get(), &DeviceManager::requestStartPrinterSession, + manager->worker_, &DeviceWorker::startPrinterDisplaySession); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + QVERIFY(manager->isPrinterClassConnected()); + QVERIFY(!manager->printerDisplaySessionActive_); + QVERIFY(!manager->printerDisplaySessionLost_); - const int endpointFd = ::open(encodedEndpoint.constData(), - O_RDWR | O_CLOEXEC | O_NONBLOCK); - QVERIFY(endpointFd >= 0); - QString error; - QVERIFY2(PrinterProtocol::validateEndpointForTesting( - endpointPath, endpointFd, sysRoot, devRoot, &error), - qPrintable(error)); + QSignalSpy preparationSpy( + manager.get(), &DeviceManager::requestPreparePrinterMedia); + QSignalSpy applySpy( + manager.get(), &DeviceManager::requestPrinterApplyMedia); + QSignalSpy metricsSpy( + manager.get(), &DeviceManager::requestPrinterConfigureMetrics); + QSignalSpy deleteSpy( + manager.get(), &DeviceManager::requestPrinterDeleteMedia); + QSignalSpy retryValidationSpy( + manager.get(), &DeviceManager::requestValidatePrinterRetryCache); - const QString productPath = QDir(sysRoot).filePath( - QStringLiteral("bus/usb/devices/1-1/idProduct")); - QVERIFY(writeTextFile(productPath, "0006\n")); - QVERIFY(!PrinterProtocol::validateEndpointForTesting( - endpointPath, endpointFd, sysRoot, devRoot, &error)); - QVERIFY(error.contains(QStringLiteral("391a:1021"))); - QVERIFY(writeTextFile(productPath, "1021\n")); + QTemporaryFile source; + QVERIFY(source.open()); + QCOMPARE(source.write(QByteArrayLiteral("source")), 6); + source.flush(); - const QString protocolPath = QDir(sysRoot).filePath( - QStringLiteral("bus/usb/devices/1-1/1-1:1.0/bInterfaceProtocol")); - QVERIFY(writeTextFile(protocolPath, "01\n")); - QVERIFY(!PrinterProtocol::validateEndpointForTesting( - endpointPath, endpointFd, sysRoot, devRoot, &error)); - QVERIFY(error.contains(QStringLiteral("printer interface"))); - QVERIFY(writeTextFile(protocolPath, "02\n")); + const QString uploadId = + QStringLiteral("69696969-6969-4969-8969-696969696961"); + QCOMPARE(manager->queueUploadOperation( + uploadId, source.fileName()), + uploadId); + QCOMPARE(manager->operationInfo(uploadId).errorCategory, + QStringLiteral("SessionNotReady")); - QVERIFY(writeTextFile(sysfsDevPath, "1:1\n")); - QVERIFY(!PrinterProtocol::validateEndpointForTesting( - endpointPath, endpointFd, sysRoot, devRoot, &error)); - QVERIFY(error.contains(QStringLiteral("sysfs"))); - QVERIFY(writeTextFile(sysfsDevPath, expectedDeviceNumber)); + TryxRuntimeApplyRequest applyRequest; + applyRequest.media = { + QStringLiteral("existing.mp4.h264_2240x1080")}; + applyRequest.ratio = QStringLiteral("2:1"); + applyRequest.screenMode = QStringLiteral("Full Screen"); + applyRequest.playMode = QStringLiteral("Single"); + const QString applyId = + QStringLiteral("69696969-6969-4969-8969-696969696962"); + QCOMPARE(manager->queueApplyOperation(applyId, applyRequest), + applyId); + QCOMPARE(manager->operationInfo(applyId).errorCategory, + QStringLiteral("SessionNotReady")); - QVERIFY(QFile::remove(endpointPath)); - QVERIFY(::symlink("/dev/zero", encodedEndpoint.constData()) == 0); - struct stat zeroStatus {}; - QVERIFY(::stat("/dev/zero", &zeroStatus) == 0); - const QByteArray replacementDeviceNumber = - QByteArray::number(major(zeroStatus.st_rdev)) + ':' + - QByteArray::number(minor(zeroStatus.st_rdev)) + '\n'; - QVERIFY(writeTextFile(sysfsDevPath, replacementDeviceNumber)); - QVERIFY2(PrinterProtocol::validateEndpointForTesting( - endpointPath, -1, sysRoot, devRoot, &error), - qPrintable(error)); - QVERIFY(!PrinterProtocol::validateEndpointForTesting( - endpointPath, endpointFd, sysRoot, devRoot, &error)); - QVERIFY(error.contains(QStringLiteral("changed"))); + TryxRuntimeMetricsConfigRequest metricsRequest; + metricsRequest.enabled = true; + metricsRequest.metrics = { + QStringLiteral("GPU Temperature")}; + metricsRequest.alignment = QStringLiteral("Left"); + metricsRequest.textColor = 0xDCDCDCU; + const QString metricsId = + QStringLiteral("69696969-6969-4969-8969-696969696963"); + QCOMPARE(manager->queueMetricsConfigOperation( + metricsId, metricsRequest), + metricsId); + QCOMPARE(manager->operationInfo(metricsId).errorCategory, + QStringLiteral("SessionNotReady")); - const QString unverifiedPath = QDir(devRoot).filePath(QStringLiteral("lp0")); - QVERIFY(!PrinterProtocol::validateEndpointForTesting( - unverifiedPath, endpointFd, sysRoot, devRoot, &error)); - QVERIFY(error.contains(QStringLiteral("unverified"))); - const QString signedEndpointPath = QDir(devRoot).filePath( - QStringLiteral("usb/lp+1")); - QVERIFY(!PrinterProtocol::validateEndpointForTesting( - signedEndpointPath, endpointFd, sysRoot, devRoot, &error)); - QVERIFY(error.contains(QStringLiteral("unverified"))); - ::close(endpointFd); -} + const QString deleteId = + QStringLiteral("69696969-6969-4969-8969-696969696964"); + QCOMPARE(manager->queueDeleteMediaOperation( + deleteId, + {QStringLiteral("existing.mp4.h264_2240x1080")}), + deleteId); + QCOMPARE(manager->operationInfo(deleteId).errorCategory, + QStringLiteral("SessionNotReady")); -void PrinterProtocolTests::samePathEndpointEventForcesNewEpochSignal() { - QTemporaryDir temporaryDirectory; - QVERIFY(temporaryDirectory.isValid()); - const QString sysRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); - const QString devRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), QStringLiteral("lp0"))); + const QString retrySourceId = + QStringLiteral("69696969-6969-4969-8969-696969696965"); + DeviceManager::OperationRecord retrySource; + retrySource.info.id = retrySourceId; + retrySource.info.kind = QStringLiteral("Upload"); + retrySource.info.state = QStringLiteral("RetryAvailable"); + retrySource.info.retryMode = QStringLiteral("PreparedMedia"); + retrySource.info.subject = QStringLiteral("prepared.mp4"); + manager->operations_.insert(retrySourceId, retrySource); + manager->operationOrder_.append(retrySourceId); - PrinterDeviceMonitor monitor; - monitor.setDiscoveryRootsForTesting(sysRoot, devRoot); - QSignalSpy snapshotSpy(&monitor, &PrinterDeviceMonitor::snapshotChanged); - monitor.rescanForTesting(false); - QCOMPARE(snapshotSpy.count(), 1); - const PrinterProtocol::DiscoverySnapshot first = monitor.snapshot(); - QVERIFY(first.state == PrinterProtocol::DiscoveryState::Ready); - QCOMPARE(first.devices.size(), 1); - QCOMPARE(first.devices.first().devicePath, - QDir(devRoot).filePath(QStringLiteral("usb/lp0"))); + const QString retryId = + QStringLiteral("69696969-6969-4969-8969-696969696966"); + QCOMPARE(manager->retryOperation(retrySourceId, retryId), + retryId); + QCOMPARE(manager->operationInfo(retryId).errorCategory, + QStringLiteral("SessionNotReady")); - monitor.rescanForTesting(false); - QCOMPARE(snapshotSpy.count(), 1); - monitor.injectUdevEventForTesting( - QByteArrayLiteral("usb"), - QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1")), - QStringLiteral("1-1")); - QCOMPARE(snapshotSpy.count(), 2); - QVERIFY(monitor.snapshot() == first); + QCOMPARE(preparationSpy.count(), 0); + QCOMPARE(applySpy.count(), 0); + QCOMPARE(metricsSpy.count(), 0); + QCOMPARE(deleteSpy.count(), 0); + QCOMPARE(retryValidationSpy.count(), 0); + QVERIFY(manager->activeOperationInfo().id.isEmpty()); } -void PrinterProtocolTests::paseUdevReadinessUsesUsbDeviceEvents() { - const auto usbAdd = PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usb"), QByteArrayLiteral("add"), - QByteArrayLiteral("391a/1021/100")); - QCOMPARE(usbAdd, qMakePair(true, false)); +void PrinterProtocolTests:: + firmwareExclusiveGateRejectsDeviceWork() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + std::unique_ptr manager( + DeviceManager::createForTesting( + sysRoot, devRoot)); - const auto usbBind = PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usb"), QByteArrayLiteral("bind"), - QByteArrayLiteral("391A/1021/100")); - QCOMPARE(usbBind, qMakePair(false, false)); + QSignalSpy quiescedSpy( + manager.get(), + &DeviceManager::firmwareTransportQuiesced); + QSignalSpy connectSpy( + manager.get(), &DeviceManager::requestConnect); + QSignalSpy keepaliveSpy( + manager.get(), &DeviceManager::requestKeepalive); + QSignalSpy disconnectSpy( + manager.get(), &DeviceManager::requestDisconnect); + QSignalSpy brightnessSpy( + manager.get(), &DeviceManager::requestBrightness); + QSignalSpy screenConfigSpy( + manager.get(), &DeviceManager::requestScreenConfig); + QSignalSpy rotationSpy( + manager.get(), &DeviceManager::requestRotation); + QSignalSpy rebootSpy( + manager.get(), &DeviceManager::requestReboot); + QSignalSpy legacyDeleteSpy( + manager.get(), &DeviceManager::requestDeleteMedia); + QSignalSpy legacyUploadSpy( + manager.get(), &DeviceManager::requestUploadMedia); + QSignalSpy legacyRefreshSpy( + manager.get(), &DeviceManager::requestRefreshMedia); + QSignalSpy sysinfoSpy( + manager.get(), &DeviceManager::requestSysinfo); + QSignalSpy preparationSpy( + manager.get(), + &DeviceManager::requestPreparePrinterMedia); + QSignalSpy applySpy( + manager.get(), + &DeviceManager::requestPrinterApplyMedia); + QSignalSpy metricsSpy( + manager.get(), + &DeviceManager::requestPrinterConfigureMetrics); + QSignalSpy deleteSpy( + manager.get(), + &DeviceManager::requestPrinterDeleteMedia); + + QString gateError; + QVERIFY2( + manager->acquireFirmwareExclusive( + QStringLiteral("firmware-lease-a"), + &gateError), + qPrintable(gateError)); + QVERIFY(manager->firmwareExclusiveActive()); + QTRY_COMPARE(quiescedSpy.count(), 1); + QCOMPARE( + quiescedSpy.first().at(0).toString(), + QStringLiteral("firmware-lease-a")); + QVERIFY(quiescedSpy.first().at(1).toBool()); + + QString secondGateError; + QVERIFY(!manager->acquireFirmwareExclusive( + QStringLiteral("firmware-lease-b"), + &secondGateError)); + QVERIFY(!secondGateError.isEmpty()); + + manager->connectDevice( + QStringLiteral("/dev/tty-test")); + manager->startKeepalive(1); + manager->connected_ = true; + manager->disconnectDevice(); + manager->setBrightness(50); + manager->setScreenConfig( + {QStringLiteral("media.h264")}); + manager->setRotation(90); + manager->rebootDevice(); + manager->deleteMedia( + {QStringLiteral("media.h264")}); + manager->uploadMedia( + QStringLiteral("/tmp/media.mp4")); + manager->refreshMediaList(); + manager->sendSysinfo( + {QStringLiteral("CPU Temperature")}, + {QStringLiteral("42")}, + {QStringLiteral("C")}); + manager->connected_ = false; + QCOMPARE(connectSpy.count(), 0); + QCOMPARE(keepaliveSpy.count(), 0); + QCOMPARE(disconnectSpy.count(), 0); + QCOMPARE(brightnessSpy.count(), 0); + QCOMPARE(screenConfigSpy.count(), 0); + QCOMPARE(rotationSpy.count(), 0); + QCOMPARE(rebootSpy.count(), 0); + QCOMPARE(legacyDeleteSpy.count(), 0); + QCOMPARE(legacyUploadSpy.count(), 0); + QCOMPARE(legacyRefreshSpy.count(), 0); + QCOMPARE(sysinfoSpy.count(), 0); - const auto usbmiscAdd = PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usbmisc"), QByteArrayLiteral("add"), QByteArray()); - QCOMPARE(usbmiscAdd, qMakePair(false, false)); + QTemporaryFile source; + QVERIFY(source.open()); + QCOMPARE( + source.write(QByteArrayLiteral("source")), 6); + source.flush(); + const QString uploadId = + QStringLiteral( + "71717171-7171-4171-8171-717171717171"); + QCOMPARE( + manager->queueUploadOperation( + uploadId, source.fileName()), + uploadId); + QCOMPARE( + manager->operationInfo(uploadId).errorCategory, + QStringLiteral("FirmwareUpdateActive")); - const auto usbmiscRemove = PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usbmisc"), QByteArrayLiteral("remove"), QByteArray()); - QCOMPARE(usbmiscRemove, qMakePair(false, false)); + TryxRuntimeApplyRequest applyRequest; + applyRequest.media = { + QStringLiteral( + "existing.mp4.h264_2240x1080")}; + const QString applyId = + QStringLiteral( + "72727272-7272-4272-8272-727272727272"); + QCOMPARE( + manager->queueApplyOperation( + applyId, applyRequest), + applyId); + QCOMPARE( + manager->operationInfo(applyId).errorCategory, + QStringLiteral("FirmwareUpdateActive")); - const auto usbRemove = PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usb"), QByteArrayLiteral("remove"), - QByteArrayLiteral("391a/1021/100"), true); - QCOMPARE(usbRemove, qMakePair(true, true)); + TryxRuntimeMetricsConfigRequest metricsRequest; + metricsRequest.enabled = true; + metricsRequest.metrics = { + QStringLiteral("CPU Temperature")}; + const QString metricsId = + QStringLiteral( + "73737373-7373-4373-8373-737373737373"); + QCOMPARE( + manager->queueMetricsConfigOperation( + metricsId, metricsRequest), + metricsId); + QCOMPARE( + manager->operationInfo(metricsId).errorCategory, + QStringLiteral("FirmwareUpdateActive")); - const auto unrelatedRemove = - PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usb"), QByteArrayLiteral("remove"), - QByteArrayLiteral("0db0/84df/0")); - QCOMPARE(unrelatedRemove, qMakePair(false, false)); + const QString deleteId = + QStringLiteral( + "74747474-7474-4474-8474-747474747474"); + QCOMPARE( + manager->queueDeleteMediaOperation( + deleteId, + {QStringLiteral( + "existing.mp4.h264_2240x1080")}), + deleteId); + QCOMPARE( + manager->operationInfo(deleteId).errorCategory, + QStringLiteral("FirmwareUpdateActive")); - const auto unknownUnrelatedRemove = - PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usb"), QByteArrayLiteral("remove"), - QByteArray()); - QCOMPARE(unknownUnrelatedRemove, qMakePair(false, false)); + const QString stageId = + QStringLiteral( + "75757575-7575-4575-8575-757575757575"); + QCOMPARE( + manager->queueStageDeviceMediaOperation( + stageId, QString(64, QLatin1Char('a')), + QStringLiteral(":1.99")), + stageId); + QCOMPARE( + manager->operationInfo(stageId).errorCategory, + QStringLiteral("FirmwareUpdateActive")); - const auto currentPaseRemoveWithoutProduct = - PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usb"), QByteArrayLiteral("remove"), - QByteArray(), true); - QCOMPARE(currentPaseRemoveWithoutProduct, - qMakePair(true, true)); + QCOMPARE(preparationSpy.count(), 0); + QCOMPARE(applySpy.count(), 0); + QCOMPARE(metricsSpy.count(), 0); + QCOMPARE(deleteSpy.count(), 0); + QVERIFY(manager->activeOperationInfo().id.isEmpty()); - const auto transitionAdd = PrinterDeviceMonitor::eventPolicyForTesting( - QByteArrayLiteral("usb"), QByteArrayLiteral("add"), - QByteArrayLiteral("391a/0006/100")); - QCOMPARE(transitionAdd, qMakePair(true, false)); + manager->releaseFirmwareExclusive( + QStringLiteral("wrong-lease")); + QVERIFY(manager->firmwareExclusiveActive()); + manager->releaseFirmwareExclusive( + QStringLiteral("firmware-lease-a"), false); + QTRY_VERIFY( + !manager->firmwareExclusiveActive()); + + manager->activeOperationId_ = + QStringLiteral("active-operation"); + QString activeError; + QVERIFY(!manager->acquireFirmwareExclusive( + QStringLiteral("firmware-lease-c"), + &activeError)); + QVERIFY(activeError.contains( + QStringLiteral("active-operation"))); + manager->activeOperationId_.clear(); } -void PrinterProtocolTests::unrelatedUsbRemoveDoesNotRestartPaseSession() { +void PrinterProtocolTests:: + firmwareExclusiveGateSuppressesReconnectUntilRelease() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); const QString devRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), - QStringLiteral("lp0"))); + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); std::unique_ptr manager( - DeviceManager::createForTesting(sysRoot, devRoot)); + DeviceManager::createForTesting( + sysRoot, devRoot)); + QSignalSpy configureSpy( + manager.get(), + &DeviceManager::requestConfigurePrinter); + QSignalSpy sessionSpy( + manager.get(), + &DeviceManager::requestStartPrinterSession); + QSignalSpy quiescedSpy( + manager.get(), + &DeviceManager::firmwareTransportQuiesced); + manager->setAutoConnectModeForTesting(true); manager->rescanPrinterForTesting(); QVERIFY(manager->isPrinterClassConnected()); - const quint64 generation = - manager->printerGenerationForTesting(); - manager->printerMonitor_->snapshot_.devices.first().sysfsPath = - QDir(sysRoot).filePath( - QStringLiteral("bus/usb/devices/1-1")); - QSignalSpy sessionStartSpy( - manager.get(), &DeviceManager::requestStartPrinterSession); - - manager->injectPrinterUdevEventForTesting( - QByteArrayLiteral("usb"), - QDir(sysRoot).filePath( - QStringLiteral("bus/usb/devices/1-10.4")), - QStringLiteral("1-10.4")); + QVERIFY(configureSpy.count() >= 1); + configureSpy.clear(); + sessionSpy.clear(); + + QString gateError; + QVERIFY2( + manager->acquireFirmwareExclusive( + QStringLiteral("firmware-reconnect-lease"), + &gateError), + qPrintable(gateError)); + QTRY_COMPARE(quiescedSpy.count(), 1); + QVERIFY(!manager->isPrinterClassConnected()); - QCOMPARE(manager->printerGenerationForTesting(), generation); - QCOMPARE(sessionStartSpy.count(), 0); + manager->rescanPrinterForTesting(); + QCOMPARE(configureSpy.count(), 0); + QCOMPARE(sessionSpy.count(), 0); + QVERIFY(!manager->isPrinterClassConnected()); + + manager->releaseFirmwareExclusive( + QStringLiteral("firmware-reconnect-lease"), + true); + QVERIFY(manager->firmwareExclusiveActive()); + QTRY_COMPARE(configureSpy.count(), 1); + QTRY_COMPARE(sessionSpy.count(), 1); + QTRY_VERIFY( + !manager->firmwareExclusiveActive()); + QVERIFY(manager->isPrinterClassConnected()); } -void PrinterProtocolTests::passivePrinterReconnectDoesNotRequestSessionResume() { +void PrinterProtocolTests:: + firmwareExclusiveGateRejectsUnresolvedDeviceState() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); const QString devRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), - QStringLiteral("lp0"))); - + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); std::unique_ptr manager( - DeviceManager::createForTesting(sysRoot, devRoot)); - manager->setAutoConnectModeForTesting(true); - manager->rescanPrinterForTesting(); - QVERIFY(manager->isPrinterClassConnected()); - QSignalSpy resumeSpy(manager.get(), - &DeviceManager::requestStartPrinterSession); + DeviceManager::createForTesting( + sysRoot, devRoot)); + + const auto expectRejected = + [&manager](const QString &leaseId, + const QString &expectedText) { + QString error; + QVERIFY(!manager->acquireFirmwareExclusive( + leaseId, &error)); + QVERIFY2( + error.contains(expectedText, + Qt::CaseInsensitive), + qPrintable(error)); + QVERIFY(!manager->firmwareExclusiveActive()); + }; - manager->injectPrinterUdevEventForTesting( - QByteArrayLiteral("usb"), - QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1")), - QStringLiteral("1-1")); - QCOMPARE(resumeSpy.count(), 1); - QCOMPARE(resumeSpy.first().at(1).toULongLong(), - manager->printerGenerationForTesting()); -} + manager->pendingDeleteOperationId_ = + QStringLiteral("pending-delete"); + expectRejected( + QStringLiteral("firmware-delete-lease"), + QStringLiteral("delete")); + manager->pendingDeleteOperationId_.clear(); + + manager->pendingReplaceJournalOperationId_ = + QStringLiteral("pending-replace"); + expectRejected( + QStringLiteral("firmware-replace-lease"), + QStringLiteral("replacement")); + manager->pendingReplaceJournalOperationId_.clear(); + + manager->printerRecoveryRequired_ = true; + expectRejected( + QStringLiteral("firmware-recovery-lease"), + QStringLiteral("recovery")); + manager->printerRecoveryRequired_ = false; + + manager->printerDisplaySessionLost_ = true; + expectRejected( + QStringLiteral("firmware-session-lease"), + QStringLiteral("session")); + manager->printerDisplaySessionLost_ = false; -void PrinterProtocolTests::samePathReenumerationCancelsOldGeneration() { - QTemporaryDir temporaryDirectory; - QVERIFY(temporaryDirectory.isValid()); - const QString sysRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); - const QString devRoot = QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), QStringLiteral("lp0"))); - const QString endpointPath = QDir(devRoot).filePath(QStringLiteral("usb/lp0")); + const QString retryOperationId = + QStringLiteral( + "76767676-7676-4676-8676-767676767676"); + DeviceManager::OperationRecord retryRecord; + retryRecord.info.id = retryOperationId; + retryRecord.info.terminalOutcome = + QStringLiteral("FinalizationUnknown"); + retryRecord.uploadFinalizationReconciliationPending = + true; + manager->operations_.insert( + retryOperationId, retryRecord); + manager->retryCacheOperationId_ = retryOperationId; + expectRejected( + QStringLiteral("firmware-retry-lease"), + QStringLiteral("unresolved")); + manager->retryCacheOperationId_.clear(); + manager->operations_.remove(retryOperationId); - std::unique_ptr manager( - DeviceManager::createForTesting(sysRoot, devRoot)); - QSignalSpy connectedSpy(manager.get(), &DeviceManager::deviceConnected); - QSignalSpy rawFailureSpy( - manager.get(), &DeviceManager::printerWorkerDeviceInfoFailedForTesting); - QSignalSpy deliveredFailureSpy( - manager.get(), &DeviceManager::printerDeviceInfoFailed); - QSignalSpy operationsCancelledSpy( - manager.get(), &DeviceManager::printerOperationsCancelled); - QSignalSpy resumeSpy(manager.get(), - &DeviceManager::requestStartPrinterSession); - manager->setAutoConnectModeForTesting(true); - manager->rescanPrinterForTesting(); - QCOMPARE(connectedSpy.count(), 1); - QVERIFY(manager->isPrinterClassConnected()); - const quint64 oldGeneration = manager->printerGenerationForTesting(); - QCOMPARE(operationsCancelledSpy.count(), 0); + QVERIFY(QDir().mkpath( + QFileInfo(manager->deleteIntentPath()).absolutePath())); + QFile deleteIntent(manager->deleteIntentPath()); + QVERIFY(deleteIntent.open( + QIODevice::WriteOnly | QIODevice::Truncate)); + QCOMPARE(deleteIntent.write("{}"), 2); + deleteIntent.close(); + expectRejected( + QStringLiteral("firmware-delete-file-lease"), + QStringLiteral("delete")); + QVERIFY(deleteIntent.remove()); + + QFile replaceIntent(manager->replaceIntentPath()); + QVERIFY(replaceIntent.open( + QIODevice::WriteOnly | QIODevice::Truncate)); + QCOMPARE(replaceIntent.write("{}"), 2); + replaceIntent.close(); + expectRejected( + QStringLiteral("firmware-replace-file-lease"), + QStringLiteral("replacement")); + QVERIFY(replaceIntent.remove()); +} +void PrinterProtocolTests:: + firmwareWorkerQuiesceClosesTransport() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - QVERIFY(manager->adoptPrinterFileDescriptorForTesting(sockets[0], endpointPath)); + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); - std::atomic_bool requestSeen{false}; + constexpr quint64 generation = 81; + DeviceWorker worker; + worker.updatePrinterGenerationGate( + generation, false); + worker.adoptPrinterFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + QSignalSpy quiescedSpy( + &worker, + &DeviceWorker::firmwareTransportQuiesced); + worker.quiesceForFirmware( + QStringLiteral("firmware-lease"), + generation); + + QCOMPARE(quiescedSpy.count(), 1); + QCOMPARE( + quiescedSpy.first().at(0).toString(), + QStringLiteral("firmware-lease")); + QCOMPARE( + quiescedSpy.first().at(1).toULongLong(), + generation); QString peerError; - std::thread peer([&]() { - QByteArray requestBuffer; - if (!serveUdbBootstrap(sockets[1], &peerError, - &requestBuffer)) { - return; - } - panorama::wire::v1::Request sessionRequest; - if (!readRequest(sockets[1], &sessionRequest, &peerError, - kPeerTimeoutMs, &requestBuffer) || - sessionRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { - peerError = QStringLiteral("same-path test did not receive session start"); - return; - } - auto sessionResponse = baseResponse(sessionRequest); - sessionResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], sessionResponse, &peerError)) { - return; - } + QVERIFY2( + waitForPeerClosureWithoutPayload( + sockets[1], kPeerTimeoutMs, &peerError), + qPrintable(peerError)); + ::close(sockets[1]); +} - panorama::wire::v1::Request request; - bool deviceInfoReceived = false; - for (int index = 0; index < 8; ++index) { - if (!readRequest(sockets[1], &request, &peerError, - kPeerTimeoutMs * 3, &requestBuffer)) { - return; - } - if (request.body_case() == - panorama::wire::v1::Request::kDeviceInformationQuery) { - deviceInfoReceived = true; - break; - } - if (request.body_case() == - panorama::wire::v1::Request::kPing) { - auto response = baseResponse(request); - response.mutable_pong()->set_payload( - request.ping().payload()); - if (!writeResponse(sockets[1], response, &peerError)) { - return; - } - continue; - } - if (request.body_case() == - panorama::wire::v1::Request::kUserConfigurationQuery) { - auto response = baseResponse(request); - auto *userConfig = response.mutable_user_configuration(); - userConfig->mutable_display_config() - ->set_backlight_brightness(75); - userConfig->mutable_work_config() - ->set_single_mode_media_file("default_01.mp4.h264_2240x1080"); - userConfig->mutable_standby_config()->set_enable(true); - if (!writeResponse(sockets[1], response, &peerError)) { - return; - } - continue; - } - if (request.body_case() != - panorama::wire::v1::Request::kOverlayLayout && - request.body_case() != - panorama::wire::v1::Request::kMetricBatch) { - peerError = QStringLiteral( - "same-path test received request body %1 before device info") - .arg(static_cast(request.body_case())); - return; - } - } - if (!deviceInfoReceived) { - peerError = QStringLiteral( - "same-path test did not receive device-info request"); - return; - } - requestSeen.store(true, std::memory_order_release); - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); - if (pollResult <= 0) { - peerError = QStringLiteral("old generation transport was not cancelled"); - return; - } - char byte = 0; - if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) != 0) { - peerError = QStringLiteral("old generation endpoint did not close cleanly"); - } - }); +void PrinterProtocolTests:: + firmwareReleaseFenceWaitsForLateQuiesce() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); - QElapsedTimer sessionTimer; - sessionTimer.start(); - while (!manager->printerDisplaySessionActiveForTesting() && - sessionTimer.elapsed() < kPeerTimeoutMs * 3) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 10); - } - const bool sessionWasActive = - manager->printerDisplaySessionActiveForTesting(); + std::unique_ptr manager( + DeviceManager::createForTesting( + sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + QSignalSpy configureSpy( + manager.get(), + &DeviceManager::requestConfigurePrinter); + QSignalSpy sessionSpy( + manager.get(), + &DeviceManager::requestStartPrinterSession); + QSignalSpy quiescedSpy( + manager.get(), + &DeviceManager::firmwareTransportQuiesced); + manager->rescanPrinterForTesting(); + QVERIFY(manager->isPrinterClassConnected()); - manager->requestDeviceInfo(); - QElapsedTimer readinessTimer; - readinessTimer.start(); - while (!requestSeen.load(std::memory_order_acquire) && - readinessTimer.elapsed() < kPeerTimeoutMs * 3) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 10); - } - const bool requestWasSeen = - requestSeen.load(std::memory_order_acquire); - resumeSpy.clear(); + QSemaphore workerEntered; + QSemaphore releaseWorker; + QVERIFY(QMetaObject::invokeMethod( + manager->worker_, + [&workerEntered, &releaseWorker]() { + workerEntered.release(); + releaseWorker.acquire(); + }, + Qt::QueuedConnection)); + QVERIFY(workerEntered.tryAcquire(1, 5000)); + configureSpy.clear(); + sessionSpy.clear(); - manager->injectPrinterUdevEventForTesting( - QByteArrayLiteral("usb"), - QDir(sysRoot).filePath(QStringLiteral("bus/usb/devices/1-1")), - QStringLiteral("1-1")); - const quint64 newGeneration = manager->printerGenerationForTesting(); - const int operationsCancelledCount = operationsCancelledSpy.count(); - const int resumeCount = resumeSpy.count(); + const QString lease = + QStringLiteral( + "firmware-late-quiesce-lease"); + QString gateError; + QVERIFY2( + manager->acquireFirmwareExclusive( + lease, &gateError), + qPrintable(gateError)); + manager->releaseFirmwareExclusive( + lease, true); + + QCoreApplication::processEvents( + QEventLoop::AllEvents, 100); + QVERIFY(manager->firmwareExclusiveActive()); + QCOMPARE(quiescedSpy.count(), 0); + QCOMPARE(configureSpy.count(), 0); + QCOMPARE(sessionSpy.count(), 0); + + releaseWorker.release(); + QTRY_COMPARE(quiescedSpy.count(), 1); + QTRY_VERIFY( + !manager->firmwareExclusiveActive()); + QTRY_COMPARE(configureSpy.count(), 1); + QTRY_COMPARE(sessionSpy.count(), 1); + QVERIFY(manager->isPrinterClassConnected()); +} - QElapsedTimer cancellationTimer; - cancellationTimer.start(); - while (rawFailureSpy.count() < 1 && - cancellationTimer.elapsed() < kPeerTimeoutMs) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 10); +void PrinterProtocolTests:: + approvedFirmwareStagingPinsBytes() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sourcePath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("approved.zip")); + const QByteArray approvedBytes( + "approved-firmware-payload"); + const QByteArray replacementBytes( + "replaced-firmware-payload"); + QCOMPARE( + approvedBytes.size(), + replacementBytes.size()); + QVERIFY(writeTextFile( + sourcePath, approvedBytes)); + const QString approvedSha = + QString::fromLatin1( + QCryptographicHash::hash( + approvedBytes, + QCryptographicHash::Sha256) + .toHex()); + + std::shared_ptr + stagedDirectory; + QString stagedPath; + QString stagingError; + QVERIFY2( + FirmwareBridge:: + stageApprovedPackageCopy( + sourcePath, + approvedBytes.size(), + approvedSha, + &stagedDirectory, + &stagedPath, + &stagingError), + qPrintable(stagingError)); + QVERIFY(stagedDirectory); + QVERIFY(stagedPath.startsWith( + stagedDirectory->path() + + QLatin1Char('/'))); + QFile stagedFile(stagedPath); + QVERIFY(stagedFile.open( + QIODevice::ReadOnly)); + QCOMPARE( + stagedFile.readAll(), + approvedBytes); + stagedFile.close(); + QVERIFY( + !(QFileInfo(stagedPath).permissions() & + QFileDevice::WriteOwner)); + + QSaveFile replacement(sourcePath); + QVERIFY(replacement.open( + QIODevice::WriteOnly)); + QCOMPARE( + replacement.write(replacementBytes), + replacementBytes.size()); + QVERIFY(replacement.commit()); + + QVERIFY(stagedFile.open( + QIODevice::ReadOnly)); + QCOMPARE( + stagedFile.readAll(), + approvedBytes); + stagedFile.close(); + QString updaterIdentityError; + QVERIFY2( + FirmwareUpdater:: + approvedPackageIdentityMatches( + stagedPath, + approvedBytes.size(), + approvedSha, + &updaterIdentityError), + qPrintable(updaterIdentityError)); + QVERIFY(!FirmwareUpdater:: + approvedPackageIdentityMatches( + sourcePath, + approvedBytes.size(), + approvedSha, + &updaterIdentityError)); + + std::shared_ptr + rejectedDirectory; + QString rejectedPath; + QString rejectedError; + QVERIFY(!FirmwareBridge:: + stageApprovedPackageCopy( + sourcePath, + approvedBytes.size(), + approvedSha, + &rejectedDirectory, + &rejectedPath, + &rejectedError)); + QVERIFY(!rejectedDirectory); + QVERIFY(rejectedPath.isEmpty()); + QVERIFY(rejectedError.contains( + QStringLiteral("identity"), + Qt::CaseInsensitive)); +} + +void PrinterProtocolTests:: + rockchipLoaderIdentityIsFailClosed() { + const QString validOutput = + QStringLiteral( + "List of rockusb connected(1)\n" + "DevNo=1 Vid=0x2207,Pid=0x350a,LocationID=19 Mode=Loader SerialNo=BYZLTRYX026900\n"); + const auto valid = + FirmwareUpdater:: + parseRockchipLoaderIdentity( + validOutput, + QStringLiteral( + "BYZLTRYX026900")); + QCOMPARE( + static_cast(valid.status), + static_cast( + FirmwareUpdater:: + RockchipProbeStatus::Valid)); + QCOMPARE(valid.deviceNumber, 1); + QCOMPARE(valid.vendorId, quint16(0x2207)); + QCOMPARE(valid.productId, quint16(0x350a)); + QCOMPARE(valid.locationId, + QStringLiteral("19")); + QCOMPARE(valid.mode, + QStringLiteral("Loader")); + QCOMPARE(valid.serial, + QStringLiteral("BYZLTRYX026900")); + + const QStringList unsafeOutputs = { + QStringLiteral( + "DevNo=1 Vid=0x2207,Pid=0x350a,LocationID=19 Mode=Loader SerialNo=BYZLTRYX026900\n"), + QStringLiteral( + "List of rockusb connected(2)\n" + "DevNo=1 Vid=0x2207,Pid=0x350a,LocationID=19 Mode=Loader SerialNo=BYZLTRYX026900\n" + "DevNo=2 Vid=0x2207,Pid=0x350a,LocationID=20 Mode=Loader SerialNo=OTHERTRYX000001\n"), + QStringLiteral( + "List of rockusb connected(1)\n" + "DevNo=1 Vid=0x2207,Pid=0x350a,LocationID=19 Mode=Maskrom SerialNo=BYZLTRYX026900\n"), + QStringLiteral( + "List of rockusb connected(1)\n" + "DevNo=1 Vid=0x1234,Pid=0x350a,LocationID=19 Mode=Loader SerialNo=BYZLTRYX026900\n"), + QStringLiteral( + "List of rockusb connected(1)\n" + "DevNo=1 Vid=0x2207,Pid=0x1234,LocationID=19 Mode=Loader SerialNo=BYZLTRYX026900\n"), + QStringLiteral( + "List of rockusb connected(1)\n" + "DevNo=1 Vid=0x2207,Pid=0x350a,LocationID=19 Mode=Loader SerialNo=\n"), + QStringLiteral( + "List of rockusb connected(1)\n" + "DevNo=1 Vid=0x2207,Pid=0x350a,LocationID=19 Mode=Loader SerialNo=UNRELATED0001\n"), + QStringLiteral( + "List of rockusb connected(1)\n" + "DevNo=broken Vid=0x2207,Pid=0x350a,LocationID=19 Mode=Loader SerialNo=BYZLTRYX026900\n") + }; + for (const QString &output : + unsafeOutputs) { + const auto identity = + FirmwareUpdater:: + parseRockchipLoaderIdentity( + output); + QCOMPARE( + static_cast( + identity.status), + static_cast( + FirmwareUpdater:: + RockchipProbeStatus:: + Unsafe)); + QVERIFY2( + !identity.error.isEmpty(), + qPrintable(output)); } - const bool oldFailureObserved = rawFailureSpy.count() == 1; - const int staleDeliveredCount = deliveredFailureSpy.count(); - manager->emitPrinterDeviceInfoFailureForTesting( - QStringLiteral("current-generation-sentinel"), newGeneration); - QElapsedTimer deliveryTimer; - deliveryTimer.start(); - while (deliveredFailureSpy.count() < 1 && - deliveryTimer.elapsed() < kPeerTimeoutMs) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + const auto mismatch = + FirmwareUpdater:: + parseRockchipLoaderIdentity( + validOutput, + QStringLiteral( + "DIFFERENTTRYX0001")); + QCOMPARE( + static_cast(mismatch.status), + static_cast( + FirmwareUpdater:: + RockchipProbeStatus::Unsafe)); + QVERIFY(mismatch.error.contains( + QStringLiteral("does not match"), + Qt::CaseInsensitive)); + + const auto none = + FirmwareUpdater:: + parseRockchipLoaderIdentity( + QStringLiteral( + "List of rockusb connected(0)\n")); + QCOMPARE( + static_cast(none.status), + static_cast( + FirmwareUpdater:: + RockchipProbeStatus:: + NoDevice)); + + auto changed = valid; + changed.locationId = + QStringLiteral("20"); + QVERIFY(!FirmwareUpdater:: + sameRockchipIdentity( + valid, changed)); +} + +void PrinterProtocolTests:: + rockchipRciRequiresRk3568() { + QString error; + QVERIFY2( + FirmwareUpdater:: + rockchipChipInfoIsRk3568( + QStringLiteral( + "Chip Info: 38 36 35 33 00 00 00 00\n"), + &error), + qPrintable(error)); + QVERIFY(error.isEmpty()); + + QVERIFY(!FirmwareUpdater:: + rockchipChipInfoIsRk3568( + QStringLiteral( + "Chip Info: 39 36 35 33 00 00\n"), + &error)); + QVERIFY(error.contains( + QStringLiteral("RK3568"), + Qt::CaseInsensitive)); + QVERIFY(!FirmwareUpdater:: + rockchipChipInfoIsRk3568( + QStringLiteral( + "Rockchip device ready\n"), + &error)); + QVERIFY(!error.isEmpty()); +} + +void PrinterProtocolTests:: + rockchipWritesAreIdentityFenced() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString toolPath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("upgrade_tool")); + const QByteArray toolScript = + QByteArrayLiteral( + "#!/bin/sh\n" + "printf '%s\\n' \"$*\" >> \"$0.log\"\n" + "case \"$1\" in\n" + " LD)\n" + " printf '%s\\n' 'List of rockusb connected(1)'\n" + " printf '%s\\n' 'DevNo=1 Vid=0x2207,Pid=0x350a,LocationID=19 Mode=Loader SerialNo=BYZLTRYX026900'\n" + " ;;\n" + " RCI)\n" + " printf '%s\\n' 'Chip Info: 38 36 35 33 00 00 00 00'\n" + " ;;\n" + "esac\n" + "exit 0\n"); + QVERIFY(writeTextFile( + toolPath, toolScript)); + QVERIFY(QFile::setPermissions( + toolPath, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); + + const QString firmwareDirectory = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("firmware")); + QVERIFY(QDir().mkpath( + firmwareDirectory)); + const QString loaderPath = + QDir(firmwareDirectory).filePath( + QStringLiteral( + "MiniLoaderAll.bin")); + const QString parameterPath = + QDir(firmwareDirectory).filePath( + QStringLiteral("parameter.txt")); + const QString rootfsPath = + QDir(firmwareDirectory).filePath( + QStringLiteral("rootfs.img")); + const QString startMarkerPath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("start.bin")); + const QString completeMarkerPath = + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("complete.bin")); + QVERIFY(writeTextFile(loaderPath, "loader")); + QVERIFY(writeTextFile(parameterPath, "parameter")); + QVERIFY(writeTextFile(rootfsPath, "rootfs")); + QVERIFY(writeTextFile(startMarkerPath, "start")); + QVERIFY(writeTextFile( + completeMarkerPath, "complete")); + + FirmwareUpdater updater; + updater.updateMode_ = + FirmwareUpdater::UpdateMode:: + RockchipLoader; + updater.upgradeToolPath_ = toolPath; + updater.selectedSerial_ = + QStringLiteral("BYZLTRYX026900"); + updater.rockchipFirmwareDir_ = + firmwareDirectory; + updater.rockchipStartMarkerPath_ = + startMarkerPath; + updater.rockchipCompleteMarkerPath_ = + completeMarkerPath; + updater.rockchipPartitions_ = { + QStringLiteral("rootfs")}; + updater.rockchipPartitionOffsets_.insert( + QStringLiteral("rootfs"), + QStringLiteral("0x1000")); + updater.rockchipPartitionTotal_ = 1; + QSignalSpy finishedSpy( + &updater, &FirmwareUpdater::finished); + QSignalSpy irreversibleSpy( + &updater, + &FirmwareUpdater::irreversibleStarted); + + updater.startProgramStep( + FirmwareUpdater::Step::DetectLoader, + toolPath, {QStringLiteral("LD")}, + 10000, + QStringLiteral("test loader probe")); + + QTRY_COMPARE_WITH_TIMEOUT( + finishedSpy.count(), 1, 10000); + QCOMPARE( + finishedSpy.first().at(0).toBool(), + true); + QCOMPARE(irreversibleSpy.count(), 1); + + QFile logFile(toolPath + + QStringLiteral(".log")); + QVERIFY(logFile.open( + QIODevice::ReadOnly)); + const QStringList commands = + QString::fromUtf8(logFile.readAll()) + .split( + QLatin1Char('\n'), + Qt::SkipEmptyParts); + QCOMPARE(commands.size(), 14); + QCOMPARE(commands.at(0), + QStringLiteral("LD")); + QCOMPARE(commands.at(1), + QStringLiteral("RCI")); + QCOMPARE(commands.at(2), + QStringLiteral("LD")); + QVERIFY(commands.at(3).startsWith( + QStringLiteral("WL 0x077ff8 "))); + QCOMPARE(commands.at(4), + QStringLiteral("LD")); + QVERIFY(commands.at(5).startsWith( + QStringLiteral("UL "))); + QCOMPARE(commands.at(6), + QStringLiteral("LD")); + QVERIFY(commands.at(7).startsWith( + QStringLiteral("DI -p "))); + QCOMPARE(commands.at(8), + QStringLiteral("LD")); + QVERIFY(commands.at(9).startsWith( + QStringLiteral("WL 0x1000 "))); + QCOMPARE(commands.at(10), + QStringLiteral("LD")); + QVERIFY(commands.at(11).startsWith( + QStringLiteral("WL 0x077ff8 "))); + QCOMPARE(commands.at(12), + QStringLiteral("LD")); + QCOMPARE(commands.at(13), + QStringLiteral("RD")); + + int mutatingCommands = 0; + for (int index = 0; + index < commands.size(); ++index) { + const QString &command = + commands.at(index); + const bool mutating = + command.startsWith( + QStringLiteral("WL ")) || + command.startsWith( + QStringLiteral("UL ")) || + command.startsWith( + QStringLiteral("DI ")) || + command == QStringLiteral("RD"); + if (!mutating) { + continue; + } + ++mutatingCommands; + QVERIFY(index > 0); + QCOMPARE( + commands.at(index - 1), + QStringLiteral("LD")); } + QCOMPARE(mutatingCommands, 6); +} - peer.join(); - ::close(sockets[1]); +void PrinterProtocolTests:: + irreversibleFirmwareTimeoutDoesNotKillProcess() { + const QString sleepExecutable = + QStandardPaths::findExecutable( + QStringLiteral("sleep")); + QVERIFY(!sleepExecutable.isEmpty()); + + const QList + irreversibleSteps = { + FirmwareUpdater::Step:: + RebootRecovery, + FirmwareUpdater::Step:: + FlashPartition + }; + for (const FirmwareUpdater::Step step : + irreversibleSteps) { + FirmwareUpdater updater; + updater.updateMode_ = + step == + FirmwareUpdater::Step:: + RebootRecovery + ? FirmwareUpdater:: + UpdateMode:: + LegacyAdbOta + : FirmwareUpdater:: + UpdateMode:: + RockchipLoader; + updater.currentStep_ = step; + updater.currentProgram_ = + sleepExecutable; + updater.irreversibleStarted_ = true; + updater.rockchipWritesStarted_ = + step != FirmwareUpdater::Step:: + RebootRecovery; + updater.process_ = + new QProcess(&updater); + updater.process_->start( + sleepExecutable, + {QStringLiteral("5")}); + QVERIFY( + updater.process_ + ->waitForStarted(3000)); + QSignalSpy statusSpy( + &updater, + &FirmwareUpdater::statusChanged); + QSignalSpy finishedSpy( + &updater, + &FirmwareUpdater::finished); + + updater.onStepTimedOut(); - QVERIFY2(sessionWasActive, qPrintable(peerError)); - QVERIFY2(requestWasSeen, qPrintable(peerError)); - QCOMPARE(newGeneration, oldGeneration + 1); - QCOMPARE(operationsCancelledCount, 1); - QCOMPARE(resumeCount, 1); - QCOMPARE(resumeSpy.first().at(0).toString(), endpointPath); - QCOMPARE(resumeSpy.first().at(1).toULongLong(), newGeneration); - QVERIFY(oldFailureObserved); - QCOMPARE(rawFailureSpy.at(0).at(1).toULongLong(), oldGeneration); - QVERIFY(rawFailureSpy.at(0).at(0).toString().contains( - QStringLiteral("cancel"), Qt::CaseInsensitive)); - QCOMPARE(staleDeliveredCount, 0); - QCOMPARE(deliveredFailureSpy.count(), 1); - QCOMPARE(deliveredFailureSpy.at(0).at(0).toString(), - QStringLiteral("current-generation-sentinel")); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QCOMPARE( + updater.process_->state(), + QProcess::Running); + QCOMPARE(finishedSpy.count(), 0); + QCOMPARE(statusSpy.count(), 1); + QVERIFY(statusSpy.first() + .at(0) + .toString() + .contains( + QStringLiteral( + "will not be interrupted"), + Qt::CaseInsensitive)); + updater.cleanupProcess(); + updater.currentStep_ = + FirmwareUpdater::Step::Idle; + } } -void PrinterProtocolTests::printerSessionLossCancelsOperationsAndReportsStoppedState() { +void PrinterProtocolTests:: + irreversibleFirmwareFailureDisablesReconnect() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("sys")); const QString devRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), - QStringLiteral("lp0"))); + QDir(temporaryDirectory.path()).filePath( + QStringLiteral("dev")); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); std::unique_ptr manager( - DeviceManager::createForTesting(sysRoot, devRoot)); + DeviceManager::createForTesting( + sysRoot, devRoot)); manager->setAutoConnectModeForTesting(true); manager->rescanPrinterForTesting(); QVERIFY(manager->isPrinterClassConnected()); - const quint64 activeGeneration = manager->printerGenerationForTesting(); + QSignalSpy configureSpy( + manager.get(), + &DeviceManager::requestConfigurePrinter); + configureSpy.clear(); - QSignalSpy cancelledSpy(manager.get(), - &DeviceManager::printerOperationsCancelled); - QSignalSpy disconnectedSpy(manager.get(), &DeviceManager::deviceDisconnected); - QSignalSpy statusSpy(manager.get(), &DeviceManager::uploadStatus); - statusSpy.clear(); - manager->emitPrinterSessionLostForTesting(activeGeneration); + const QString journalPath = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral( + "recovery/interlock.json")); + FirmwareBridge bridge( + manager.get(), nullptr, journalPath); + FirmwareBridge::Approval approval; + approval.kind = + QStringLiteral("RockchipBundle"); + approval.sha256 = + QString(64, QLatin1Char('f')); + QString journalError; + QVERIFY2( + bridge.armRecoveryJournal( + approval, &journalError), + qPrintable(journalError)); + const QString lease = + QStringLiteral( + "firmware-unknown-outcome-lease"); + QString gateError; + QVERIFY2( + manager->acquireFirmwareExclusive( + lease, &gateError), + qPrintable(gateError)); + bridge.firmwareGateLeaseId_ = lease; + bridge.flashBusy_ = true; + bridge.updaterStarted_ = true; + bridge.updaterIrreversibleStarted_ = + true; + QSignalSpy finishedSpy( + &bridge, &FirmwareBridge::finished); + + bridge.handleUpdaterFinished( + false, + QStringLiteral( + "synthetic unknown outcome")); + + QTRY_VERIFY( + !manager->firmwareExclusiveActive()); + QVERIFY(!manager->autoConnectMode_); + QVERIFY( + !manager->isPrinterClassConnected()); + QCOMPARE(configureSpy.count(), 0); + QCOMPARE(finishedSpy.count(), 1); + QVERIFY(finishedSpy.first() + .at(1) + .toString() + .contains( + QStringLiteral( + "explicitly acknowledge"), + Qt::CaseInsensitive)); + const auto recovery = + TryxFirmwareRecoveryJournal( + journalPath) + .load(); + QCOMPARE( + recovery.status, + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded); + QCOMPARE( + recovery.record.phase, + QStringLiteral("Irreversible")); - QTRY_COMPARE(cancelledSpy.count(), 1); - QCOMPARE(disconnectedSpy.count(), 0); - QVERIFY(manager->isPrinterClassConnected()); - QCOMPARE(manager->printerGenerationForTesting(), activeGeneration); - QVERIFY(!manager->printerDisplaySessionActiveForTesting()); - QCOMPARE(statusSpy.count(), 1); - QVERIFY(statusSpy.first().first().toString().contains( - QStringLiteral("new USB endpoint generation"), Qt::CaseInsensitive)); + manager->rescanPrinterForTesting(); + QCOMPARE(configureSpy.count(), 0); + QVERIFY( + !manager->isPrinterClassConnected()); } -void PrinterProtocolTests::lostPrinterSessionRejectsMutationsBeforeDispatch() { +void PrinterProtocolTests:: + firmwareRecoveryJournalPersistsAcrossRestart() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); + const QString journalPath = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral( + "recovery/interlock.json")); + TryxFirmwareRecoveryJournal journal( + journalPath); + const TryxFirmwareRecoveryRecord expected = + firmwareRecoveryRecord( + QStringLiteral("Irreversible")); + QString journalError; + QVERIFY2( + journal.write(expected, &journalError), + qPrintable(journalError)); + + struct stat directoryStatus {}; + struct stat journalStatus {}; + QCOMPARE( + ::stat( + QFile::encodeName( + QFileInfo(journalPath) + .absolutePath()) + .constData(), + &directoryStatus), + 0); + QCOMPARE( + directoryStatus.st_mode & 07777, + static_cast(0700)); + QCOMPARE( + ::lstat( + QFile::encodeName(journalPath) + .constData(), + &journalStatus), + 0); + QVERIFY(S_ISREG(journalStatus.st_mode)); + QCOMPARE( + journalStatus.st_mode & 07777, + static_cast(0600)); + QCOMPARE( + journalStatus.st_nlink, + static_cast(1)); + + const auto loaded = journal.load(); + QCOMPARE( + loaded.status, + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded); + QVERIFY(sameFirmwareRecoveryRecord( + loaded.record, expected)); + + for (int restart = 0; restart < 2; + ++restart) { + FirmwareBridge bridge( + nullptr, nullptr, journalPath); + QVERIFY(bridge.recoveryRequired()); + const QVariantMap state = + bridge.stateForCaller( + QStringLiteral(":1.25")); + QCOMPARE( + state.value( + QStringLiteral( + "recoveryRequired")) + .toBool(), + true); + QCOMPARE( + state.value( + QStringLiteral("apiVersion")) + .toUInt(), + quint32(2)); + QVERIFY( + !state.contains( + QStringLiteral("journalPath"))); + QVERIFY( + !state.contains( + QStringLiteral("approvalToken"))); + } + + QVERIFY( + FirmwareAdaptor::staticMetaObject + .indexOfMethod( + "AcknowledgeFirmwareRecovery()") >= + 0); + const QString sysRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("sys")); const QString devRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), - QStringLiteral("lp0"))); - + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, + QStringLiteral("1-1"), + QStringLiteral("lp0"))); + registerTryxRuntimeMetaTypes(); std::unique_ptr manager( - DeviceManager::createForTesting(sysRoot, devRoot)); - manager->setAutoConnectModeForTesting(true); + DeviceManager::createForTesting( + sysRoot, devRoot)); + TryxRuntimeExportedObject exportedObject; + TryxRuntimeManagerAdaptor connectionAdaptor( + &exportedObject, manager.get()); + FirmwareBridge bridge( + manager.get(), nullptr, journalPath); + QVERIFY( + manager + ->firmwareRecoveryInterlockActive()); + QSignalSpy configureSpy( + manager.get(), + &DeviceManager::requestConfigurePrinter); + QSignalSpy connectSpy( + manager.get(), + &DeviceManager::requestConnect); + manager->rescanPrinterForTesting(); - const quint64 generation = manager->printerGenerationForTesting(); - manager->emitPrinterSessionLostForTesting(generation); - QTRY_VERIFY(manager->printerDisplaySessionLost_); + connectionAdaptor.ConnectDevice( + QString()); + QCoreApplication::processEvents( + QEventLoop::AllEvents, 100); + QCOMPARE(configureSpy.count(), 0); + QCOMPARE(connectSpy.count(), 0); + QVERIFY( + manager + ->firmwareRecoveryInterlockActive()); + QCOMPARE( + journal.load().status, + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded); + + QVERIFY( + bridge.requestRecoveryAcknowledgement( + QStringLiteral(":1.32"))); + QVERIFY( + !manager + ->firmwareRecoveryInterlockActive()); + QTRY_COMPARE(configureSpy.count(), 1); + QCOMPARE( + journal.load().status, + TryxFirmwareRecoveryJournalLoadStatus:: + Missing); +} - QTemporaryFile source; - QVERIFY(source.open()); - QCOMPARE(source.write(QByteArrayLiteral("source")), 6); - source.flush(); - QSignalSpy preparationSpy(manager.get(), - &DeviceManager::requestPreparePrinterMedia); +void PrinterProtocolTests:: + firmwareRecoveryInheritedSafeExitPreservesRecord() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString journalPath = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral( + "recovery/interlock.json")); + TryxFirmwareRecoveryJournal journal( + journalPath); + const TryxFirmwareRecoveryRecord inherited = + firmwareRecoveryRecord( + QStringLiteral( + "AwaitingDeviceVerification"), + QLatin1Char('a')); + QString journalError; + QVERIFY2( + journal.write( + inherited, &journalError), + qPrintable(journalError)); + + FirmwareBridge::Approval approval; + approval.kind = + QStringLiteral("RockchipBundle"); + approval.sha256 = + QString(64, QLatin1Char('b')); - const QString uploadId = - QStringLiteral("61616161-6161-4161-8161-616161616161"); - QCOMPARE(manager->queueUploadOperation(uploadId, source.fileName()), - uploadId); - QCOMPARE(manager->operationInfo(uploadId).state, - QStringLiteral("Failed")); - QCOMPARE(manager->operationInfo(uploadId).errorCategory, - QStringLiteral("SessionLost")); - QCOMPARE(preparationSpy.count(), 0); + { + FirmwareBridge bridge( + nullptr, nullptr, journalPath); + bridge.attemptInheritedRecovery_ = + true; + QVERIFY2( + bridge.armRecoveryJournal( + approval, &journalError), + qPrintable(journalError)); + const auto beforeFailure = + journal.load(); + QVERIFY(sameFirmwareRecoveryRecord( + beforeFailure.record, inherited)); + + bridge.flashBusy_ = true; + bridge.updaterStarted_ = true; + bridge.updaterIrreversibleStarted_ = + false; + bridge.handleUpdaterFinished( + false, + QStringLiteral( + "synthetic safe failure")); - TryxRuntimeApplyRequest applyRequest; - applyRequest.media = { - QStringLiteral("existing.mp4.h264_2240x1080")}; - applyRequest.ratio = QStringLiteral("2:1"); - applyRequest.screenMode = QStringLiteral("Full Screen"); - applyRequest.playMode = QStringLiteral("Single"); - const QString applyId = - QStringLiteral("62626262-6262-4262-8262-626262626262"); - QCOMPARE(manager->queueApplyOperation(applyId, applyRequest), applyId); - QCOMPARE(manager->operationInfo(applyId).errorCategory, - QStringLiteral("SessionLost")); + const auto afterFailure = + journal.load(); + QCOMPARE( + afterFailure.status, + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded); + QVERIFY(sameFirmwareRecoveryRecord( + afterFailure.record, inherited)); + QVERIFY(bridge.recoveryRequired()); + QCOMPARE( + bridge.phase_, + QStringLiteral( + "RecoveryRequired")); + } - TryxRuntimeMetricsConfigRequest metricsRequest; - metricsRequest.enabled = true; - metricsRequest.metrics = {QStringLiteral("GPU Temperature")}; - const QString metricsId = - QStringLiteral("63636363-6363-4363-8363-636363636363"); - QCOMPARE(manager->queueMetricsConfigOperation(metricsId, metricsRequest), - metricsId); - QCOMPARE(manager->operationInfo(metricsId).errorCategory, - QStringLiteral("SessionLost")); + { + FirmwareBridge bridge( + nullptr, nullptr, journalPath); + QTRY_VERIFY(bridge.updater_ != nullptr); + bridge.attemptInheritedRecovery_ = + true; + QVERIFY2( + bridge.armRecoveryJournal( + approval, &journalError), + qPrintable(journalError)); + bridge.flashBusy_ = true; + bridge.updaterStarted_ = false; + bridge.flashOwnerUniqueName_ = + QStringLiteral(":1.25"); + + bridge.requestCancel( + QStringLiteral(":1.25")); + + const auto afterCancel = + journal.load(); + QCOMPARE( + afterCancel.status, + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded); + QVERIFY(sameFirmwareRecoveryRecord( + afterCancel.record, inherited)); + QVERIFY(bridge.recoveryRequired()); + } } void PrinterProtocolTests:: - lostPrinterSessionRequiresObservedRemovalBeforeReconnect() { + firmwareRecoveryCleanSafeExitClearsRecord() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString journalPath = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral( + "recovery/interlock.json")); + TryxFirmwareRecoveryJournal journal( + journalPath); + FirmwareBridge bridge( + nullptr, nullptr, journalPath); + FirmwareBridge::Approval approval; + approval.kind = + QStringLiteral("RockchipBundle"); + approval.sha256 = + QString(64, QLatin1Char('c')); + QString journalError; + + QVERIFY2( + bridge.armRecoveryJournal( + approval, &journalError), + qPrintable(journalError)); + QCOMPARE( + journal.load().status, + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded); + bridge.flashBusy_ = true; + bridge.updaterStarted_ = true; + bridge.handleUpdaterFinished( + false, + QStringLiteral( + "synthetic safe failure")); + QCOMPARE( + journal.load().status, + TryxFirmwareRecoveryJournalLoadStatus:: + Missing); + QVERIFY(!bridge.recoveryRequired()); + + QTRY_VERIFY(bridge.updater_ != nullptr); + QVERIFY2( + bridge.armRecoveryJournal( + approval, &journalError), + qPrintable(journalError)); + bridge.flashBusy_ = true; + bridge.updaterStarted_ = false; + bridge.flashOwnerUniqueName_ = + QStringLiteral(":1.26"); + bridge.requestCancel( + QStringLiteral(":1.26")); + QCOMPARE( + journal.load().status, + TryxFirmwareRecoveryJournalLoadStatus:: + Missing); + QVERIFY(!bridge.recoveryRequired()); +} + +void PrinterProtocolTests:: + firmwareRecoverySuccessRequiresExplicitAcknowledgement() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("sys")); const QString devRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - const QString usbName = QStringLiteral("1-1"); - const QString lpName = QStringLiteral("lp0"); - QVERIFY(createUsbDevice(sysRoot, usbName, "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, usbName, lpName)); + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("dev")); + const QString journalPath = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral( + "recovery/interlock.json")); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, + QStringLiteral("1-1"), + QStringLiteral("lp0"))); std::unique_ptr manager( - DeviceManager::createForTesting(sysRoot, devRoot)); - QObject::disconnect( - manager.get(), &DeviceManager::requestStartPrinterSession, - manager->worker_, &DeviceWorker::startPrinterDisplaySession); - QSignalSpy startSpy( - manager.get(), &DeviceManager::requestStartPrinterSession); - manager->setAutoConnectModeForTesting(true); - manager->rescanPrinterForTesting(); - QVERIFY(manager->isPrinterClassConnected()); - startSpy.clear(); - - manager->emitPrinterSessionLostForTesting( - manager->printerGenerationForTesting()); - QTRY_VERIFY(manager->printerDisplaySessionLost_); - QVERIFY(!manager->printerSessionLossRemovalObserved_); - - manager->connectDevice(); - QCOMPARE(startSpy.count(), 0); - QVERIFY(manager->printerDisplaySessionLost_); - QVERIFY(!manager->printerSessionLossRemovalObserved_); + DeviceManager::createForTesting( + sysRoot, devRoot)); + QSignalSpy configureSpy( + manager.get(), + &DeviceManager::requestConfigurePrinter); + FirmwareBridge bridge( + manager.get(), nullptr, journalPath); + FirmwareBridge::Approval approval; + approval.kind = + QStringLiteral("RockchipBundle"); + approval.sha256 = + QString(64, QLatin1Char('d')); + QString journalError; + QVERIFY2( + bridge.armRecoveryJournal( + approval, &journalError), + qPrintable(journalError)); + + bridge.flashBusy_ = true; + bridge.updaterStarted_ = true; + bridge.handleUpdaterFinished( + true, + QStringLiteral( + "synthetic success")); - const QString usbDevicePath = - QDir(sysRoot).filePath( - QStringLiteral("bus/usb/devices/") + usbName); - const QString classPath = - QDir(sysRoot).filePath( - QStringLiteral("class/usbmisc/") + lpName); - QVERIFY(QDir(usbDevicePath).removeRecursively()); - QVERIFY(QDir(classPath).removeRecursively()); - QVERIFY(QFile::remove( - QDir(devRoot).filePath( - QStringLiteral("usb/") + lpName))); - manager->rescanPrinterForTesting(); - QVERIFY(manager->printerDisplaySessionLost_); - QVERIFY(manager->printerSessionLossRemovalObserved_); - QCOMPARE(startSpy.count(), 0); + const auto awaiting = + TryxFirmwareRecoveryJournal( + journalPath) + .load(); + QCOMPARE( + awaiting.status, + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded); + QCOMPARE( + awaiting.record.phase, + QStringLiteral( + "AwaitingDeviceVerification")); + QVERIFY(bridge.recoveryRequired()); + QCOMPARE( + bridge.phase_, + QStringLiteral( + "AwaitingDeviceVerification")); + QCOMPARE(configureSpy.count(), 0); - QVERIFY(createUsbDevice(sysRoot, usbName, "1021")); - QVERIFY(createPrinterEndpoint( - sysRoot, devRoot, usbName, lpName)); manager->rescanPrinterForTesting(); - QCOMPARE(startSpy.count(), 1); - QVERIFY(!manager->printerDisplaySessionLost_); - QVERIFY(!manager->printerSessionLossRemovalObserved_); + QCOMPARE(configureSpy.count(), 0); + QVERIFY( + TryxFirmwareRecoveryJournal( + journalPath) + .load() + .status == + TryxFirmwareRecoveryJournalLoadStatus:: + Loaded); } void PrinterProtocolTests:: - sessionNotReadyRejectsMutationsBeforeDispatch() { + firmwareRecoveryAcknowledgementWaitsForReleaseFence() { QTemporaryDir temporaryDirectory; QVERIFY(temporaryDirectory.isValid()); const QString sysRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("sys")); const QString devRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, - QStringLiteral("1-1"), - QStringLiteral("lp0"))); + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("dev")); + const QString journalPath = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral( + "recovery/interlock.json")); + QVERIFY(createUsbDevice( + sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint( + sysRoot, devRoot, + QStringLiteral("1-1"), + QStringLiteral("lp0"))); std::unique_ptr manager( - DeviceManager::createForTesting(sysRoot, devRoot)); - QObject::disconnect( - manager.get(), &DeviceManager::requestStartPrinterSession, - manager->worker_, &DeviceWorker::startPrinterDisplaySession); - manager->setAutoConnectModeForTesting(true); + DeviceManager::createForTesting( + sysRoot, devRoot)); + manager->setAutoConnectModeForTesting( + true); + QSignalSpy configureSpy( + manager.get(), + &DeviceManager::requestConfigurePrinter); + QSignalSpy sessionSpy( + manager.get(), + &DeviceManager::requestStartPrinterSession); manager->rescanPrinterForTesting(); QVERIFY(manager->isPrinterClassConnected()); - QVERIFY(!manager->printerDisplaySessionActive_); - QVERIFY(!manager->printerDisplaySessionLost_); - - QSignalSpy preparationSpy( - manager.get(), &DeviceManager::requestPreparePrinterMedia); - QSignalSpy applySpy( - manager.get(), &DeviceManager::requestPrinterApplyMedia); - QSignalSpy metricsSpy( - manager.get(), &DeviceManager::requestPrinterConfigureMetrics); - QSignalSpy deleteSpy( - manager.get(), &DeviceManager::requestPrinterDeleteMedia); - QSignalSpy retryValidationSpy( - manager.get(), &DeviceManager::requestValidatePrinterRetryCache); - - QTemporaryFile source; - QVERIFY(source.open()); - QCOMPARE(source.write(QByteArrayLiteral("source")), 6); - source.flush(); + configureSpy.clear(); + sessionSpy.clear(); - const QString uploadId = - QStringLiteral("69696969-6969-4969-8969-696969696961"); - QCOMPARE(manager->queueUploadOperation( - uploadId, source.fileName()), - uploadId); - QCOMPARE(manager->operationInfo(uploadId).errorCategory, - QStringLiteral("SessionNotReady")); + QSemaphore workerEntered; + QSemaphore releaseWorker; + QVERIFY(QMetaObject::invokeMethod( + manager->worker_, + [&workerEntered, &releaseWorker]() { + workerEntered.release(); + releaseWorker.acquire(); + }, + Qt::QueuedConnection)); + QVERIFY(workerEntered.tryAcquire(1, 5000)); + + FirmwareBridge bridge( + manager.get(), nullptr, journalPath); + FirmwareBridge::Approval approval; + approval.kind = + QStringLiteral("RockchipBundle"); + approval.sha256 = + QString(64, QLatin1Char('e')); + QString journalError; + QVERIFY2( + bridge.armRecoveryJournal( + approval, &journalError), + qPrintable(journalError)); + + const QString lease = + QStringLiteral( + "firmware-recovery-ack-lease"); + QString gateError; + QVERIFY2( + manager->acquireFirmwareExclusive( + lease, &gateError), + qPrintable(gateError)); + bridge.firmwareGateLeaseId_ = lease; + bridge.flashBusy_ = true; + bridge.updaterStarted_ = true; + bridge.handleUpdaterFinished( + true, + QStringLiteral( + "synthetic success")); - TryxRuntimeApplyRequest applyRequest; - applyRequest.media = { - QStringLiteral("existing.mp4.h264_2240x1080")}; - applyRequest.ratio = QStringLiteral("2:1"); - applyRequest.screenMode = QStringLiteral("Full Screen"); - applyRequest.playMode = QStringLiteral("Single"); - const QString applyId = - QStringLiteral("69696969-6969-4969-8969-696969696962"); - QCOMPARE(manager->queueApplyOperation(applyId, applyRequest), - applyId); - QCOMPARE(manager->operationInfo(applyId).errorCategory, - QStringLiteral("SessionNotReady")); + QVERIFY(manager->firmwareExclusiveActive()); + QVERIFY( + bridge.requestRecoveryAcknowledgement( + QStringLiteral(":1.27"))); + QCOMPARE( + TryxFirmwareRecoveryJournal( + journalPath) + .load() + .status, + TryxFirmwareRecoveryJournalLoadStatus:: + Missing); + QVERIFY(!bridge.recoveryRequired()); + QVERIFY(manager->firmwareExclusiveActive()); + QCOMPARE(configureSpy.count(), 0); + QCOMPARE(sessionSpy.count(), 0); + + releaseWorker.release(); + QTRY_VERIFY( + !manager->firmwareExclusiveActive()); + QTRY_COMPARE(configureSpy.count(), 1); + QTRY_COMPARE(sessionSpy.count(), 1); + QVERIFY(manager->isPrinterClassConnected()); - TryxRuntimeMetricsConfigRequest metricsRequest; - metricsRequest.enabled = true; - metricsRequest.metrics = { - QStringLiteral("GPU Temperature")}; - metricsRequest.alignment = QStringLiteral("Left"); - metricsRequest.textColor = 0xDCDCDCU; - const QString metricsId = - QStringLiteral("69696969-6969-4969-8969-696969696963"); - QCOMPARE(manager->queueMetricsConfigOperation( - metricsId, metricsRequest), - metricsId); - QCOMPARE(manager->operationInfo(metricsId).errorCategory, - QStringLiteral("SessionNotReady")); + QVERIFY( + !bridge.requestRecoveryAcknowledgement( + QStringLiteral(":1.27"))); + QCoreApplication::processEvents( + QEventLoop::AllEvents, 100); + QCOMPARE(configureSpy.count(), 1); +} - const QString deleteId = - QStringLiteral("69696969-6969-4969-8969-696969696964"); - QCOMPARE(manager->queueDeleteMediaOperation( - deleteId, - {QStringLiteral("existing.mp4.h264_2240x1080")}), - deleteId); - QCOMPARE(manager->operationInfo(deleteId).errorCategory, - QStringLiteral("SessionNotReady")); +void PrinterProtocolTests:: + firmwareRecoveryAcknowledgementUnlinksSymlinkExactly() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString recoveryDirectory = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral("recovery")); + const QString journalPath = + QDir(recoveryDirectory) + .filePath( + QStringLiteral( + "interlock.json")); + const QString targetPath = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral("target.txt")); + const QByteArray targetContents( + "do-not-remove-or-modify"); + QVERIFY(QDir().mkpath( + recoveryDirectory)); + QVERIFY(QFile::setPermissions( + recoveryDirectory, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); + QVERIFY(writeTextFile( + targetPath, targetContents)); + QCOMPARE( + ::symlink( + QFile::encodeName(targetPath) + .constData(), + QFile::encodeName(journalPath) + .constData()), + 0); - const QString retrySourceId = - QStringLiteral("69696969-6969-4969-8969-696969696965"); - DeviceManager::OperationRecord retrySource; - retrySource.info.id = retrySourceId; - retrySource.info.kind = QStringLiteral("Upload"); - retrySource.info.state = QStringLiteral("RetryAvailable"); - retrySource.info.retryMode = QStringLiteral("PreparedMedia"); - retrySource.info.subject = QStringLiteral("prepared.mp4"); - manager->operations_.insert(retrySourceId, retrySource); - manager->operationOrder_.append(retrySourceId); + const QString sysRoot = + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("dev")); + std::unique_ptr manager( + DeviceManager::createForTesting( + sysRoot, devRoot)); + FirmwareBridge bridge( + manager.get(), nullptr, journalPath); + QVERIFY(bridge.recoveryRequired()); + QVERIFY(bridge.recoveryJournalInvalid_); + QVERIFY( + !bridge.requestRecoveryAcknowledgement( + QString())); + struct stat symlinkStatus {}; + QCOMPARE( + ::lstat( + QFile::encodeName(journalPath) + .constData(), + &symlinkStatus), + 0); + QVERIFY(S_ISLNK(symlinkStatus.st_mode)); + + QVERIFY( + bridge.requestRecoveryAcknowledgement( + QStringLiteral(":1.28"))); + QCOMPARE( + ::lstat( + QFile::encodeName(journalPath) + .constData(), + &symlinkStatus), + -1); + QCOMPARE(errno, ENOENT); + QFile target(targetPath); + QVERIFY(target.open(QIODevice::ReadOnly)); + QCOMPARE(target.readAll(), targetContents); + target.close(); + QVERIFY(manager->autoConnectMode_); + + const QString hardLinkTargetPath = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral( + "hardlink-target.txt")); + QVERIFY(writeTextFile( + hardLinkTargetPath, + targetContents)); + QVERIFY(QFile::setPermissions( + hardLinkTargetPath, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + QCOMPARE( + ::link( + QFile::encodeName( + hardLinkTargetPath) + .constData(), + QFile::encodeName(journalPath) + .constData()), + 0); + { + FirmwareBridge hardLinkBridge( + manager.get(), nullptr, + journalPath); + QVERIFY( + hardLinkBridge.recoveryRequired()); + QVERIFY( + hardLinkBridge + .recoveryJournalInvalid_); + QVERIFY( + hardLinkBridge + .requestRecoveryAcknowledgement( + QStringLiteral(":1.30"))); + } + QCOMPARE( + ::lstat( + QFile::encodeName(journalPath) + .constData(), + &symlinkStatus), + -1); + QCOMPARE(errno, ENOENT); + QFile hardLinkTarget( + hardLinkTargetPath); + QVERIFY( + hardLinkTarget.open( + QIODevice::ReadOnly)); + QCOMPARE( + hardLinkTarget.readAll(), + targetContents); +} - const QString retryId = - QStringLiteral("69696969-6969-4969-8969-696969696966"); - QCOMPARE(manager->retryOperation(retrySourceId, retryId), - retryId); - QCOMPARE(manager->operationInfo(retryId).errorCategory, - QStringLiteral("SessionNotReady")); +void PrinterProtocolTests:: + firmwareRecoveryDirectoryEntryRemainsFailClosed() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString recoveryDirectory = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral("recovery")); + const QString journalPath = + QDir(recoveryDirectory) + .filePath( + QStringLiteral( + "interlock.json")); + QVERIFY(QDir().mkpath(journalPath)); + QVERIFY(QFile::setPermissions( + recoveryDirectory, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); - QCOMPARE(preparationSpy.count(), 0); - QCOMPARE(applySpy.count(), 0); - QCOMPARE(metricsSpy.count(), 0); - QCOMPARE(deleteSpy.count(), 0); - QCOMPARE(retryValidationSpy.count(), 0); - QVERIFY(manager->activeOperationInfo().id.isEmpty()); + const QString sysRoot = + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()) + .filePath(QStringLiteral("dev")); + std::unique_ptr manager( + DeviceManager::createForTesting( + sysRoot, devRoot)); + FirmwareBridge bridge( + manager.get(), nullptr, journalPath); + QVERIFY(bridge.recoveryRequired()); + QVERIFY(bridge.recoveryJournalInvalid_); + QVERIFY( + !bridge.requestRecoveryAcknowledgement( + QStringLiteral(":1.29"))); + QVERIFY(QFileInfo(journalPath).isDir()); + QVERIFY(!manager->autoConnectMode_); + + const QString unsafeDirectory = + QDir(temporaryDirectory.path()) + .filePath( + QStringLiteral( + "unsafe-recovery")); + const QString unsafeJournalPath = + QDir(unsafeDirectory) + .filePath( + QStringLiteral( + "interlock.json")); + QVERIFY(QDir().mkpath( + unsafeDirectory)); + QVERIFY(QFile::setPermissions( + unsafeDirectory, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner | + QFileDevice::ReadGroup | + QFileDevice::ExeGroup | + QFileDevice::ReadOther | + QFileDevice::ExeOther)); + QVERIFY(writeTextFile( + unsafeJournalPath, + QByteArrayLiteral("{}"))); + QVERIFY(QFile::setPermissions( + unsafeJournalPath, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + FirmwareBridge unsafeDirectoryBridge( + manager.get(), nullptr, + unsafeJournalPath); + QVERIFY( + unsafeDirectoryBridge + .recoveryRequired()); + QVERIFY( + unsafeDirectoryBridge + .recoveryJournalInvalid_); + QVERIFY( + !unsafeDirectoryBridge + .requestRecoveryAcknowledgement( + QStringLiteral(":1.31"))); + QVERIFY(QFileInfo::exists( + unsafeJournalPath)); } void PrinterProtocolTests:: @@ -9598,185 +12962,614 @@ void PrinterProtocolTests::displayKeepaliveTransportFailureIsRetryable() { ::close(sockets[1]); } -void PrinterProtocolTests::displayKeepaliveMalformedResponseIsDiscarded() { +void PrinterProtocolTests::displayKeepaliveMalformedResponseIsDiscarded() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + const int responseReadyFd = ::eventfd(0, EFD_CLOEXEC); + QVERIFY(responseReadyFd >= 0); + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "display keepalive did not send RunConfig"); + } + return; + } + const QByteArray malformedPayload(1, char(0x0f)); + const QByteArray frame = PrinterFrameCodec::encode(malformedPayload); + if (!writeAllFd(sockets[1], frame, &peerError)) { + return; + } + const uint64_t readyValue = 1; + if (::write(responseReadyFd, &readyValue, sizeof(readyValue)) != + static_cast(sizeof(readyValue))) { + peerError = QStringLiteral( + "failed to signal malformed RunConfig response"); + return; + } + + panorama::wire::v1::Request secondRequest; + if (!readRequest(sockets[1], &secondRequest, &peerError) || + secondRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "second display keepalive did not preserve the connection"); + } + } + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], + QStringLiteral("test-endpoint")); + QString error; + const PrinterProtocol::OperationContext context; + QCOMPARE(protocol.sendDisplayKeepalive(QStringLiteral("test-endpoint"), + &error, context), + PrinterProtocol::KeepaliveOutcome::Sent); + QVERIFY2(error.isEmpty(), qPrintable(error)); + pollfd readyDescriptor{}; + readyDescriptor.fd = responseReadyFd; + readyDescriptor.events = POLLIN; + QCOMPARE(::poll(&readyDescriptor, 1, kPeerTimeoutMs), 1); + uint64_t readyValue = 0; + QCOMPARE(::read(responseReadyFd, &readyValue, sizeof(readyValue)), + static_cast(sizeof(readyValue))); + QCOMPARE(readyValue, uint64_t(1)); + QCOMPARE(protocol.sendDisplayKeepalive(QStringLiteral("test-endpoint"), + &error, context), + PrinterProtocol::KeepaliveOutcome::Sent); + QVERIFY2(error.isEmpty(), qPrintable(error)); + + peer.join(); + ::close(responseReadyFd); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::displayKeepaliveIncompleteResponseIsDiscarded() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + const int responseReadyFd = ::eventfd(0, EFD_CLOEXEC); + QVERIFY(responseReadyFd >= 0); + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request firstRequest; + if (!readRequest(sockets[1], &firstRequest, &peerError) || + firstRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "first display keepalive did not send RunConfig"); + } + return; + } + + const QByteArray incompleteFrame = + PrinterFrameCodec::encode(QByteArray(32, char(0x5a))).first(8); + if (!writeAllFd(sockets[1], incompleteFrame, &peerError)) { + return; + } + const uint64_t readyValue = 1; + if (::write(responseReadyFd, &readyValue, sizeof(readyValue)) != + static_cast(sizeof(readyValue))) { + peerError = QStringLiteral( + "failed to signal incomplete RunConfig response"); + return; + } + + panorama::wire::v1::Request secondRequest; + if (!readRequest(sockets[1], &secondRequest, &peerError) || + secondRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "second display keepalive did not preserve the connection"); + } + } + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], + QStringLiteral("test-endpoint")); + QString error; + const PrinterProtocol::OperationContext context; + QCOMPARE(protocol.sendDisplayKeepalive(QStringLiteral("test-endpoint"), + &error, context), + PrinterProtocol::KeepaliveOutcome::Sent); + QVERIFY2(error.isEmpty(), qPrintable(error)); + pollfd readyDescriptor{}; + readyDescriptor.fd = responseReadyFd; + readyDescriptor.events = POLLIN; + QCOMPARE(::poll(&readyDescriptor, 1, kPeerTimeoutMs), 1); + uint64_t readyValue = 0; + QCOMPARE(::read(responseReadyFd, &readyValue, sizeof(readyValue)), + static_cast(sizeof(readyValue))); + QCOMPARE(readyValue, uint64_t(1)); + QCOMPARE(protocol.sendDisplayKeepalive(QStringLiteral("test-endpoint"), + &error, context), + PrinterProtocol::KeepaliveOutcome::Sent); + QVERIFY2(error.isEmpty(), qPrintable(error)); + + peer.join(); + ::close(responseReadyFd); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::notificationBeforeExpectedResponse() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError)) { + return; + } + auto notification = baseResponse(request); + notification.mutable_asynchronous_event()->set_play_finished(true); + if (!writeResponse(sockets[1], notification, &peerError)) { + return; + } + auto response = baseResponse(request); + response.mutable_pong()->set_payload("after-notification"); + writeResponse(sockets[1], response, &peerError); + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + QString payload; + QString error; + const PrinterProtocol::OperationContext context; + QVERIFY2(protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, + &error, context), + qPrintable(error)); + QCOMPARE(payload, QStringLiteral("after-notification")); + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::headerlessPongBeforeTrackedResponseIsSkipped() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kMediaCatalogQuery) { + if (peerError.isEmpty()) { + peerError = QStringLiteral("missing tracked file-list request"); + } + return; + } + + panorama::wire::v1::Response latePong; + latePong.mutable_pong()->set_payload("late"); + if (!writeResponse(sockets[1], latePong, &peerError)) { + return; + } + + auto response = baseResponse(request); + auto *file = response.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/after-pong.png"); + file->set_file_ext(".h264_2240x1080"); + file->set_file_size(321); + writeResponse(sockets[1], response, &peerError); + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], + QStringLiteral("test-endpoint")); + const PrinterProtocol::OperationContext context; + const auto result = + protocol.readMediaList(QStringLiteral("test-endpoint"), context); + QVERIFY2(result.success, qPrintable(result.error)); + QCOMPARE(result.files.size(), 1); + QCOMPARE(result.files.first().name, + QStringLiteral("after-pong.png.h264_2240x1080")); + + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::boundedResponseBurstBeforeFileListIsSkipped() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + int fileListRequestCount = 0; + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kMediaCatalogQuery) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "bounded burst test did not receive FileList"); + } + return; + } + ++fileListRequestCount; + for (int index = 0; index < 40; ++index) { + panorama::wire::v1::Response pong; + pong.mutable_pong()->set_payload("queued-pong"); + if (!writeResponse(sockets[1], pong, &peerError)) { + return; + } + } + auto response = baseResponse(request); + auto *file = response.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/recovered.mp4"); + file->set_file_ext(".h264_2240x1080"); + file->set_file_size(4096); + writeResponse(sockets[1], response, &peerError); + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + const PrinterProtocol::MediaListResult result = protocol.readMediaList( + QStringLiteral("test-endpoint"), {}); + + peer.join(); + ::close(sockets[1]); + + QVERIFY2(result.success, qPrintable(result.error)); + QCOMPARE(fileListRequestCount, 1); + QCOMPARE(result.files.size(), 1); + QCOMPARE(result.files.first().name, + QStringLiteral("recovered.mp4.h264_2240x1080")); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::slowTrackedResponseGetsInFlightKeepalive() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int responseReadyFd = ::eventfd(0, EFD_CLOEXEC); - QVERIFY(responseReadyFd >= 0); QString peerError; + qint64 keepaliveDelayMs = -1; std::thread peer([&]() { panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { + panorama::wire::v1::Request::kMediaCatalogQuery) { if (peerError.isEmpty()) { - peerError = QStringLiteral( - "display keepalive did not send RunConfig"); + peerError = QStringLiteral("missing slow file-list request"); } return; } - const QByteArray malformedPayload(1, char(0x0f)); - const QByteArray frame = PrinterFrameCodec::encode(malformedPayload); - if (!writeAllFd(sockets[1], frame, &peerError)) { - return; - } - const uint64_t readyValue = 1; - if (::write(responseReadyFd, &readyValue, sizeof(readyValue)) != - static_cast(sizeof(readyValue))) { - peerError = QStringLiteral( - "failed to signal malformed RunConfig response"); - return; - } - panorama::wire::v1::Request secondRequest; - if (!readRequest(sockets[1], &secondRequest, &peerError) || - secondRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { + QElapsedTimer idleTimer; + idleTimer.start(); + panorama::wire::v1::Request keepalive; + if (!readRequest(sockets[1], &keepalive, &peerError, 3000) || + !keepalive.has_header() || + keepalive.header().ByteSizeLong() != 0 || + keepalive.body_case() != panorama::wire::v1::Request::kPing || + keepalive.ping().payload() != "hello?") { if (peerError.isEmpty()) { - peerError = QStringLiteral( - "second display keepalive did not preserve the connection"); + peerError = QStringLiteral("missing in-flight UDB keepalive"); } + return; } + keepaliveDelayMs = idleTimer.elapsed(); + + auto response = baseResponse(request); + auto *file = response.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/slow.png"); + file->set_file_ext(".h264_2240x1080"); + file->set_file_size(654); + writeResponse(sockets[1], response, &peerError); }); - PrinterProtocol protocol(500); + PrinterProtocol protocol(4000); protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QString error; - const PrinterProtocol::OperationContext context; - QCOMPARE(protocol.sendDisplayKeepalive(QStringLiteral("test-endpoint"), - &error, context), - PrinterProtocol::KeepaliveOutcome::Sent); - QVERIFY2(error.isEmpty(), qPrintable(error)); - pollfd readyDescriptor{}; - readyDescriptor.fd = responseReadyFd; - readyDescriptor.events = POLLIN; - QCOMPARE(::poll(&readyDescriptor, 1, kPeerTimeoutMs), 1); - uint64_t readyValue = 0; - QCOMPARE(::read(responseReadyFd, &readyValue, sizeof(readyValue)), - static_cast(sizeof(readyValue))); - QCOMPARE(readyValue, uint64_t(1)); - QCOMPARE(protocol.sendDisplayKeepalive(QStringLiteral("test-endpoint"), - &error, context), - PrinterProtocol::KeepaliveOutcome::Sent); - QVERIFY2(error.isEmpty(), qPrintable(error)); + PrinterProtocol::OperationContext context; + context.maintainKeepalive = true; + const auto result = + protocol.readMediaList(QStringLiteral("test-endpoint"), context); + QVERIFY2(result.success, qPrintable(result.error)); + QCOMPARE(result.files.size(), 1); peer.join(); - ::close(responseReadyFd); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY(keepaliveDelayMs >= 1500); + QVERIFY(keepaliveDelayMs < 3000); } -void PrinterProtocolTests::displayKeepaliveIncompleteResponseIsDiscarded() { +void PrinterProtocolTests::unrelatedTrackIsSkipped() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int responseReadyFd = ::eventfd(0, EFD_CLOEXEC); - QVERIFY(responseReadyFd >= 0); QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request firstRequest; - if (!readRequest(sockets[1], &firstRequest, &peerError) || - firstRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { - if (peerError.isEmpty()) { - peerError = QStringLiteral( - "first display keepalive did not send RunConfig"); - } + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError)) { + return; + } + auto unrelated = baseResponse(request, 1); + unrelated.mutable_pong()->set_payload("wrong-track"); + if (!writeResponse(sockets[1], unrelated, &peerError)) { return; } + auto response = baseResponse(request); + response.mutable_pong()->set_payload("matching-track"); + writeResponse(sockets[1], response, &peerError); + }); - const QByteArray incompleteFrame = - PrinterFrameCodec::encode(QByteArray(32, char(0x5a))).first(8); - if (!writeAllFd(sockets[1], incompleteFrame, &peerError)) { + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + QString payload; + QString error; + const PrinterProtocol::OperationContext context; + QVERIFY2(protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, + &error, context), + qPrintable(error)); + QCOMPARE(payload, QStringLiteral("matching-track")); + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::timeoutIsBounded() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + PrinterProtocol protocol(40); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + QElapsedTimer timer; + timer.start(); + QString payload; + QString error; + const PrinterProtocol::OperationContext context; + QVERIFY(!protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, + &error, context)); + QVERIFY(error.contains(QStringLiteral("Timed out"), Qt::CaseInsensitive)); + QVERIFY(timer.elapsed() < 500); + ::close(sockets[1]); +} + +void PrinterProtocolTests::cancellationFdStopsTransaction() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + const int cancellationFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + QVERIFY(cancellationFd >= 0); + const uint64_t value = 1; + QCOMPARE(::write(cancellationFd, &value, sizeof(value)), + static_cast(sizeof(value))); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + PrinterProtocol::OperationContext context; + context.cancellationFd = cancellationFd; + QString payload; + QString error; + QVERIFY(!protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, + &error, context)); + QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); + char byte = 0; + errno = 0; + QCOMPARE(::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT), + static_cast(-1)); + QVERIFY(errno == EAGAIN || errno == EWOULDBLOCK); + + ::close(cancellationFd); + ::close(sockets[1]); +} + +void PrinterProtocolTests::cancellationFdInterruptsBlockedResponse() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + const int cancellationFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + QVERIFY(cancellationFd >= 0); + + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != panorama::wire::v1::Request::kPing) { + peerError = QStringLiteral("peer did not receive ping before cancellation"); return; } - const uint64_t readyValue = 1; - if (::write(responseReadyFd, &readyValue, sizeof(readyValue)) != - static_cast(sizeof(readyValue))) { - peerError = QStringLiteral( - "failed to signal incomplete RunConfig response"); + const uint64_t value = 1; + if (::write(cancellationFd, &value, sizeof(value)) != + static_cast(sizeof(value))) { + peerError = QStringLiteral("failed to signal cancellation eventfd"); return; } - - panorama::wire::v1::Request secondRequest; - if (!readRequest(sockets[1], &secondRequest, &peerError) || - secondRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { - if (peerError.isEmpty()) { - peerError = QStringLiteral( - "second display keepalive did not preserve the connection"); - } + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); + if (pollResult <= 0) { + peerError = QStringLiteral("transport did not close after blocked cancellation"); + return; + } + char byte = 0; + const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); + if (received > 0) { + peerError = QStringLiteral("unexpected bytes after blocked cancellation"); } }); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], - QStringLiteral("test-endpoint")); + PrinterProtocol protocol(2000); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + PrinterProtocol::OperationContext context; + context.cancellationFd = cancellationFd; + QElapsedTimer timer; + timer.start(); + QString payload; QString error; - const PrinterProtocol::OperationContext context; - QCOMPARE(protocol.sendDisplayKeepalive(QStringLiteral("test-endpoint"), - &error, context), - PrinterProtocol::KeepaliveOutcome::Sent); - QVERIFY2(error.isEmpty(), qPrintable(error)); - pollfd readyDescriptor{}; - readyDescriptor.fd = responseReadyFd; - readyDescriptor.events = POLLIN; - QCOMPARE(::poll(&readyDescriptor, 1, kPeerTimeoutMs), 1); - uint64_t readyValue = 0; - QCOMPARE(::read(responseReadyFd, &readyValue, sizeof(readyValue)), - static_cast(sizeof(readyValue))); - QCOMPARE(readyValue, uint64_t(1)); - QCOMPARE(protocol.sendDisplayKeepalive(QStringLiteral("test-endpoint"), - &error, context), - PrinterProtocol::KeepaliveOutcome::Sent); - QVERIFY2(error.isEmpty(), qPrintable(error)); - + QVERIFY(!protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, + &error, context)); + QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); + QVERIFY(timer.elapsed() < 500); peer.join(); - ::close(responseReadyFd); + ::close(cancellationFd); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::notificationBeforeExpectedResponse() { +void PrinterProtocolTests::userCancellationDoesNotInterruptSessionRecovery() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + const int requestSeenFd = eventfd(0, EFD_CLOEXEC); + const int cancellationIssuedFd = eventfd(0, EFD_CLOEXEC); + QVERIFY(requestSeenFd >= 0); + QVERIFY(cancellationIssuedFd >= 0); QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError)) { - return; + const QList expectedBodies = { + panorama::wire::v1::Request::kDeviceInformationQuery, + panorama::wire::v1::Request::kSystemConfigurationQuery, + panorama::wire::v1::Request::kDeviceAuthenticationQuery + }; + for (int index = 0; index < expectedBodies.size(); ++index) { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != expectedBodies.at(index)) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "unexpected recovery bootstrap request %1").arg(index); + } + return; + } + if (index == 0) { + const uint64_t value = 1; + if (::write(requestSeenFd, &value, sizeof(value)) != + static_cast(sizeof(value))) { + peerError = QStringLiteral( + "failed to signal blocked recovery request"); + return; + } + pollfd descriptor{}; + descriptor.fd = cancellationIssuedFd; + descriptor.events = POLLIN; + if (::poll(&descriptor, 1, kPeerTimeoutMs) != 1) { + peerError = QStringLiteral( + "user cancellation was not issued in time"); + return; + } + uint64_t observedValue = 0; + if (::read(cancellationIssuedFd, &observedValue, + sizeof(observedValue)) != + static_cast(sizeof(observedValue))) { + peerError = QStringLiteral( + "failed to consume user-cancellation signal"); + return; + } + } + + auto response = baseResponse(request); + if (index == 0) { + auto *deviceInfo = response.mutable_device_information(); + deviceInfo->set_product_name("PANORAMA SE"); + deviceInfo->set_firmware_version("test-firmware"); + deviceInfo->set_serial_number("test-serial"); + } else if (index == 1) { + response.mutable_system_configuration(); + } else { + response.mutable_device_authentication()->set_auth("test-auth"); + } + if (!writeResponse(sockets[1], response, &peerError)) { + return; + } } - auto notification = baseResponse(request); - notification.mutable_asynchronous_event()->set_play_finished(true); - if (!writeResponse(sockets[1], notification, &peerError)) { + + panorama::wire::v1::Request sessionRequest; + if (!readRequest(sockets[1], &sessionRequest, &peerError) || + sessionRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "recovery did not reach display-session activation"); + } return; } - auto response = baseResponse(request); - response.mutable_pong()->set_payload("after-notification"); - writeResponse(sockets[1], response, &peerError); + auto sessionResponse = baseResponse(sessionRequest); + sessionResponse.mutable_acknowledgement(); + writeResponse(sockets[1], sessionResponse, &peerError); }); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QString payload; - QString error; - const PrinterProtocol::OperationContext context; - QVERIFY2(protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, - &error, context), - qPrintable(error)); - QCOMPARE(payload, QStringLiteral("after-notification")); + constexpr quint64 generation = 39; + const QString endpoint = QStringLiteral("test-endpoint"); + QThread workerThread; + auto *worker = new DeviceWorker; + worker->moveToThread(&workerThread); + connect(&workerThread, &QThread::finished, + worker, &QObject::deleteLater); + workerThread.start(); + QVERIFY(QMetaObject::invokeMethod( + worker, + [worker, sockets, endpoint]() { + worker->updatePrinterGenerationGate(generation, true); + worker->configurePrinterDevice( + endpoint, QStringLiteral("test-serial"), generation); + worker->adoptPrinterFileDescriptorForTesting(sockets[0], endpoint); + }, + Qt::BlockingQueuedConnection)); + + QSignalSpy sessionStartedSpy(worker, &DeviceWorker::printerSessionStarted); + QSignalSpy operationErrorSpy(worker, &DeviceWorker::printerOperationError); + QVERIFY(QMetaObject::invokeMethod( + worker, "startPrinterDisplaySession", Qt::QueuedConnection, + Q_ARG(QString, endpoint), Q_ARG(quint64, generation))); + + pollfd requestDescriptor{}; + requestDescriptor.fd = requestSeenFd; + requestDescriptor.events = POLLIN; + QCOMPARE(::poll(&requestDescriptor, 1, kPeerTimeoutMs), 1); + uint64_t requestValue = 0; + QCOMPARE(::read(requestSeenFd, &requestValue, sizeof(requestValue)), + static_cast(sizeof(requestValue))); + worker->cancelPrinterOperation(QStringLiteral("cancelled-upload")); + const uint64_t cancellationValue = 1; + QCOMPARE(::write(cancellationIssuedFd, &cancellationValue, + sizeof(cancellationValue)), + static_cast(sizeof(cancellationValue))); + + QTRY_COMPARE_WITH_TIMEOUT(sessionStartedSpy.count(), 1, + kPeerTimeoutMs * 3); + QCOMPARE(operationErrorSpy.count(), 0); + QVERIFY(QMetaObject::invokeMethod( + worker, "clearPrinterDevice", Qt::BlockingQueuedConnection, + Q_ARG(quint64, generation))); + workerThread.quit(); + QVERIFY(workerThread.wait(kPeerTimeoutMs)); + peer.join(); + ::close(requestSeenFd); + ::close(cancellationIssuedFd); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::headerlessPongBeforeTrackedResponseIsSkipped() { +void PrinterProtocolTests::typedFileListTransaction() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); @@ -9785,504 +13578,532 @@ void PrinterProtocolTests::headerlessPongBeforeTrackedResponseIsSkipped() { std::thread peer([&]() { panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery) { + request.body_case() != panorama::wire::v1::Request::kMediaCatalogQuery) { if (peerError.isEmpty()) { - peerError = QStringLiteral("missing tracked file-list request"); + peerError = QStringLiteral("unexpected file-list request"); } return; } - - panorama::wire::v1::Response latePong; - latePong.mutable_pong()->set_payload("late"); - if (!writeResponse(sockets[1], latePong, &peerError)) { - return; - } - auto response = baseResponse(request); - auto *file = response.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/after-pong.png"); - file->set_file_ext(".h264_2240x1080"); - file->set_file_size(321); + auto *userFile = response.mutable_media_catalog()->add_media_file_list(); + userFile->set_file_path("/userdata/user/custom.png"); + userFile->set_file_ext(".h264_2240x1080"); + userFile->set_file_size(1234); + auto *preset = response.mutable_media_catalog()->add_preset_file_list(); + preset->set_file_path("/userdata/default/default_01.mp4.h264_2240x1080"); + preset->set_file_size(5678); + preset->set_read_only(true); writeResponse(sockets[1], response, &peerError); }); PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], - QStringLiteral("test-endpoint")); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); const PrinterProtocol::OperationContext context; - const auto result = - protocol.readMediaList(QStringLiteral("test-endpoint"), context); + const auto result = protocol.readMediaList(QStringLiteral("test-endpoint"), context); QVERIFY2(result.success, qPrintable(result.error)); - QCOMPARE(result.files.size(), 1); - QCOMPARE(result.files.first().name, - QStringLiteral("after-pong.png.h264_2240x1080")); - + QCOMPARE(result.files.size(), 2); + QCOMPARE(result.files.at(0).name, QStringLiteral("custom.png.h264_2240x1080")); + QCOMPARE(result.files.at(0).size, quint32(1234)); + QVERIFY(!result.files.at(0).readOnly); + QCOMPARE(static_cast(result.files.at(0).source), + static_cast(PrinterProtocol::MediaSource::User)); + QCOMPARE(result.files.at(1).name, + QStringLiteral("default_01.mp4.h264_2240x1080")); + QCOMPARE(result.files.at(1).size, quint32(5678)); + QVERIFY(result.files.at(1).readOnly); + QCOMPARE(static_cast(result.files.at(1).source), + static_cast(PrinterProtocol::MediaSource::Preset)); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::boundedResponseBurstBeforeFileListIsSkipped() { +void PrinterProtocolTests::deleteUserMediaLostAckReconcilesWithoutReplay() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - - int fileListRequestCount = 0; + const QString target = + QStringLiteral("delete-me.mp4.h264_2240x1080"); QString peerError; + int removeRequests = 0; std::thread peer([&]() { panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != panorama::wire::v1::Request::kMediaCatalogQuery) { - if (peerError.isEmpty()) { - peerError = QStringLiteral( - "bounded burst test did not receive FileList"); - } + peerError = QStringLiteral("missing delete preflight FileList"); return; } - ++fileListRequestCount; - for (int index = 0; index < 40; ++index) { - panorama::wire::v1::Response pong; - pong.mutable_pong()->set_payload("queued-pong"); - if (!writeResponse(sockets[1], pong, &peerError)) { - return; - } - } - auto response = baseResponse(request); - auto *file = response.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/recovered.mp4"); + auto listResponse = baseResponse(request); + auto *file = listResponse.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/delete-me.mp4"); file->set_file_ext(".h264_2240x1080"); - file->set_file_size(4096); - writeResponse(sockets[1], response, &peerError); - }); - - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting( - sockets[0], QStringLiteral("test-endpoint")); - const PrinterProtocol::MediaListResult result = protocol.readMediaList( - QStringLiteral("test-endpoint"), {}); - - peer.join(); - ::close(sockets[1]); - - QVERIFY2(result.success, qPrintable(result.error)); - QCOMPARE(fileListRequestCount, 1); - QCOMPARE(result.files.size(), 1); - QCOMPARE(result.files.first().name, - QStringLiteral("recovered.mp4.h264_2240x1080")); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); -} + file->set_file_size(6125); + if (!writeResponse(sockets[1], listResponse, &peerError)) { + return; + } -void PrinterProtocolTests::slowTrackedResponseGetsInFlightKeepalive() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral("missing delete UserConfig preflight"); + return; + } + auto configResponse = baseResponse(request); + configResponse.mutable_user_configuration()->mutable_work_config() + ->set_single_mode_media_file("other.mp4.h264_2240x1080"); + if (!writeResponse(sockets[1], configResponse, &peerError)) { + return; + } - QString peerError; - qint64 keepaliveDelayMs = -1; - std::thread peer([&]() { - panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery) { - if (peerError.isEmpty()) { - peerError = QStringLiteral("missing slow file-list request"); - } + panorama::wire::v1::Request::kFileRemoval || + request.file_removal().file_name() != target.toStdString() || + request.file_removal().file_type() != "media") { + peerError = QStringLiteral("unexpected FileRemove contract"); return; } - - QElapsedTimer idleTimer; - idleTimer.start(); - panorama::wire::v1::Request keepalive; - if (!readRequest(sockets[1], &keepalive, &peerError, 3000) || - !keepalive.has_header() || - keepalive.header().ByteSizeLong() != 0 || - keepalive.body_case() != panorama::wire::v1::Request::kPing || - keepalive.ping().payload() != "hello?") { - if (peerError.isEmpty()) { - peerError = QStringLiteral("missing in-flight UDB keepalive"); - } + ++removeRequests; + // Deliberately omit the optional ACK. The next request must be a + // read-only reconciliation, never a second FileRemove. + if (!readRequest(sockets[1], &request, &peerError, 1000) || + request.body_case() != + panorama::wire::v1::Request::kMediaCatalogQuery) { + peerError = QStringLiteral( + "missing FileList reconciliation after optional ACK timeout"); return; } - keepaliveDelayMs = idleTimer.elapsed(); - - auto response = baseResponse(request); - auto *file = response.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/slow.png"); - file->set_file_ext(".h264_2240x1080"); - file->set_file_size(654); - writeResponse(sockets[1], response, &peerError); + auto absentResponse = baseResponse(request); + absentResponse.mutable_media_catalog(); + writeResponse(sockets[1], absentResponse, &peerError); }); - PrinterProtocol protocol(4000); + PrinterProtocol protocol(60); protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - PrinterProtocol::OperationContext context; - context.maintainKeepalive = true; - const auto result = - protocol.readMediaList(QStringLiteral("test-endpoint"), context); + int dispatchCount = 0; + const auto beforeDispatch = + [&dispatchCount](int, const PrinterProtocol::MediaFile &, + QString *) { + ++dispatchCount; + return true; + }; + const auto result = protocol.removeUserMedia( + QStringLiteral("test-endpoint"), QStringList{target}, + beforeDispatch, {}, PrinterProtocol::OperationContext{}, false); QVERIFY2(result.success, qPrintable(result.error)); - QCOMPARE(result.files.size(), 1); - + QCOMPARE(result.outcome, PrinterProtocol::MutationOutcome::Succeeded); + QVERIFY(!result.commandAcknowledged); + QCOMPARE(result.deletedNames, QStringList{target}); + QCOMPARE(dispatchCount, 1); peer.join(); ::close(sockets[1]); + QCOMPARE(removeRequests, 1); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QVERIFY(keepaliveDelayMs >= 1500); - QVERIFY(keepaliveDelayMs < 3000); } -void PrinterProtocolTests::unrelatedTrackIsSkipped() { +void PrinterProtocolTests::deleteUserMediaAcceptsHeaderOnlySuccess() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - + const QString target = + QStringLiteral("header-only.mp4.h264_2240x1080"); QString peerError; std::thread peer([&]() { panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError)) { + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kMediaCatalogQuery) { + peerError = QStringLiteral("missing header-only preflight"); return; } - auto unrelated = baseResponse(request, 1); - unrelated.mutable_pong()->set_payload("wrong-track"); - if (!writeResponse(sockets[1], unrelated, &peerError)) { + auto listResponse = baseResponse(request); + auto *file = listResponse.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/header-only.mp4"); + file->set_file_ext(".h264_2240x1080"); + file->set_file_size(5000); + if (!writeResponse(sockets[1], listResponse, &peerError)) { return; } - auto response = baseResponse(request); - response.mutable_pong()->set_payload("matching-track"); - writeResponse(sockets[1], response, &peerError); + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral("missing header-only config"); + return; + } + auto configResponse = baseResponse(request); + configResponse.mutable_user_configuration()->mutable_work_config() + ->set_single_mode_media_file("other.mp4.h264_2240x1080"); + if (!writeResponse(sockets[1], configResponse, &peerError)) { + return; + } + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kFileRemoval) { + peerError = QStringLiteral("missing header-only FileRemove"); + return; + } + const auto headerOnlyResponse = baseResponse(request); + if (!writeResponse(sockets[1], headerOnlyResponse, &peerError)) { + return; + } + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kMediaCatalogQuery) { + peerError = QStringLiteral("missing header-only reconciliation"); + return; + } + auto absentResponse = baseResponse(request); + absentResponse.mutable_media_catalog(); + writeResponse(sockets[1], absentResponse, &peerError); }); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QString payload; - QString error; - const PrinterProtocol::OperationContext context; - QVERIFY2(protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, - &error, context), - qPrintable(error)); - QCOMPARE(payload, QStringLiteral("matching-track")); + PrinterProtocol protocol(300); + protocol.adoptFileDescriptorForTesting(sockets[0], + QStringLiteral("test-endpoint")); + const auto result = protocol.removeUserMedia( + QStringLiteral("test-endpoint"), QStringList{target}, + [](int, const PrinterProtocol::MediaFile &, QString *) { + return true; + }, + {}, PrinterProtocol::OperationContext{}, false); + QVERIFY2(result.success, qPrintable(result.error)); + QVERIFY(result.commandAcknowledged); + QCOMPARE(result.deletedNames, QStringList{target}); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::timeoutIsBounded() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - - PrinterProtocol protocol(40); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QElapsedTimer timer; - timer.start(); - QString payload; - QString error; - const PrinterProtocol::OperationContext context; - QVERIFY(!protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, - &error, context)); - QVERIFY(error.contains(QStringLiteral("Timed out"), Qt::CaseInsensitive)); - QVERIFY(timer.elapsed() < 500); - ::close(sockets[1]); -} - -void PrinterProtocolTests::cancellationFdStopsTransaction() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int cancellationFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); - QVERIFY(cancellationFd >= 0); - const uint64_t value = 1; - QCOMPARE(::write(cancellationFd, &value, sizeof(value)), - static_cast(sizeof(value))); - - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - PrinterProtocol::OperationContext context; - context.cancellationFd = cancellationFd; - QString payload; - QString error; - QVERIFY(!protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, - &error, context)); - QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); - char byte = 0; - errno = 0; - QCOMPARE(::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT), - static_cast(-1)); - QVERIFY(errno == EAGAIN || errno == EWOULDBLOCK); - - ::close(cancellationFd); - ::close(sockets[1]); -} - -void PrinterProtocolTests::cancellationFdInterruptsBlockedResponse() { +void PrinterProtocolTests::deleteUserMediaRejectsReferencedFileBeforeDispatch() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int cancellationFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); - QVERIFY(cancellationFd >= 0); - + const QString target = + QStringLiteral("active.mp4.h264_2240x1080"); QString peerError; std::thread peer([&]() { panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != panorama::wire::v1::Request::kPing) { - peerError = QStringLiteral("peer did not receive ping before cancellation"); + request.body_case() != + panorama::wire::v1::Request::kMediaCatalogQuery) { + peerError = QStringLiteral("missing referenced-file FileList"); return; } - const uint64_t value = 1; - if (::write(cancellationFd, &value, sizeof(value)) != - static_cast(sizeof(value))) { - peerError = QStringLiteral("failed to signal cancellation eventfd"); + auto listResponse = baseResponse(request); + auto *file = listResponse.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/active.mp4"); + file->set_file_ext(".h264_2240x1080"); + file->set_file_size(4000); + if (!writeResponse(sockets[1], listResponse, &peerError)) { + return; + } + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral("missing referenced-file UserConfig"); + return; + } + auto configResponse = baseResponse(request); + configResponse.mutable_user_configuration()->mutable_poweron_config() + ->set_media_file(target.toStdString()); + if (!writeResponse(sockets[1], configResponse, &peerError)) { return; } pollfd descriptor{}; descriptor.fd = sockets[1]; descriptor.events = POLLIN; - const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); - if (pollResult <= 0) { - peerError = QStringLiteral("transport did not close after blocked cancellation"); - return; - } - char byte = 0; - const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); - if (received > 0) { - peerError = QStringLiteral("unexpected bytes after blocked cancellation"); + const int polled = ::poll(&descriptor, 1, 150); + if (polled > 0 && (descriptor.revents & POLLIN) != 0) { + char byte = 0; + if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) > 0) { + peerError = QStringLiteral( + "FileRemove was sent for referenced media"); + } } - }); - - PrinterProtocol protocol(2000); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - PrinterProtocol::OperationContext context; - context.cancellationFd = cancellationFd; - QElapsedTimer timer; - timer.start(); - QString payload; - QString error; - QVERIFY(!protocol.trackedPingForTesting(QStringLiteral("test-endpoint"), &payload, - &error, context)); - QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); - QVERIFY(timer.elapsed() < 500); + }); + + PrinterProtocol protocol(300); + protocol.adoptFileDescriptorForTesting(sockets[0], + QStringLiteral("test-endpoint")); + int dispatchCount = 0; + const auto result = protocol.removeUserMedia( + QStringLiteral("test-endpoint"), QStringList{target}, + [&dispatchCount](int, const PrinterProtocol::MediaFile &, QString *) { + ++dispatchCount; + return true; + }, + {}, PrinterProtocol::OperationContext{}, false); + QVERIFY(!result.success); + QCOMPARE(result.outcome, PrinterProtocol::MutationOutcome::NotStarted); + QVERIFY(result.error.contains(QStringLiteral("referenced"), + Qt::CaseInsensitive)); + QCOMPARE(dispatchCount, 0); peer.join(); - ::close(cancellationFd); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::userCancellationDoesNotInterruptSessionRecovery() { +void PrinterProtocolTests:: + deleteExpectedIdentityIsRecheckedBeforeDispatch() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int requestSeenFd = eventfd(0, EFD_CLOEXEC); - const int cancellationIssuedFd = eventfd(0, EFD_CLOEXEC); - QVERIFY(requestSeenFd >= 0); - QVERIFY(cancellationIssuedFd >= 0); - + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + const QString target = + QStringLiteral( + "identity-race.mp4.h264_2240x1080"); QString peerError; std::thread peer([&]() { - const QList expectedBodies = { - panorama::wire::v1::Request::kDeviceInformationQuery, - panorama::wire::v1::Request::kSystemConfigurationQuery, - panorama::wire::v1::Request::kDeviceAuthenticationQuery - }; - for (int index = 0; index < expectedBodies.size(); ++index) { - panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != expectedBodies.at(index)) { - if (peerError.isEmpty()) { - peerError = QStringLiteral( - "unexpected recovery bootstrap request %1").arg(index); - } - return; - } - if (index == 0) { - const uint64_t value = 1; - if (::write(requestSeenFd, &value, sizeof(value)) != - static_cast(sizeof(value))) { - peerError = QStringLiteral( - "failed to signal blocked recovery request"); - return; - } - pollfd descriptor{}; - descriptor.fd = cancellationIssuedFd; - descriptor.events = POLLIN; - if (::poll(&descriptor, 1, kPeerTimeoutMs) != 1) { - peerError = QStringLiteral( - "user cancellation was not issued in time"); - return; - } - uint64_t observedValue = 0; - if (::read(cancellationIssuedFd, &observedValue, - sizeof(observedValue)) != - static_cast(sizeof(observedValue))) { - peerError = QStringLiteral( - "failed to consume user-cancellation signal"); - return; - } - } + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kMediaCatalogQuery) { + peerError = QStringLiteral( + "missing identity preflight FileList"); + return; + } + auto initialList = baseResponse(request); + auto *initialMedia = + initialList.mutable_media_catalog() + ->add_media_file_list(); + initialMedia->set_file_path( + "/userdata/user/identity-race.mp4"); + initialMedia->set_file_ext( + ".h264_2240x1080"); + initialMedia->set_file_size(4096); + if (!writeResponse( + sockets[1], initialList, &peerError)) { + return; + } - auto response = baseResponse(request); - if (index == 0) { - auto *deviceInfo = response.mutable_device_information(); - deviceInfo->set_product_name("PANORAMA SE"); - deviceInfo->set_firmware_version("test-firmware"); - deviceInfo->set_serial_number("test-serial"); - } else if (index == 1) { - response.mutable_system_configuration(); - } else { - response.mutable_device_authentication()->set_auth("test-auth"); - } - if (!writeResponse(sockets[1], response, &peerError)) { - return; - } + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { + peerError = QStringLiteral( + "missing identity UserConfig preflight"); + return; + } + auto configuration = baseResponse(request); + configuration.mutable_user_configuration() + ->mutable_work_config() + ->set_single_mode_media_file( + "other.mp4.h264_2240x1080"); + if (!writeResponse( + sockets[1], configuration, &peerError)) { + return; } - panorama::wire::v1::Request sessionRequest; - if (!readRequest(sockets[1], &sessionRequest, &peerError) || - sessionRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { - if (peerError.isEmpty()) { - peerError = QStringLiteral( - "recovery did not reach display-session activation"); - } + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kMediaCatalogQuery) { + peerError = QStringLiteral( + "missing immediate identity revalidation"); return; } - auto sessionResponse = baseResponse(sessionRequest); - sessionResponse.mutable_acknowledgement(); - writeResponse(sockets[1], sessionResponse, &peerError); + auto changedList = baseResponse(request); + auto *changedMedia = + changedList.mutable_media_catalog() + ->add_media_file_list(); + changedMedia->set_file_path( + "/userdata/user/identity-race.mp4"); + changedMedia->set_file_ext( + ".h264_2240x1080"); + changedMedia->set_file_size(8192); + if (!writeResponse( + sockets[1], changedList, &peerError)) { + return; + } + verifyNoPeerPayload( + sockets[1], 150, &peerError); }); - constexpr quint64 generation = 39; - const QString endpoint = QStringLiteral("test-endpoint"); - QThread workerThread; - auto *worker = new DeviceWorker; - worker->moveToThread(&workerThread); - connect(&workerThread, &QThread::finished, - worker, &QObject::deleteLater); - workerThread.start(); - QVERIFY(QMetaObject::invokeMethod( - worker, - [worker, sockets, endpoint]() { - worker->updatePrinterGenerationGate(generation, true); - worker->configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), generation); - worker->adoptPrinterFileDescriptorForTesting(sockets[0], endpoint); + PrinterProtocol protocol(300); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + int dispatchCount = 0; + const auto result = protocol.removeUserMedia( + QStringLiteral("test-endpoint"), + QStringList{target}, + [&dispatchCount]( + int, const PrinterProtocol::MediaFile &, + QString *) { + ++dispatchCount; + return true; }, - Qt::BlockingQueuedConnection)); - - QSignalSpy sessionStartedSpy(worker, &DeviceWorker::printerSessionStarted); - QSignalSpy operationErrorSpy(worker, &DeviceWorker::printerOperationError); - QVERIFY(QMetaObject::invokeMethod( - worker, "startPrinterDisplaySession", Qt::QueuedConnection, - Q_ARG(QString, endpoint), Q_ARG(quint64, generation))); - - pollfd requestDescriptor{}; - requestDescriptor.fd = requestSeenFd; - requestDescriptor.events = POLLIN; - QCOMPARE(::poll(&requestDescriptor, 1, kPeerTimeoutMs), 1); - uint64_t requestValue = 0; - QCOMPARE(::read(requestSeenFd, &requestValue, sizeof(requestValue)), - static_cast(sizeof(requestValue))); - worker->cancelPrinterOperation(QStringLiteral("cancelled-upload")); - const uint64_t cancellationValue = 1; - QCOMPARE(::write(cancellationIssuedFd, &cancellationValue, - sizeof(cancellationValue)), - static_cast(sizeof(cancellationValue))); - - QTRY_COMPARE_WITH_TIMEOUT(sessionStartedSpy.count(), 1, - kPeerTimeoutMs * 3); - QCOMPARE(operationErrorSpy.count(), 0); - QVERIFY(QMetaObject::invokeMethod( - worker, "clearPrinterDevice", Qt::BlockingQueuedConnection, - Q_ARG(quint64, generation))); - workerThread.quit(); - QVERIFY(workerThread.wait(kPeerTimeoutMs)); - + {}, PrinterProtocol::OperationContext{}, + false, 4096); + QVERIFY(!result.success); + QCOMPARE( + result.outcome, + PrinterProtocol::MutationOutcome::NotStarted); + QVERIFY(result.error.contains( + QStringLiteral("identity changed"), + Qt::CaseInsensitive)); + QCOMPARE(dispatchCount, 0); peer.join(); - ::close(requestSeenFd); - ::close(cancellationIssuedFd); ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY2( + peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::typedFileListTransaction() { +void PrinterProtocolTests:: + deleteReplacementIdentityIsRecheckedBeforeDispatch() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + const QString target = + QStringLiteral( + "replace-old.mp4.h264_2240x1080"); + const QString replacement = + QStringLiteral( + "replace-new.mp4.h264_2240x1080"); QString peerError; std::thread peer([&]() { + const auto appendMedia = []( + panorama::wire::v1::Response *response, + const char *path, + quint32 size) { + auto *media = + response->mutable_media_catalog() + ->add_media_file_list(); + media->set_file_path(path); + media->set_file_ext( + ".h264_2240x1080"); + media->set_file_size(size); + }; panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != panorama::wire::v1::Request::kMediaCatalogQuery) { - if (peerError.isEmpty()) { - peerError = QStringLiteral("unexpected file-list request"); - } + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kMediaCatalogQuery) { + peerError = QStringLiteral( + "missing replacement preflight FileList"); return; } - auto response = baseResponse(request); - auto *userFile = response.mutable_media_catalog()->add_media_file_list(); - userFile->set_file_path("/userdata/user/custom.png"); - userFile->set_file_ext(".h264_2240x1080"); - userFile->set_file_size(1234); - auto *preset = response.mutable_media_catalog()->add_preset_file_list(); - preset->set_file_path("/userdata/default/default_01.mp4.h264_2240x1080"); - preset->set_file_size(5678); - preset->set_read_only(true); - writeResponse(sockets[1], response, &peerError); + auto initialList = baseResponse(request); + appendMedia( + &initialList, + "/userdata/user/replace-old.mp4", 4096); + appendMedia( + &initialList, + "/userdata/user/replace-new.mp4", 8192); + if (!writeResponse( + sockets[1], initialList, &peerError)) { + return; + } + + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery) { + peerError = QStringLiteral( + "missing replacement UserConfig preflight"); + return; + } + auto configuration = baseResponse(request); + configuration.mutable_user_configuration() + ->mutable_work_config() + ->set_single_mode_media_file( + "other.mp4.h264_2240x1080"); + if (!writeResponse( + sockets[1], configuration, &peerError)) { + return; + } + + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kMediaCatalogQuery) { + peerError = QStringLiteral( + "missing immediate replacement revalidation"); + return; + } + auto changedList = baseResponse(request); + appendMedia( + &changedList, + "/userdata/user/replace-old.mp4", 4096); + if (!writeResponse( + sockets[1], changedList, &peerError)) { + return; + } + verifyNoPeerPayload( + sockets[1], 150, &peerError); }); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - const PrinterProtocol::OperationContext context; - const auto result = protocol.readMediaList(QStringLiteral("test-endpoint"), context); - QVERIFY2(result.success, qPrintable(result.error)); - QCOMPARE(result.files.size(), 2); - QCOMPARE(result.files.at(0).name, QStringLiteral("custom.png.h264_2240x1080")); - QCOMPARE(result.files.at(0).size, quint32(1234)); - QVERIFY(!result.files.at(0).readOnly); - QCOMPARE(static_cast(result.files.at(0).source), - static_cast(PrinterProtocol::MediaSource::User)); - QCOMPARE(result.files.at(1).name, - QStringLiteral("default_01.mp4.h264_2240x1080")); - QCOMPARE(result.files.at(1).size, quint32(5678)); - QVERIFY(result.files.at(1).readOnly); - QCOMPARE(static_cast(result.files.at(1).source), - static_cast(PrinterProtocol::MediaSource::Preset)); + PrinterProtocol protocol(300); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + int dispatchCount = 0; + const auto result = protocol.removeUserMedia( + QStringLiteral("test-endpoint"), + QStringList{target}, + [&dispatchCount]( + int, const PrinterProtocol::MediaFile &, + QString *) { + ++dispatchCount; + return true; + }, + {}, PrinterProtocol::OperationContext{}, + false, 4096, replacement, 8192); + QVERIFY(!result.success); + QCOMPARE( + result.outcome, + PrinterProtocol::MutationOutcome::NotStarted); + QVERIFY(result.error.contains( + QStringLiteral("replacement identity changed"), + Qt::CaseInsensitive)); + QCOMPARE(dispatchCount, 0); peer.join(); ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY2( + peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::deleteUserMediaLostAckReconcilesWithoutReplay() { +void PrinterProtocolTests::deleteUserMediaRetainsUnknownAfterBoundedReconciliation() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); const QString target = - QStringLiteral("delete-me.mp4.h264_2240x1080"); + QStringLiteral("slow-delete.mp4.h264_2240x1080"); QString peerError; int removeRequests = 0; std::thread peer([&]() { + const auto respondPresent = [&](const panorama::wire::v1::Request &request) { + auto response = baseResponse(request); + auto *file = response.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/slow-delete.mp4"); + file->set_file_ext(".h264_2240x1080"); + file->set_file_size(7000); + return writeResponse(sockets[1], response, &peerError); + }; panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery) { - peerError = QStringLiteral("missing delete preflight FileList"); - return; - } - auto listResponse = baseResponse(request); - auto *file = listResponse.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/delete-me.mp4"); - file->set_file_ext(".h264_2240x1080"); - file->set_file_size(6125); - if (!writeResponse(sockets[1], listResponse, &peerError)) { + panorama::wire::v1::Request::kMediaCatalogQuery || + !respondPresent(request)) { + if (peerError.isEmpty()) { + peerError = QStringLiteral("missing slow-delete preflight"); + } return; } - if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("missing delete UserConfig preflight"); + peerError = QStringLiteral("missing slow-delete config"); return; } auto configResponse = baseResponse(request); @@ -10291,2713 +14112,3785 @@ void PrinterProtocolTests::deleteUserMediaLostAckReconcilesWithoutReplay() { if (!writeResponse(sockets[1], configResponse, &peerError)) { return; } - if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != - panorama::wire::v1::Request::kFileRemoval || - request.file_removal().file_name() != target.toStdString() || - request.file_removal().file_type() != "media") { - peerError = QStringLiteral("unexpected FileRemove contract"); + panorama::wire::v1::Request::kFileRemoval) { + peerError = QStringLiteral("missing slow-delete FileRemove"); return; } ++removeRequests; - // Deliberately omit the optional ACK. The next request must be a - // read-only reconciliation, never a second FileRemove. - if (!readRequest(sockets[1], &request, &peerError, 1000) || - request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery) { - peerError = QStringLiteral( - "missing FileList reconciliation after optional ACK timeout"); + auto removeResponse = baseResponse(request); + removeResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], removeResponse, &peerError)) { return; } - auto absentResponse = baseResponse(request); - absentResponse.mutable_media_catalog(); - writeResponse(sockets[1], absentResponse, &peerError); + for (int attempt = 0; attempt < 4; ++attempt) { + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kMediaCatalogQuery || + !respondPresent(request)) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "missing bounded reconciliation read"); + } + return; + } + } + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int polled = ::poll(&descriptor, 1, 150); + if (polled > 0 && (descriptor.revents & POLLIN) != 0) { + char byte = 0; + if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) > 0) { + peerError = QStringLiteral( + "FileRemove or unbounded FileList was replayed"); + } + } }); - PrinterProtocol protocol(60); + PrinterProtocol protocol(300); protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - int dispatchCount = 0; - const auto beforeDispatch = - [&dispatchCount](int, const PrinterProtocol::MediaFile &, - QString *) { - ++dispatchCount; - return true; - }; const auto result = protocol.removeUserMedia( QStringLiteral("test-endpoint"), QStringList{target}, - beforeDispatch, {}, PrinterProtocol::OperationContext{}, false); - QVERIFY2(result.success, qPrintable(result.error)); - QCOMPARE(result.outcome, PrinterProtocol::MutationOutcome::Succeeded); - QVERIFY(!result.commandAcknowledged); - QCOMPARE(result.deletedNames, QStringList{target}); - QCOMPARE(dispatchCount, 1); + [](int, const PrinterProtocol::MediaFile &, QString *) { + return true; + }, + {}, PrinterProtocol::OperationContext{}, false); + QVERIFY(!result.success); + QCOMPARE(result.outcome, + PrinterProtocol::MutationOutcome::PartialOrUnknown); + QVERIFY(result.commandAcknowledged); + QVERIFY(result.error.contains(QStringLiteral("will not be repeated"), + Qt::CaseInsensitive)); peer.join(); ::close(sockets[1]); QCOMPARE(removeRequests, 1); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::deleteUserMediaAcceptsHeaderOnlySuccess() { +void PrinterProtocolTests::deleteReconcileOnlyNeverDispatchesFileRemove() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); const QString target = - QStringLiteral("header-only.mp4.h264_2240x1080"); + QStringLiteral("pending.mp4.h264_2240x1080"); QString peerError; std::thread peer([&]() { panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != panorama::wire::v1::Request::kMediaCatalogQuery) { - peerError = QStringLiteral("missing header-only preflight"); + peerError = QStringLiteral("reconcile-only did not read FileList"); return; } - auto listResponse = baseResponse(request); - auto *file = listResponse.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/header-only.mp4"); + auto response = baseResponse(request); + auto *file = response.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/pending.mp4"); file->set_file_ext(".h264_2240x1080"); - file->set_file_size(5000); - if (!writeResponse(sockets[1], listResponse, &peerError)) { - return; - } - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("missing header-only config"); - return; - } - auto configResponse = baseResponse(request); - configResponse.mutable_user_configuration()->mutable_work_config() - ->set_single_mode_media_file("other.mp4.h264_2240x1080"); - if (!writeResponse(sockets[1], configResponse, &peerError)) { - return; - } - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kFileRemoval) { - peerError = QStringLiteral("missing header-only FileRemove"); - return; - } - const auto headerOnlyResponse = baseResponse(request); - if (!writeResponse(sockets[1], headerOnlyResponse, &peerError)) { + file->set_file_size(8000); + if (!writeResponse(sockets[1], response, &peerError)) { return; } - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery) { - peerError = QStringLiteral("missing header-only reconciliation"); - return; + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int polled = ::poll(&descriptor, 1, 150); + if (polled > 0 && (descriptor.revents & POLLIN) != 0) { + char byte = 0; + if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) > 0) { + peerError = QStringLiteral( + "reconcile-only path sent a mutation"); + } } - auto absentResponse = baseResponse(request); - absentResponse.mutable_media_catalog(); - writeResponse(sockets[1], absentResponse, &peerError); }); - PrinterProtocol protocol(300); - protocol.adoptFileDescriptorForTesting(sockets[0], - QStringLiteral("test-endpoint")); - const auto result = protocol.removeUserMedia( - QStringLiteral("test-endpoint"), QStringList{target}, - [](int, const PrinterProtocol::MediaFile &, QString *) { - return true; - }, - {}, PrinterProtocol::OperationContext{}, false); - QVERIFY2(result.success, qPrintable(result.error)); - QVERIFY(result.commandAcknowledged); - QCOMPARE(result.deletedNames, QStringList{target}); - peer.join(); - ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + PrinterProtocol protocol(300); + protocol.adoptFileDescriptorForTesting(sockets[0], + QStringLiteral("test-endpoint")); + const auto result = protocol.removeUserMedia( + QStringLiteral("test-endpoint"), QStringList{target}, {}, {}, + PrinterProtocol::OperationContext{}, true); + QVERIFY(!result.success); + QCOMPARE(result.outcome, + PrinterProtocol::MutationOutcome::PartialOrUnknown); + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::deleteIntentSurvivesRestartAndOnlyReconcilesSameDevice() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sysRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); + const QString devRoot = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); + QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); + QVERIFY(createPrinterEndpoint(sysRoot, devRoot, QStringLiteral("1-1"), + QStringLiteral("lp0"))); + const QString target = + QStringLiteral("journal.mp4.h264_2240x1080"); + const QString operationId = + QStringLiteral("17171717-1717-4717-8717-171717171717"); + QString deviceIdentity; + + { + std::unique_ptr manager( + DeviceManager::createForTesting(sysRoot, devRoot)); + manager->setAutoConnectModeForTesting(true); + manager->rescanPrinterForTesting(); + manager->printerDisplaySessionActive_ = true; + deviceIdentity = manager->printerDeviceSerial_; + QVERIFY(!deviceIdentity.isEmpty()); + PrinterProtocol::MediaFile media; + media.name = target; + media.size = 9000; + media.source = PrinterProtocol::MediaSource::User; + media.readOnly = false; + manager->updateMediaCatalog({media}); + QObject::disconnect(manager.get(), + &DeviceManager::requestPrinterDeleteMedia, + manager->worker_, + &DeviceWorker::deletePrinterMedia); + QSignalSpy deleteRequestSpy( + manager.get(), &DeviceManager::requestPrinterDeleteMedia); + QCOMPARE(manager->queueDeleteMediaOperation(operationId, + QStringList{target}), + operationId); + QCOMPARE(deleteRequestSpy.count(), 1); + QCOMPARE(deleteRequestSpy.first().at(4).toBool(), false); + QString intentError; + QVERIFY2(manager->writeDeleteIntent( + operationId, QStringLiteral("Dispatch"), true, 0, + target, {}, &intentError), + qPrintable(intentError)); + emit manager->worker_->printerDeleteFinished( + operationId, QStringList{target}, {}, {}, false, + PrinterProtocol::MutationOutcome::PartialOrUnknown, + QStringLiteral("simulated lost reconciliation"), + manager->printerGeneration_); + const TryxRuntimeOperationInfo pending = + manager->operationInfo(operationId); + QCOMPARE(pending.state, QStringLiteral("RetryAvailable")); + QCOMPARE(pending.retryMode, QStringLiteral("DeleteReconcile")); + QVERIFY(QFileInfo::exists(manager->deleteIntentPath())); + manager->cancelOperation(operationId); + QVERIFY(QFileInfo::exists(manager->deleteIntentPath())); + QCOMPARE(manager->operationInfo(operationId).retryMode, + QStringLiteral("DeleteReconcile")); + } + + std::unique_ptr recovered( + DeviceManager::createForTesting(sysRoot, devRoot)); + recovered->setAutoConnectModeForTesting(true); + recovered->rescanPrinterForTesting(); + recovered->printerDisplaySessionActive_ = true; + recovered->loadDeleteIntent(); + QCOMPARE(recovered->pendingDeleteOperationId_, operationId); + QCOMPARE(recovered->operationInfo(operationId).retryMode, + QStringLiteral("DeleteReconcile")); + QObject::disconnect(recovered.get(), + &DeviceManager::requestPrinterDeleteMedia, + recovered->worker_, + &DeviceWorker::deletePrinterMedia); + QSignalSpy reconciliationSpy( + recovered.get(), &DeviceManager::requestPrinterDeleteMedia); + recovered->printerDeviceSerial_ = QStringLiteral("different-device"); + recovered->resumePendingDeleteReconciliation(); + QCOMPARE(reconciliationSpy.count(), 0); + QVERIFY(QFileInfo::exists(recovered->deleteIntentPath())); + + recovered->printerDeviceSerial_ = deviceIdentity; + recovered->resumePendingDeleteReconciliation(); + QCOMPARE(reconciliationSpy.count(), 1); + QCOMPARE(reconciliationSpy.first().at(1).toStringList(), + QStringList{target}); + QCOMPARE(reconciliationSpy.first().at(4).toBool(), true); + emit recovered->worker_->printerDeleteFinished( + operationId, QStringList{target}, QStringList{target}, {}, true, + PrinterProtocol::MutationOutcome::Succeeded, QString(), + recovered->printerGeneration_); + QCOMPARE(recovered->operationInfo(operationId).state, + QStringLiteral("Succeeded")); + QVERIFY(!QFileInfo::exists(recovered->deleteIntentPath())); + QVERIFY(recovered->pendingDeleteOperationId_.isEmpty()); } -void PrinterProtocolTests::deleteUserMediaRejectsReferencedFileBeforeDispatch() { +void PrinterProtocolTests::passivePrinterWorkerSendsNoFrames() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const QString target = - QStringLiteral("active.mp4.h264_2240x1080"); + QString peerError; std::thread peer([&]() { - panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery) { - peerError = QStringLiteral("missing referenced-file FileList"); - return; - } - auto listResponse = baseResponse(request); - auto *file = listResponse.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/active.mp4"); - file->set_file_ext(".h264_2240x1080"); - file->set_file_size(4000); - if (!writeResponse(sockets[1], listResponse, &peerError)) { - return; - } - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("missing referenced-file UserConfig"); - return; - } - auto configResponse = baseResponse(request); - configResponse.mutable_user_configuration()->mutable_poweron_config() - ->set_media_file(target.toStdString()); - if (!writeResponse(sockets[1], configResponse, &peerError)) { - return; - } pollfd descriptor{}; descriptor.fd = sockets[1]; descriptor.events = POLLIN; - const int polled = ::poll(&descriptor, 1, 150); - if (polled > 0 && (descriptor.revents & POLLIN) != 0) { + const int pollResult = ::poll(&descriptor, 1, 200); + if (pollResult < 0) { + peerError = QStringLiteral("passive session poll failed"); + return; + } + if (pollResult > 0) { char byte = 0; - if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) > 0) { - peerError = QStringLiteral( - "FileRemove was sent for referenced media"); + const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); + if (received > 0) { + peerError = QStringLiteral("passive printer worker sent USB bytes"); } } }); - PrinterProtocol protocol(300); - protocol.adoptFileDescriptorForTesting(sockets[0], - QStringLiteral("test-endpoint")); - int dispatchCount = 0; - const auto result = protocol.removeUserMedia( - QStringLiteral("test-endpoint"), QStringList{target}, - [&dispatchCount](int, const PrinterProtocol::MediaFile &, QString *) { - ++dispatchCount; - return true; - }, - {}, PrinterProtocol::OperationContext{}, false); - QVERIFY(!result.success); - QCOMPARE(result.outcome, PrinterProtocol::MutationOutcome::NotStarted); - QVERIFY(result.error.contains(QStringLiteral("referenced"), - Qt::CaseInsensitive)); - QCOMPARE(dispatchCount, 0); + constexpr quint64 generation = 40; + const QString endpoint = QStringLiteral("test-endpoint"); + DeviceWorker worker; + worker.updatePrinterGenerationGate(generation, true); + worker.configurePrinterDevice( + endpoint, QStringLiteral("test-serial"), generation); + worker.adoptPrinterFileDescriptorForTesting(sockets[0], endpoint); + QVERIFY(!worker.printerSessionActiveForTesting()); + QVERIFY(QMetaObject::invokeMethod(&worker, "sendPrinterKeepalive", + Qt::DirectConnection)); + peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::deleteUserMediaRetainsUnknownAfterBoundedReconciliation() { +void PrinterProtocolTests::printerRefreshStartsSessionAndKeepalive() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const QString target = - QStringLiteral("slow-delete.mp4.h264_2240x1080"); + const int firstKeepaliveSeenFd = eventfd(0, EFD_CLOEXEC); + QVERIFY(firstKeepaliveSeenFd >= 0); + QString peerError; - int removeRequests = 0; std::thread peer([&]() { - const auto respondPresent = [&](const panorama::wire::v1::Request &request) { - auto response = baseResponse(request); - auto *file = response.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/slow-delete.mp4"); - file->set_file_ext(".h264_2240x1080"); - file->set_file_size(7000); - return writeResponse(sockets[1], response, &peerError); - }; - panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery || - !respondPresent(request)) { - if (peerError.isEmpty()) { - peerError = QStringLiteral("missing slow-delete preflight"); - } - return; - } - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("missing slow-delete config"); - return; - } - auto configResponse = baseResponse(request); - configResponse.mutable_user_configuration()->mutable_work_config() - ->set_single_mode_media_file("other.mp4.h264_2240x1080"); - if (!writeResponse(sockets[1], configResponse, &peerError)) { - return; - } - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kFileRemoval) { - peerError = QStringLiteral("missing slow-delete FileRemove"); - return; - } - ++removeRequests; - auto removeResponse = baseResponse(request); - removeResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], removeResponse, &peerError)) { + QByteArray requestBuffer; + if (!serveUdbBootstrap(sockets[1], &peerError, + &requestBuffer)) { return; } - for (int attempt = 0; attempt < 4; ++attempt) { - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery || - !respondPresent(request)) { - if (peerError.isEmpty()) { + bool activationSeen = false; + bool pingKeepaliveSeen = false; + int fileListsSeen = 0; + for (int requestCount = 0; + requestCount < 12 && + (!activationSeen || fileListsSeen < 2 || + !pingKeepaliveSeen); + ++requestCount) { + panorama::wire::v1::Request request; + QString readError; + if (!readRequest(sockets[1], &request, &readError, + kPeerTimeoutMs * 3, &requestBuffer)) { + peerError = QStringLiteral( + "session request read failed after %1 requests " + "(activation=%2 lists=%3 keepalive=%4): %5") + .arg(requestCount) + .arg(activationSeen) + .arg(fileListsSeen) + .arg(pingKeepaliveSeen) + .arg(readError); + return; + } + + auto response = baseResponse(request); + if (request.body_case() == + panorama::wire::v1::Request::kOverlayLayout) { + if (!activationSeen) { + if (!request.has_header() || + request.header().version() != 1 || + request.header().track_id() == 0) { + peerError = QStringLiteral( + "unexpected tracked display-session activation"); + return; + } + activationSeen = true; + } else { peerError = QStringLiteral( - "missing bounded reconciliation read"); + "periodic keepalive repeated RunConfig instead of Ping"); + return; + } + response.mutable_acknowledgement(); + } else if (request.body_case() == + panorama::wire::v1::Request::kMediaCatalogQuery) { + if (!activationSeen || fileListsSeen >= 2) { + peerError = QStringLiteral( + "unexpected FileList request in display session"); + return; + } + ++fileListsSeen; + auto *file = + response.mutable_media_catalog()->add_media_file_list(); + file->set_file_path("/userdata/user/session-test.png"); + file->set_file_ext(".h264_2240x1080"); + file->set_file_size(1234); + } else if (request.body_case() == + panorama::wire::v1::Request::kPing) { + if (!activationSeen || !request.has_header() || + request.header().ByteSizeLong() != 0 || + request.ping().payload() != "hello?") { + peerError = QStringLiteral( + "periodic keepalive did not match UDB Ping"); + return; + } + pingKeepaliveSeen = true; + const uint64_t seenValue = 1; + if (::write(firstKeepaliveSeenFd, &seenValue, + sizeof(seenValue)) != + static_cast(sizeof(seenValue))) { + peerError = QStringLiteral( + "first keepalive readiness signal failed"); + return; } + response.mutable_pong()->set_payload( + request.ping().payload()); + } else { + peerError = QStringLiteral("unexpected session request body %1") + .arg(static_cast( + request.body_case())); return; } - } - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int polled = ::poll(&descriptor, 1, 150); - if (polled > 0 && (descriptor.revents & POLLIN) != 0) { - char byte = 0; - if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) > 0) { - peerError = QStringLiteral( - "FileRemove or unbounded FileList was replayed"); + + if (!writeResponse(sockets[1], response, &peerError)) { + return; } } + if (!activationSeen || fileListsSeen != 2 || + !pingKeepaliveSeen) { + peerError = QStringLiteral( + "display session sequence did not complete"); + return; + } + + pollfd closeDescriptor{}; + closeDescriptor.fd = sockets[1]; + closeDescriptor.events = POLLIN; + const int closePoll = ::poll(&closeDescriptor, 1, 100); + if (closePoll < 0) { + peerError = QString::fromLocal8Bit(std::strerror(errno)); + return; + } + char byte = 0; + const ssize_t peeked = ::recv( + sockets[1], &byte, sizeof(byte), MSG_PEEK | MSG_DONTWAIT); + if (peeked == 0) { + peerError = QStringLiteral( + "display keepalive unexpectedly closed the persistent transport"); + } else if (peeked < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + peerError = QString::fromLocal8Bit(std::strerror(errno)); + } }); - PrinterProtocol protocol(300); - protocol.adoptFileDescriptorForTesting(sockets[0], - QStringLiteral("test-endpoint")); - const auto result = protocol.removeUserMedia( - QStringLiteral("test-endpoint"), QStringList{target}, - [](int, const PrinterProtocol::MediaFile &, QString *) { - return true; - }, - {}, PrinterProtocol::OperationContext{}, false); - QVERIFY(!result.success); - QCOMPARE(result.outcome, - PrinterProtocol::MutationOutcome::PartialOrUnknown); - QVERIFY(result.commandAcknowledged); - QVERIFY(result.error.contains(QStringLiteral("will not be repeated"), - Qt::CaseInsensitive)); + constexpr quint64 generation = 41; + const QString endpoint = QStringLiteral("test-endpoint"); + DeviceWorker worker; + worker.updatePrinterGenerationGate(generation, true); + worker.configurePrinterDevice( + endpoint, QStringLiteral("test-serial"), generation); + worker.adoptPrinterFileDescriptorForTesting(sockets[0], endpoint); + QSignalSpy listSpy(&worker, &DeviceWorker::printerMediaListReady); + QSignalSpy errorSpy(&worker, &DeviceWorker::printerMediaListFailed); + + worker.refreshPrinterMediaList(endpoint, QString(), generation); + worker.refreshPrinterMediaList(endpoint, QString(), generation); + if (errorSpy.count() != 0 || listSpy.count() != 2) { + worker.clearPrinterDevice(generation); + peer.join(); + ::close(firstKeepaliveSeenFd); + ::close(sockets[1]); + QFAIL(qPrintable( + QStringLiteral( + "refresh session failed: errors=%1 lists=%2 first=%3 second=%4 peer=%5") + .arg(errorSpy.count()) + .arg(listSpy.count()) + .arg(errorSpy.count() > 0 + ? errorSpy.at(0).at(1).toString() + : QStringLiteral("")) + .arg(errorSpy.count() > 1 + ? errorSpy.at(1).at(1).toString() + : QStringLiteral("")) + .arg(peerError))); + } + QCOMPARE(errorSpy.count(), 0); + QCOMPARE(listSpy.count(), 2); + const QList firstFiles = + qvariant_cast>( + listSpy.at(0).at(1)); + QCOMPARE(firstFiles.size(), 1); + QCOMPARE(firstFiles.first().name, + QStringLiteral("session-test.png.h264_2240x1080")); + QVERIFY(worker.printerSessionActiveForTesting()); + QVERIFY(QMetaObject::invokeMethod(&worker, "sendPrinterKeepalive", + Qt::DirectConnection)); + pollfd keepaliveDescriptor{}; + keepaliveDescriptor.fd = firstKeepaliveSeenFd; + keepaliveDescriptor.events = POLLIN; + QCOMPARE(::poll(&keepaliveDescriptor, 1, kPeerTimeoutMs), 1); + uint64_t seenValue = 0; + QCOMPARE(::read(firstKeepaliveSeenFd, &seenValue, sizeof(seenValue)), + static_cast(sizeof(seenValue))); + QCOMPARE(seenValue, uint64_t(1)); + peer.join(); + ::close(firstKeepaliveSeenFd); ::close(sockets[1]); - QCOMPARE(removeRequests, 1); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::deleteReconcileOnlyNeverDispatchesFileRemove() { +void PrinterProtocolTests:: +runConfigErrorResponseRejectsSessionStart() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const QString target = - QStringLiteral("pending.mp4.h264_2240x1080"); + QString peerError; std::thread peer([&]() { + QByteArray requestBuffer; + if (!serveUdbBootstrap(sockets[1], &peerError, + &requestBuffer)) { + return; + } panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kMediaCatalogQuery) { - peerError = QStringLiteral("reconcile-only did not read FileList"); + if (!readRequest(sockets[1], &request, &peerError, + kPeerTimeoutMs, &requestBuffer) || + request.body_case() != panorama::wire::v1::Request::kOverlayLayout) { + peerError = QStringLiteral("session failure test did not receive RunConfig"); return; } auto response = baseResponse(request); - auto *file = response.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/pending.mp4"); - file->set_file_ext(".h264_2240x1080"); - file->set_file_size(8000); + response.mutable_error()->set_code(panorama::wire::v1::ProtocolError::FAILURE); + response.mutable_error()->set_why("session rejected"); if (!writeResponse(sockets[1], response, &peerError)) { return; } - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int polled = ::poll(&descriptor, 1, 150); - if (polled > 0 && (descriptor.revents & POLLIN) != 0) { - char byte = 0; - if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) > 0) { - peerError = QStringLiteral( - "reconcile-only path sent a mutation"); - } - } }); - PrinterProtocol protocol(300); - protocol.adoptFileDescriptorForTesting(sockets[0], - QStringLiteral("test-endpoint")); - const auto result = protocol.removeUserMedia( - QStringLiteral("test-endpoint"), QStringList{target}, {}, {}, - PrinterProtocol::OperationContext{}, true); - QVERIFY(!result.success); - QCOMPARE(result.outcome, - PrinterProtocol::MutationOutcome::PartialOrUnknown); - peer.join(); - ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); -} - -void PrinterProtocolTests::deleteIntentSurvivesRestartAndOnlyReconcilesSameDevice() { - QTemporaryDir temporaryDirectory; - QVERIFY(temporaryDirectory.isValid()); - const QString sysRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("sys")); - const QString devRoot = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("dev")); - QVERIFY(createUsbDevice(sysRoot, QStringLiteral("1-1"), "1021")); - QVERIFY(createPrinterEndpoint(sysRoot, devRoot, QStringLiteral("1-1"), - QStringLiteral("lp0"))); - const QString target = - QStringLiteral("journal.mp4.h264_2240x1080"); - const QString operationId = - QStringLiteral("17171717-1717-4717-8717-171717171717"); - QString deviceIdentity; - - { - std::unique_ptr manager( - DeviceManager::createForTesting(sysRoot, devRoot)); - manager->setAutoConnectModeForTesting(true); - manager->rescanPrinterForTesting(); - manager->printerDisplaySessionActive_ = true; - deviceIdentity = manager->printerDeviceSerial_; - QVERIFY(!deviceIdentity.isEmpty()); - PrinterProtocol::MediaFile media; - media.name = target; - media.size = 9000; - media.source = PrinterProtocol::MediaSource::User; - media.readOnly = false; - manager->updateMediaCatalog({media}); - QObject::disconnect(manager.get(), - &DeviceManager::requestPrinterDeleteMedia, - manager->worker_, - &DeviceWorker::deletePrinterMedia); - QSignalSpy deleteRequestSpy( - manager.get(), &DeviceManager::requestPrinterDeleteMedia); - QCOMPARE(manager->queueDeleteMediaOperation(operationId, - QStringList{target}), - operationId); - QCOMPARE(deleteRequestSpy.count(), 1); - QCOMPARE(deleteRequestSpy.first().at(4).toBool(), false); - QString intentError; - QVERIFY2(manager->writeDeleteIntent( - operationId, QStringLiteral("Dispatch"), true, 0, - target, {}, &intentError), - qPrintable(intentError)); - emit manager->worker_->printerDeleteFinished( - operationId, QStringList{target}, {}, {}, false, - PrinterProtocol::MutationOutcome::PartialOrUnknown, - QStringLiteral("simulated lost reconciliation"), - manager->printerGeneration_); - const TryxRuntimeOperationInfo pending = - manager->operationInfo(operationId); - QCOMPARE(pending.state, QStringLiteral("RetryAvailable")); - QCOMPARE(pending.retryMode, QStringLiteral("DeleteReconcile")); - QVERIFY(QFileInfo::exists(manager->deleteIntentPath())); - manager->cancelOperation(operationId); - QVERIFY(QFileInfo::exists(manager->deleteIntentPath())); - QCOMPARE(manager->operationInfo(operationId).retryMode, - QStringLiteral("DeleteReconcile")); - } + constexpr quint64 generation = 42; + const QString endpoint = QStringLiteral("test-endpoint"); + DeviceWorker worker; + worker.updatePrinterGenerationGate(generation, true); + worker.configurePrinterDevice( + endpoint, QStringLiteral("test-serial"), generation); + worker.adoptPrinterFileDescriptorForTesting(sockets[0], endpoint); + QSignalSpy listSpy(&worker, &DeviceWorker::printerMediaListReady); + QSignalSpy errorSpy(&worker, &DeviceWorker::printerMediaListFailed); - std::unique_ptr recovered( - DeviceManager::createForTesting(sysRoot, devRoot)); - recovered->setAutoConnectModeForTesting(true); - recovered->rescanPrinterForTesting(); - recovered->printerDisplaySessionActive_ = true; - recovered->loadDeleteIntent(); - QCOMPARE(recovered->pendingDeleteOperationId_, operationId); - QCOMPARE(recovered->operationInfo(operationId).retryMode, - QStringLiteral("DeleteReconcile")); - QObject::disconnect(recovered.get(), - &DeviceManager::requestPrinterDeleteMedia, - recovered->worker_, - &DeviceWorker::deletePrinterMedia); - QSignalSpy reconciliationSpy( - recovered.get(), &DeviceManager::requestPrinterDeleteMedia); - recovered->printerDeviceSerial_ = QStringLiteral("different-device"); - recovered->resumePendingDeleteReconciliation(); - QCOMPARE(reconciliationSpy.count(), 0); - QVERIFY(QFileInfo::exists(recovered->deleteIntentPath())); + worker.refreshPrinterMediaList(endpoint, QString(), generation); + QCOMPARE(listSpy.count(), 0); + QCOMPARE(errorSpy.count(), 1); + QVERIFY(!worker.printerSessionActiveForTesting()); + QVERIFY(errorSpy.first().at(1).toString().contains( + QStringLiteral("session rejected"), + Qt::CaseInsensitive)); - recovered->printerDeviceSerial_ = deviceIdentity; - recovered->resumePendingDeleteReconciliation(); - QCOMPARE(reconciliationSpy.count(), 1); - QCOMPARE(reconciliationSpy.first().at(1).toStringList(), - QStringList{target}); - QCOMPARE(reconciliationSpy.first().at(4).toBool(), true); - emit recovered->worker_->printerDeleteFinished( - operationId, QStringList{target}, QStringList{target}, {}, true, - PrinterProtocol::MutationOutcome::Succeeded, QString(), - recovered->printerGeneration_); - QCOMPARE(recovered->operationInfo(operationId).state, - QStringLiteral("Succeeded")); - QVERIFY(!QFileInfo::exists(recovered->deleteIntentPath())); - QVERIFY(recovered->pendingDeleteOperationId_.isEmpty()); + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::passivePrinterWorkerSendsNoFrames() { +void PrinterProtocolTests:: +restoredOverlayWaitsForKeepaliveBeforeSessionReady() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); + + const QString expectedCpu = + SystemMonitor::cpuModelName().trimmed(); + QVERIFY(!expectedCpu.isEmpty()); + SystemMonitor modelMonitor; + const QString expectedGpu = + modelMonitor.primaryGpuModelName().trimmed(); QString peerError; std::thread peer([&]() { - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int pollResult = ::poll(&descriptor, 1, 200); - if (pollResult < 0) { - peerError = QStringLiteral("passive session poll failed"); + QByteArray requestBuffer; + if (!serveUdbBootstrap( + sockets[1], &peerError, &requestBuffer)) { return; } - if (pollResult > 0) { - char byte = 0; - const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); - if (received > 0) { - peerError = QStringLiteral("passive printer worker sent USB bytes"); + panorama::wire::v1::Request bootstrapRunRequest; + if (!readRequest( + sockets[1], &bootstrapRunRequest, &peerError, + kPeerTimeoutMs, &requestBuffer) || + bootstrapRunRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + peerError = QStringLiteral( + "restored overlay test did not receive bootstrap RunConfig"); + return; + } + if (!bootstrapRunRequest.has_header() || + bootstrapRunRequest.header().version() != 1 || + bootstrapRunRequest.header().track_id() == 0 || + bootstrapRunRequest.overlay_layout().label_groups_size() != 0) { + peerError = QStringLiteral( + "bootstrap RunConfig was not an empty tracked activation"); + return; + } + auto bootstrapRunResponse = + baseResponse(bootstrapRunRequest); + bootstrapRunResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], bootstrapRunResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request keepaliveRequest; + if (!readRequest( + sockets[1], &keepaliveRequest, &peerError, + kPeerTimeoutMs, &requestBuffer) || + keepaliveRequest.body_case() != + panorama::wire::v1::Request::kPing || + !keepaliveRequest.has_header() || + keepaliveRequest.header().ByteSizeLong() != 0 || + keepaliveRequest.ping().payload() != "hello?") { + peerError = QStringLiteral( + "restored overlay test did not receive the readiness Ping"); + return; + } + panorama::wire::v1::Response keepaliveResponse; + keepaliveResponse.mutable_header(); + keepaliveResponse.mutable_pong()->set_payload("Hey!"); + if (!writeResponse(sockets[1], keepaliveResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request overlayRunRequest; + if (!readRequest( + sockets[1], &overlayRunRequest, &peerError, + kPeerTimeoutMs, &requestBuffer) || + overlayRunRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout) { + peerError = QStringLiteral( + "restored overlay test did not receive the full RunConfig"); + return; + } + if (!overlayRunRequest.has_header() || + overlayRunRequest.header().version() != 1 || + overlayRunRequest.header().track_id() == 0 || + overlayRunRequest.header().track_id() == + bootstrapRunRequest.header().track_id()) { + peerError = QStringLiteral( + "restored overlay RunConfig was not a new tracked mutation"); + return; + } + const auto &run = overlayRunRequest.overlay_layout(); + const panorama::wire::v1::OverlayGroup *metricGroup = + nullptr; + const panorama::wire::v1::OverlayGroup *badgeGroup = + nullptr; + for (int index = 0; + index < run.label_groups_size(); ++index) { + if (run.label_groups(index).group_id() == 100U) { + metricGroup = &run.label_groups(index); + } else if ( + run.label_groups(index).group_id() == 300U) { + badgeGroup = &run.label_groups(index); } } + if (!metricGroup || + metricGroup->labels_size() < 3 || + !badgeGroup || + badgeGroup->labels_size() < + (expectedGpu.isEmpty() ? 1 : 2) || + QString::fromStdString( + badgeGroup->labels(0).text()).trimmed() != expectedCpu || + (!expectedGpu.isEmpty() && + QString::fromStdString( + badgeGroup->labels(1).text()).trimmed() != expectedGpu)) { + peerError = QStringLiteral( + "restored overlay was not hydrated: " + "metric_labels=%1 badge_labels=%2 cpu=%3 gpu=%4") + .arg( + metricGroup + ? metricGroup->labels_size() + : -1) + .arg( + badgeGroup + ? badgeGroup->labels_size() + : -1) + .arg( + badgeGroup && + badgeGroup->labels_size() > 0 + ? QString::fromStdString( + badgeGroup->labels(0) + .text()) + : QStringLiteral("")) + .arg( + badgeGroup && + badgeGroup->labels_size() > 1 + ? QString::fromStdString( + badgeGroup->labels(1) + .text()) + : QStringLiteral("")); + return; + } + auto runResponse = baseResponse(overlayRunRequest); + runResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], runResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request activePing; + if (!readRequest( + sockets[1], &activePing, &peerError, + kPeerTimeoutMs, &requestBuffer) || + activePing.body_case() != + panorama::wire::v1::Request::kPing || + !activePing.has_header() || + activePing.header().ByteSizeLong() != 0) { + peerError = QStringLiteral( + "active overlay session did not send Ping first"); + return; + } + panorama::wire::v1::Response activePong; + activePong.mutable_header(); + activePong.mutable_pong()->set_payload("Hey!"); + if (!writeResponse(sockets[1], activePong, + &peerError)) { + return; + } + + panorama::wire::v1::Request overlayLease; + if (!readRequest( + sockets[1], &overlayLease, &peerError, + kPeerTimeoutMs, &requestBuffer) || + overlayLease.body_case() != + panorama::wire::v1::Request::kOverlayLayout || + !overlayLease.has_header() || + overlayLease.header().ByteSizeLong() != 0 || + overlayLease.overlay_layout().label_groups_size() == 0) { + peerError = QStringLiteral( + "active overlay session did not refresh the layout lease"); + return; + } + panorama::wire::v1::Response leaseResponse; + leaseResponse.mutable_header(); + leaseResponse.mutable_acknowledgement(); + writeResponse(sockets[1], leaseResponse, + &peerError); }); - constexpr quint64 generation = 40; - const QString endpoint = QStringLiteral("test-endpoint"); + constexpr quint64 generation = 43; + const QString endpoint = + QStringLiteral("test-endpoint"); DeviceWorker worker; worker.updatePrinterGenerationGate(generation, true); worker.configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), generation); - worker.adoptPrinterFileDescriptorForTesting(sockets[0], endpoint); + endpoint, QStringLiteral("test-serial"), + generation); + worker.adoptPrinterFileDescriptorForTesting( + sockets[0], endpoint); + PrinterProtocol::PaseOverlayConfig overlay; + overlay.left.metrics = { + QStringLiteral("CPU Temperature")}; + overlay.left.badges = { + QStringLiteral("CPU Badge")}; + if (!expectedGpu.isEmpty()) { + overlay.left.badges.append( + QStringLiteral("GPU Badge")); + } + worker.restorePrinterOverlay(overlay, generation); + QSignalSpy startedSpy( + &worker, &DeviceWorker::printerSessionStarted); + QSignalSpy lostSpy( + &worker, &DeviceWorker::printerSessionLost); + QSignalSpy errorSpy( + &worker, &DeviceWorker::printerOperationError); + QSignalSpy readySpy( + &worker, &DeviceWorker::printerTransportReady); + + worker.startPrinterDisplaySession(endpoint, generation); + QCOMPARE(worker.printerSessionState_, + DeviceWorker::PrinterSessionState:: + AwaitingOverlayActivation); + QVERIFY(worker.printerOverlayActivationPending_); QVERIFY(!worker.printerSessionActiveForTesting()); - QVERIFY(QMetaObject::invokeMethod(&worker, "sendPrinterKeepalive", - Qt::DirectConnection)); + QVERIFY(!worker.printerMetricsTimer_->isActive()); + QCOMPARE(startedSpy.count(), 0); + QCOMPARE(lostSpy.count(), 0); + QCOMPARE(errorSpy.count(), 0); + + QVERIFY(QMetaObject::invokeMethod( + &worker, "sendPrinterKeepalive", + Qt::DirectConnection)); + QVERIFY(QMetaObject::invokeMethod( + &worker, "sendPrinterKeepalive", + Qt::DirectConnection)); + QVERIFY(QMetaObject::invokeMethod( + &worker, "sendPrinterKeepalive", + Qt::DirectConnection)); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QCOMPARE(worker.printerSessionState_, + DeviceWorker::PrinterSessionState::Active); + QVERIFY(!worker.printerOverlayActivationPending_); + QCOMPARE(startedSpy.count(), 1); + QCOMPARE(lostSpy.count(), 0); + QCOMPARE(errorSpy.count(), 0); + QCOMPARE(readySpy.count(), 3); + QVERIFY(worker.printerSessionActiveForTesting()); + QVERIFY(worker.printerMetricsTimer_->isActive()); + QVERIFY(!worker.printerRecoveryTimer_->isActive()); } -void PrinterProtocolTests::printerRefreshStartsSessionAndKeepalive() { +void PrinterProtocolTests:: +restoredOverlayFailureBecomesLostWithoutReplay() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int firstKeepaliveSeenFd = eventfd(0, EFD_CLOEXEC); - QVERIFY(firstKeepaliveSeenFd >= 0); + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); QString peerError; + int overlayRunCount = 0; std::thread peer([&]() { QByteArray requestBuffer; - if (!serveUdbBootstrap(sockets[1], &peerError, - &requestBuffer)) { + if (!serveUdbBootstrap( + sockets[1], &peerError, &requestBuffer)) { return; } - bool activationSeen = false; - bool pingKeepaliveSeen = false; - int fileListsSeen = 0; - for (int requestCount = 0; - requestCount < 12 && - (!activationSeen || fileListsSeen < 2 || - !pingKeepaliveSeen); - ++requestCount) { - panorama::wire::v1::Request request; - QString readError; - if (!readRequest(sockets[1], &request, &readError, - kPeerTimeoutMs * 3, &requestBuffer)) { - peerError = QStringLiteral( - "session request read failed after %1 requests " - "(activation=%2 lists=%3 keepalive=%4): %5") - .arg(requestCount) - .arg(activationSeen) - .arg(fileListsSeen) - .arg(pingKeepaliveSeen) - .arg(readError); - return; - } - - auto response = baseResponse(request); - if (request.body_case() == - panorama::wire::v1::Request::kOverlayLayout) { - if (!activationSeen) { - if (!request.has_header() || - request.header().version() != 1 || - request.header().track_id() == 0) { - peerError = QStringLiteral( - "unexpected tracked display-session activation"); - return; - } - activationSeen = true; - } else { - peerError = QStringLiteral( - "periodic keepalive repeated RunConfig instead of Ping"); - return; - } - response.mutable_acknowledgement(); - } else if (request.body_case() == - panorama::wire::v1::Request::kMediaCatalogQuery) { - if (!activationSeen || fileListsSeen >= 2) { - peerError = QStringLiteral( - "unexpected FileList request in display session"); - return; - } - ++fileListsSeen; - auto *file = - response.mutable_media_catalog()->add_media_file_list(); - file->set_file_path("/userdata/user/session-test.png"); - file->set_file_ext(".h264_2240x1080"); - file->set_file_size(1234); - } else if (request.body_case() == - panorama::wire::v1::Request::kPing) { - if (!activationSeen || !request.has_header() || - request.header().ByteSizeLong() != 0 || - request.ping().payload() != "hello?") { - peerError = QStringLiteral( - "periodic keepalive did not match UDB Ping"); - return; - } - pingKeepaliveSeen = true; - const uint64_t seenValue = 1; - if (::write(firstKeepaliveSeenFd, &seenValue, - sizeof(seenValue)) != - static_cast(sizeof(seenValue))) { - peerError = QStringLiteral( - "first keepalive readiness signal failed"); - return; - } - response.mutable_pong()->set_payload( - request.ping().payload()); - } else { - peerError = QStringLiteral("unexpected session request body %1") - .arg(static_cast( - request.body_case())); - return; - } - if (!writeResponse(sockets[1], response, &peerError)) { - return; - } + panorama::wire::v1::Request bootstrapRunRequest; + if (!readRequest( + sockets[1], &bootstrapRunRequest, &peerError, + kPeerTimeoutMs, &requestBuffer) || + bootstrapRunRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout || + bootstrapRunRequest.overlay_layout() + .label_groups_size() != 0) { + peerError = QStringLiteral( + "overlay failure test did not receive empty bootstrap RunConfig"); + return; } - if (!activationSeen || fileListsSeen != 2 || - !pingKeepaliveSeen) { + auto bootstrapResponse = + baseResponse(bootstrapRunRequest); + bootstrapResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], bootstrapResponse, + &peerError)) { + return; + } + + panorama::wire::v1::Request keepaliveRequest; + if (!readRequest( + sockets[1], &keepaliveRequest, &peerError, + kPeerTimeoutMs, &requestBuffer) || + keepaliveRequest.body_case() != + panorama::wire::v1::Request::kPing) { peerError = QStringLiteral( - "display session sequence did not complete"); + "overlay failure test did not receive readiness Ping"); + return; + } + panorama::wire::v1::Response keepaliveResponse; + keepaliveResponse.mutable_header(); + keepaliveResponse.mutable_pong()->set_payload("Hey!"); + if (!writeResponse(sockets[1], keepaliveResponse, + &peerError)) { return; } - pollfd closeDescriptor{}; - closeDescriptor.fd = sockets[1]; - closeDescriptor.events = POLLIN; - const int closePoll = ::poll(&closeDescriptor, 1, 100); - if (closePoll < 0) { - peerError = QString::fromLocal8Bit(std::strerror(errno)); + panorama::wire::v1::Request overlayRunRequest; + if (!readRequest( + sockets[1], &overlayRunRequest, &peerError, + kPeerTimeoutMs, &requestBuffer) || + overlayRunRequest.body_case() != + panorama::wire::v1::Request::kOverlayLayout || + overlayRunRequest.overlay_layout() + .label_groups_size() == 0) { + peerError = QStringLiteral( + "overlay failure test did not receive full overlay RunConfig"); return; } - char byte = 0; - const ssize_t peeked = ::recv( - sockets[1], &byte, sizeof(byte), MSG_PEEK | MSG_DONTWAIT); - if (peeked == 0) { + ++overlayRunCount; + auto rejectedResponse = + baseResponse(overlayRunRequest); + rejectedResponse.mutable_error()->set_code( + panorama::wire::v1::ProtocolError::FAILURE); + rejectedResponse.mutable_error()->set_why( + "overlay rejected"); + if (!writeResponse(sockets[1], rejectedResponse, + &peerError)) { + return; + } + + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int pollResult = + ::poll(&descriptor, 1, 200); + if (pollResult < 0) { peerError = QStringLiteral( - "display keepalive unexpectedly closed the persistent transport"); - } else if (peeked < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { - peerError = QString::fromLocal8Bit(std::strerror(errno)); + "overlay replay check poll failed"); + return; + } + if (pollResult > 0 && + (descriptor.revents & POLLIN) != 0) { + char byte = 0; + const ssize_t received = + ::recv(sockets[1], &byte, sizeof(byte), + MSG_DONTWAIT); + if (received > 0) { + peerError = QStringLiteral( + "overlay RunConfig was replayed after rejection"); + } } }); - constexpr quint64 generation = 41; - const QString endpoint = QStringLiteral("test-endpoint"); + constexpr quint64 generation = 44; + const QString endpoint = + QStringLiteral("test-endpoint"); DeviceWorker worker; worker.updatePrinterGenerationGate(generation, true); worker.configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), generation); - worker.adoptPrinterFileDescriptorForTesting(sockets[0], endpoint); - QSignalSpy listSpy(&worker, &DeviceWorker::printerMediaListReady); - QSignalSpy errorSpy(&worker, &DeviceWorker::printerMediaListFailed); + endpoint, QStringLiteral("test-serial"), + generation); + worker.adoptPrinterFileDescriptorForTesting( + sockets[0], endpoint); + PrinterProtocol::PaseOverlayConfig overlay; + overlay.left.metrics = { + QStringLiteral("CPU Temperature")}; + overlay.left.badges = { + QStringLiteral("CPU Badge")}; + worker.restorePrinterOverlay(overlay, generation); - worker.refreshPrinterMediaList(endpoint, QString(), generation); - worker.refreshPrinterMediaList(endpoint, QString(), generation); - if (errorSpy.count() != 0 || listSpy.count() != 2) { - worker.clearPrinterDevice(generation); - peer.join(); - ::close(firstKeepaliveSeenFd); - ::close(sockets[1]); - QFAIL(qPrintable( - QStringLiteral( - "refresh session failed: errors=%1 lists=%2 first=%3 second=%4 peer=%5") - .arg(errorSpy.count()) - .arg(listSpy.count()) - .arg(errorSpy.count() > 0 - ? errorSpy.at(0).at(1).toString() - : QStringLiteral("")) - .arg(errorSpy.count() > 1 - ? errorSpy.at(1).at(1).toString() - : QStringLiteral("")) - .arg(peerError))); - } - QCOMPARE(errorSpy.count(), 0); - QCOMPARE(listSpy.count(), 2); - const QList firstFiles = - qvariant_cast>( - listSpy.at(0).at(1)); - QCOMPARE(firstFiles.size(), 1); - QCOMPARE(firstFiles.first().name, - QStringLiteral("session-test.png.h264_2240x1080")); - QVERIFY(worker.printerSessionActiveForTesting()); - QVERIFY(QMetaObject::invokeMethod(&worker, "sendPrinterKeepalive", - Qt::DirectConnection)); - pollfd keepaliveDescriptor{}; - keepaliveDescriptor.fd = firstKeepaliveSeenFd; - keepaliveDescriptor.events = POLLIN; - QCOMPARE(::poll(&keepaliveDescriptor, 1, kPeerTimeoutMs), 1); - uint64_t seenValue = 0; - QCOMPARE(::read(firstKeepaliveSeenFd, &seenValue, sizeof(seenValue)), - static_cast(sizeof(seenValue))); - QCOMPARE(seenValue, uint64_t(1)); + QSignalSpy startedSpy( + &worker, &DeviceWorker::printerSessionStarted); + QSignalSpy stoppedSpy( + &worker, &DeviceWorker::printerSessionStopped); + QSignalSpy lostSpy( + &worker, &DeviceWorker::printerSessionLost); + QSignalSpy errorSpy( + &worker, &DeviceWorker::printerOperationError); + + worker.startPrinterDisplaySession(endpoint, generation); + QCOMPARE(worker.printerSessionState_, + DeviceWorker::PrinterSessionState:: + AwaitingOverlayActivation); + QCOMPARE(startedSpy.count(), 0); + + QVERIFY(QMetaObject::invokeMethod( + &worker, "sendPrinterKeepalive", + Qt::DirectConnection)); + QVERIFY(QMetaObject::invokeMethod( + &worker, "sendPrinterKeepalive", + Qt::DirectConnection)); peer.join(); - ::close(firstKeepaliveSeenFd); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QCOMPARE(overlayRunCount, 1); + QCOMPARE(worker.printerSessionState_, + DeviceWorker::PrinterSessionState::Lost); + QVERIFY(!worker.printerOverlayActivationPending_); + QCOMPARE(startedSpy.count(), 0); + QCOMPARE(stoppedSpy.count(), 1); + QCOMPARE(lostSpy.count(), 1); + QCOMPARE(errorSpy.count(), 1); + QVERIFY(!worker.printerKeepaliveTimer_->isActive()); + QVERIFY(!worker.printerMetricsTimer_->isActive()); + QVERIFY(!worker.printerRecoveryTimer_->isActive()); } void PrinterProtocolTests:: -runConfigErrorResponseRejectsSessionStart() { +overlayLeaseRejectionBecomesLostWithoutRecovery() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); QString peerError; + int leaseCount = 0; std::thread peer([&]() { - QByteArray requestBuffer; - if (!serveUdbBootstrap(sockets[1], &peerError, - &requestBuffer)) { - return; - } panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError, - kPeerTimeoutMs, &requestBuffer) || - request.body_case() != panorama::wire::v1::Request::kOverlayLayout) { - peerError = QStringLiteral("session failure test did not receive RunConfig"); - return; - } - auto response = baseResponse(request); - response.mutable_error()->set_code(panorama::wire::v1::ProtocolError::FAILURE); - response.mutable_error()->set_why("session rejected"); - if (!writeResponse(sockets[1], response, &peerError)) { + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request::kOverlayLayout || + !request.has_header() || + request.header().ByteSizeLong() != 0 || + request.overlay_layout().label_groups_size() == 0) { + peerError = QStringLiteral( + "overlay lease rejection test did not receive an untracked layout"); return; } + ++leaseCount; + panorama::wire::v1::Response response; + response.mutable_error()->set_code( + panorama::wire::v1::ProtocolError::FAILURE); + response.mutable_error()->set_why( + "renderer rejected overlay lease"); + writeResponse(sockets[1], response, &peerError); }); - constexpr quint64 generation = 42; - const QString endpoint = QStringLiteral("test-endpoint"); + constexpr quint64 generation = 45; + const QString endpoint = + QStringLiteral("test-endpoint"); DeviceWorker worker; worker.updatePrinterGenerationGate(generation, true); worker.configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), generation); - worker.adoptPrinterFileDescriptorForTesting(sockets[0], endpoint); - QSignalSpy listSpy(&worker, &DeviceWorker::printerMediaListReady); - QSignalSpy errorSpy(&worker, &DeviceWorker::printerMediaListFailed); + endpoint, QStringLiteral("test-serial"), + generation); + worker.adoptPrinterFileDescriptorForTesting( + sockets[0], endpoint); + worker.printerSessionState_ = + DeviceWorker::PrinterSessionState::Active; + worker.printerOverlayConfig_.left.metrics = { + QStringLiteral("CPU Temperature")}; + worker.printerOverlayLeaseRefreshNext_ = true; + worker.printerMetricsTimer_->start(10000); + worker.printerRecoveryTimer_->start(10000); - worker.refreshPrinterMediaList(endpoint, QString(), generation); - QCOMPARE(listSpy.count(), 0); - QCOMPARE(errorSpy.count(), 1); - QVERIFY(!worker.printerSessionActiveForTesting()); - QVERIFY(errorSpy.first().at(1).toString().contains( - QStringLiteral("session rejected"), - Qt::CaseInsensitive)); + QSignalSpy lostSpy( + &worker, &DeviceWorker::printerSessionLost); + QSignalSpy errorSpy( + &worker, &DeviceWorker::printerOperationError); + + QVERIFY(QMetaObject::invokeMethod( + &worker, "sendPrinterKeepalive", + Qt::DirectConnection)); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QCOMPARE(leaseCount, 1); + QCOMPARE(worker.printerSessionState_, + DeviceWorker::PrinterSessionState::Lost); + QCOMPARE(lostSpy.count(), 1); + QCOMPARE(errorSpy.count(), 1); + QVERIFY(!worker.printerOverlayLeaseRefreshNext_); + QVERIFY(!worker.printerKeepaliveTimer_->isActive()); + QVERIFY(!worker.printerMetricsTimer_->isActive()); + QVERIFY(!worker.printerRecoveryTimer_->isActive()); } -void PrinterProtocolTests:: -restoredOverlayWaitsForKeepaliveBeforeSessionReady() { +void PrinterProtocolTests::applyMediaPreservesUnknownFields() { int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); - - const QString expectedCpu = - SystemMonitor::cpuModelName().trimmed(); - QVERIFY(!expectedCpu.isEmpty()); - SystemMonitor modelMonitor; - const QString expectedGpu = - modelMonitor.primaryGpuModelName().trimmed(); + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); QString peerError; std::thread peer([&]() { - QByteArray requestBuffer; - if (!serveUdbBootstrap( - sockets[1], &peerError, &requestBuffer)) { + panorama::wire::v1::Request getRequest; + if (!readRequest(sockets[1], &getRequest, &peerError) || + getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral("unexpected get-user-config request"); return; } - panorama::wire::v1::Request bootstrapRunRequest; - if (!readRequest( - sockets[1], &bootstrapRunRequest, &peerError, - kPeerTimeoutMs, &requestBuffer) || - bootstrapRunRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { - peerError = QStringLiteral( - "restored overlay test did not receive bootstrap RunConfig"); + auto getResponse = baseResponse(getRequest); + auto *config = getResponse.mutable_user_configuration(); + config->mutable_poweron_config()->set_media_file("keep-poweron.h264"); + config->mutable_display_config()->set_backlight_brightness(55); + config->mutable_filter_config()->set_alpha(73); + config->mutable_work_config()->set_media_mode( + panorama::wire::v1::WorkConfiguration::MEDIA_DUAL); + config->mutable_work_config()->set_loop_mode( + panorama::wire::v1::WorkConfiguration::LOOP_ALL); + config->mutable_work_config()->set_single_mode_media_file("old.h264"); + config->GetReflection()->MutableUnknownFields(config)->AddVarint(99, 123456); + config->mutable_work_config()->GetReflection() + ->MutableUnknownFields(config->mutable_work_config()) + ->AddLengthDelimited(77, "nested-unknown"); + if (!writeResponse(sockets[1], getResponse, &peerError)) { return; } - if (!bootstrapRunRequest.has_header() || - bootstrapRunRequest.header().version() != 1 || - bootstrapRunRequest.header().track_id() == 0 || - bootstrapRunRequest.overlay_layout().label_groups_size() != 0) { - peerError = QStringLiteral( - "bootstrap RunConfig was not an empty tracked activation"); + + panorama::wire::v1::Request applyRequest; + if (!readRequest(sockets[1], &applyRequest, &peerError) || + applyRequest.body_case() != panorama::wire::v1::Request::kUserConfiguration) { + peerError = QStringLiteral("unexpected user-config apply request"); return; } - auto bootstrapRunResponse = - baseResponse(bootstrapRunRequest); - bootstrapRunResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], bootstrapRunResponse, - &peerError)) { + const auto &applied = applyRequest.user_configuration(); + if (applied.work_config().media_mode() != + panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE || + applied.work_config().loop_mode() != + panorama::wire::v1::WorkConfiguration::LOOP_SINGLE || + applied.work_config().single_mode_media_file() != "new.h264" || + applied.poweron_config().media_file() != "keep-poweron.h264" || + applied.display_config().backlight_brightness() != 55 || + applied.filter_config().alpha() != 73 || + !hasUnknownField(applied.GetReflection()->GetUnknownFields(applied), 99) || + !hasUnknownField(applied.work_config().GetReflection()->GetUnknownFields( + applied.work_config()), + 77)) { + peerError = QStringLiteral("user config was not mutated in place"); + return; + } + auto applyResponse = baseResponse(applyRequest); + applyResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], applyResponse, &peerError)) { return; } - panorama::wire::v1::Request keepaliveRequest; - if (!readRequest( - sockets[1], &keepaliveRequest, &peerError, - kPeerTimeoutMs, &requestBuffer) || - keepaliveRequest.body_case() != - panorama::wire::v1::Request::kPing || - !keepaliveRequest.has_header() || - keepaliveRequest.header().ByteSizeLong() != 0 || - keepaliveRequest.ping().payload() != "hello?") { - peerError = QStringLiteral( - "restored overlay test did not receive the readiness Ping"); + panorama::wire::v1::Request runRequest; + if (!readRequest(sockets[1], &runRequest, &peerError) || + runRequest.body_case() != panorama::wire::v1::Request::kOverlayLayout) { + peerError = QStringLiteral("unexpected run-config request"); return; } - panorama::wire::v1::Response keepaliveResponse; - keepaliveResponse.mutable_header(); - keepaliveResponse.mutable_pong()->set_payload("Hey!"); - if (!writeResponse(sockets[1], keepaliveResponse, + auto runResponse = baseResponse(runRequest); + runResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], runResponse, &peerError)) { return; } - panorama::wire::v1::Request overlayRunRequest; - if (!readRequest( - sockets[1], &overlayRunRequest, &peerError, - kPeerTimeoutMs, &requestBuffer) || - overlayRunRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout) { + panorama::wire::v1::Request readbackRequest; + if (!readRequest(sockets[1], &readbackRequest, + &peerError) || + readbackRequest.body_case() != + panorama::wire::v1::Request::kUserConfigurationQuery) { peerError = QStringLiteral( - "restored overlay test did not receive the full RunConfig"); + "unexpected user-config readback request"); return; } - if (!overlayRunRequest.has_header() || - overlayRunRequest.header().version() != 1 || - overlayRunRequest.header().track_id() == 0 || - overlayRunRequest.header().track_id() == - bootstrapRunRequest.header().track_id()) { - peerError = QStringLiteral( - "restored overlay RunConfig was not a new tracked mutation"); + auto readbackResponse = + baseResponse(readbackRequest); + *readbackResponse.mutable_user_configuration() = + applyRequest.user_configuration(); + writeResponse( + sockets[1], readbackResponse, &peerError); + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + QString error; + const PrinterProtocol::OperationContext context; + QVERIFY2(protocol.applyPresetMedia(QStringLiteral("test-endpoint"), + QStringLiteral("new.h264"), -1, + &error, context), + qPrintable(error)); + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::rejectedApplyDoesNotSendRunConfig() { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request getRequest; + if (!readRequest(sockets[1], &getRequest, &peerError) || + getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral("unexpected get-user-config request"); + return; + } + auto getResponse = baseResponse(getRequest); + getResponse.mutable_user_configuration()->mutable_work_config() + ->set_single_mode_media_file("old.h264"); + if (!writeResponse(sockets[1], getResponse, &peerError)) { + return; + } + + panorama::wire::v1::Request applyRequest; + if (!readRequest(sockets[1], &applyRequest, &peerError) || + applyRequest.body_case() != panorama::wire::v1::Request::kUserConfiguration) { + peerError = QStringLiteral("unexpected user-config apply request"); return; } - const auto &run = overlayRunRequest.overlay_layout(); - const panorama::wire::v1::OverlayGroup *metricGroup = - nullptr; - const panorama::wire::v1::OverlayGroup *badgeGroup = - nullptr; - for (int index = 0; - index < run.label_groups_size(); ++index) { - if (run.label_groups(index).group_id() == 100U) { - metricGroup = &run.label_groups(index); - } else if ( - run.label_groups(index).group_id() == 300U) { - badgeGroup = &run.label_groups(index); + auto applyResponse = baseResponse(applyRequest); + applyResponse.mutable_error()->set_code(panorama::wire::v1::ProtocolError::FAILURE); + applyResponse.mutable_error()->set_why("rejected apply"); + if (!writeResponse(sockets[1], applyResponse, &peerError)) { + return; + } + + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int pollResult = ::poll(&descriptor, 1, 100); + if (pollResult > 0 && (descriptor.revents & POLLIN) != 0) { + char byte = 0; + const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); + if (received > 0) { + peerError = QStringLiteral("run-config was sent after rejected apply"); } + } else if (pollResult < 0 && errno != EINTR) { + peerError = QStringLiteral("failed to verify rejected apply boundary"); } - if (!metricGroup || - metricGroup->labels_size() < 3 || - !badgeGroup || - badgeGroup->labels_size() < - (expectedGpu.isEmpty() ? 1 : 2) || - QString::fromStdString( - badgeGroup->labels(0).text()).trimmed() != expectedCpu || - (!expectedGpu.isEmpty() && - QString::fromStdString( - badgeGroup->labels(1).text()).trimmed() != expectedGpu)) { - peerError = QStringLiteral( - "restored overlay was not hydrated: " - "metric_labels=%1 badge_labels=%2 cpu=%3 gpu=%4") - .arg( - metricGroup - ? metricGroup->labels_size() - : -1) - .arg( - badgeGroup - ? badgeGroup->labels_size() - : -1) - .arg( - badgeGroup && - badgeGroup->labels_size() > 0 - ? QString::fromStdString( - badgeGroup->labels(0) - .text()) - : QStringLiteral("")) - .arg( - badgeGroup && - badgeGroup->labels_size() > 1 - ? QString::fromStdString( - badgeGroup->labels(1) - .text()) - : QStringLiteral("")); + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + QString error; + const PrinterProtocol::OperationContext context; + QVERIFY(!protocol.applyPresetMedia(QStringLiteral("test-endpoint"), + QStringLiteral("new.h264"), -1, + &error, context)); + QVERIFY(error.contains(QStringLiteral("rejected apply"))); + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::userConfigOutcomeUnknownAfterFullSendIsPartial_data() { + QTest::addColumn("disconnectAfterSend"); + QTest::newRow("user-config-cancelled-after-send") << false; + QTest::newRow("user-config-disconnected-after-send") << true; +} + +void PrinterProtocolTests::userConfigOutcomeUnknownAfterFullSendIsPartial() { + QFETCH(bool, disconnectAfterSend); + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + const int cancellationFd = disconnectAfterSend + ? -1 + : ::eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + QVERIFY(disconnectAfterSend || cancellationFd >= 0); + + QString peerError; + bool peerClosedEndpoint = false; + std::thread peer([&]() { + panorama::wire::v1::Request getRequest; + if (!readRequest(sockets[1], &getRequest, &peerError) || + getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral("unexpected get-user-config request"); return; } - auto runResponse = baseResponse(overlayRunRequest); - runResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], runResponse, - &peerError)) { + auto getResponse = baseResponse(getRequest); + getResponse.mutable_user_configuration()->mutable_work_config() + ->set_single_mode_media_file("old.h264"); + if (!writeResponse(sockets[1], getResponse, &peerError)) { return; } - panorama::wire::v1::Request activePing; - if (!readRequest( - sockets[1], &activePing, &peerError, - kPeerTimeoutMs, &requestBuffer) || - activePing.body_case() != - panorama::wire::v1::Request::kPing || - !activePing.has_header() || - activePing.header().ByteSizeLong() != 0) { - peerError = QStringLiteral( - "active overlay session did not send Ping first"); + panorama::wire::v1::Request applyRequest; + if (!readRequest(sockets[1], &applyRequest, &peerError) || + applyRequest.body_case() != panorama::wire::v1::Request::kUserConfiguration || + applyRequest.user_configuration().work_config().single_mode_media_file() != + "new.h264") { + peerError = QStringLiteral("user configuration was not fully sent"); return; } - panorama::wire::v1::Response activePong; - activePong.mutable_header(); - activePong.mutable_pong()->set_payload("Hey!"); - if (!writeResponse(sockets[1], activePong, - &peerError)) { + + if (disconnectAfterSend) { + ::close(sockets[1]); + peerClosedEndpoint = true; return; + } else { + const uint64_t value = 1; + if (::write(cancellationFd, &value, sizeof(value)) != + static_cast(sizeof(value))) { + peerError = QStringLiteral("failed to signal user-config cancellation"); + return; + } } - panorama::wire::v1::Request overlayLease; - if (!readRequest( - sockets[1], &overlayLease, &peerError, - kPeerTimeoutMs, &requestBuffer) || - overlayLease.body_case() != - panorama::wire::v1::Request::kOverlayLayout || - !overlayLease.has_header() || - overlayLease.header().ByteSizeLong() != 0 || - overlayLease.overlay_layout().label_groups_size() == 0) { - peerError = QStringLiteral( - "active overlay session did not refresh the layout lease"); + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); + if (pollResult <= 0) { + peerError = QStringLiteral("uncertain user-config session stayed open"); return; } - panorama::wire::v1::Response leaseResponse; - leaseResponse.mutable_header(); - leaseResponse.mutable_acknowledgement(); - writeResponse(sockets[1], leaseResponse, - &peerError); + char byte = 0; + const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); + if (received != 0) { + peerError = received > 0 + ? QStringLiteral("unexpected write after uncertain user-config outcome") + : QStringLiteral("failed to confirm uncertain session close"); + } }); - constexpr quint64 generation = 43; - const QString endpoint = - QStringLiteral("test-endpoint"); - DeviceWorker worker; - worker.updatePrinterGenerationGate(generation, true); - worker.configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), - generation); - worker.adoptPrinterFileDescriptorForTesting( - sockets[0], endpoint); - PrinterProtocol::PaseOverlayConfig overlay; - overlay.left.metrics = { - QStringLiteral("CPU Temperature")}; - overlay.left.badges = { - QStringLiteral("CPU Badge")}; - if (!expectedGpu.isEmpty()) { - overlay.left.badges.append( - QStringLiteral("GPU Badge")); + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + PrinterProtocol::OperationContext context; + context.cancellationFd = cancellationFd; + QString error; + QElapsedTimer timer; + timer.start(); + PrinterProtocol::MutationDetails mutation; + const bool success = protocol.applyPresetMedia( + QStringLiteral("test-endpoint"), QStringLiteral("new.h264"), -1, + &error, context, &mutation); + const qint64 elapsedMs = timer.elapsed(); + peer.join(); + if (!peerClosedEndpoint) { + ::close(sockets[1]); + } + if (cancellationFd >= 0) { + ::close(cancellationFd); } - worker.restorePrinterOverlay(overlay, generation); - QSignalSpy startedSpy( - &worker, &DeviceWorker::printerSessionStarted); - QSignalSpy lostSpy( - &worker, &DeviceWorker::printerSessionLost); - QSignalSpy errorSpy( - &worker, &DeviceWorker::printerOperationError); - QSignalSpy readySpy( - &worker, &DeviceWorker::printerTransportReady); - - worker.startPrinterDisplaySession(endpoint, generation); - QCOMPARE(worker.printerSessionState_, - DeviceWorker::PrinterSessionState:: - AwaitingOverlayActivation); - QVERIFY(worker.printerOverlayActivationPending_); - QVERIFY(!worker.printerSessionActiveForTesting()); - QVERIFY(!worker.printerMetricsTimer_->isActive()); - QCOMPARE(startedSpy.count(), 0); - QCOMPARE(lostSpy.count(), 0); - QCOMPARE(errorSpy.count(), 0); - - QVERIFY(QMetaObject::invokeMethod( - &worker, "sendPrinterKeepalive", - Qt::DirectConnection)); - QVERIFY(QMetaObject::invokeMethod( - &worker, "sendPrinterKeepalive", - Qt::DirectConnection)); - QVERIFY(QMetaObject::invokeMethod( - &worker, "sendPrinterKeepalive", - Qt::DirectConnection)); - peer.join(); - ::close(sockets[1]); + QVERIFY(!success); + QCOMPARE(mutation.outcome, + PrinterProtocol::MutationOutcome::PartialOrUnknown); + QCOMPARE(mutation.stage, QStringLiteral("WritingConfig")); + QVERIFY(error.contains(QStringLiteral("fully sent"), Qt::CaseInsensitive)); + QVERIFY(error.contains(QStringLiteral("not confirmed"), Qt::CaseInsensitive)); + QVERIFY(error.contains(QStringLiteral("rollback"), Qt::CaseInsensitive)); + if (disconnectAfterSend) { + QVERIFY(error.contains(QStringLiteral("disconnect"), Qt::CaseInsensitive)); + } else { + QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); + } + QVERIFY(elapsedMs < 500); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(worker.printerSessionState_, - DeviceWorker::PrinterSessionState::Active); - QVERIFY(!worker.printerOverlayActivationPending_); - QCOMPARE(startedSpy.count(), 1); - QCOMPARE(lostSpy.count(), 0); - QCOMPARE(errorSpy.count(), 0); - QCOMPARE(readySpy.count(), 3); - QVERIFY(worker.printerSessionActiveForTesting()); - QVERIFY(worker.printerMetricsTimer_->isActive()); - QVERIFY(!worker.printerRecoveryTimer_->isActive()); } -void PrinterProtocolTests:: -restoredOverlayFailureBecomesLostWithoutReplay() { +void PrinterProtocolTests::runConfigFailureAfterAcceptedUserConfigIsPartial_data() { + QTest::addColumn("cancelAfterRunSend"); + QTest::newRow("run-out-disconnected") << false; + QTest::newRow("run-cancelled-after-send") << true; +} + +void PrinterProtocolTests::runConfigFailureAfterAcceptedUserConfigIsPartial() { + QFETCH(bool, cancelAfterRunSend); int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + const int cancellationFd = cancelAfterRunSend + ? ::eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK) + : -1; + QVERIFY(!cancelAfterRunSend || cancellationFd >= 0); QString peerError; - int overlayRunCount = 0; + bool peerClosedEndpoint = false; std::thread peer([&]() { - QByteArray requestBuffer; - if (!serveUdbBootstrap( - sockets[1], &peerError, &requestBuffer)) { + panorama::wire::v1::Request getRequest; + if (!readRequest(sockets[1], &getRequest, &peerError) || + getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral("unexpected get-user-config request"); + return; + } + auto getResponse = baseResponse(getRequest); + getResponse.mutable_user_configuration()->mutable_work_config() + ->set_single_mode_media_file("old.h264"); + if (!writeResponse(sockets[1], getResponse, &peerError)) { return; } - panorama::wire::v1::Request bootstrapRunRequest; - if (!readRequest( - sockets[1], &bootstrapRunRequest, &peerError, - kPeerTimeoutMs, &requestBuffer) || - bootstrapRunRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout || - bootstrapRunRequest.overlay_layout() - .label_groups_size() != 0) { - peerError = QStringLiteral( - "overlay failure test did not receive empty bootstrap RunConfig"); + panorama::wire::v1::Request applyRequest; + if (!readRequest(sockets[1], &applyRequest, &peerError) || + applyRequest.body_case() != panorama::wire::v1::Request::kUserConfiguration || + applyRequest.user_configuration().work_config().single_mode_media_file() != + "new.h264") { + peerError = QStringLiteral("unexpected user-config apply request"); return; } - auto bootstrapResponse = - baseResponse(bootstrapRunRequest); - bootstrapResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], bootstrapResponse, - &peerError)) { + auto applyResponse = baseResponse(applyRequest); + applyResponse.mutable_acknowledgement(); + if (!writeResponse(sockets[1], applyResponse, &peerError)) { return; } - panorama::wire::v1::Request keepaliveRequest; - if (!readRequest( - sockets[1], &keepaliveRequest, &peerError, - kPeerTimeoutMs, &requestBuffer) || - keepaliveRequest.body_case() != - panorama::wire::v1::Request::kPing) { - peerError = QStringLiteral( - "overlay failure test did not receive readiness Ping"); + panorama::wire::v1::Request runRequest; + if (!readRequest(sockets[1], &runRequest, &peerError) || + runRequest.body_case() != panorama::wire::v1::Request::kOverlayLayout) { + peerError = QStringLiteral("unexpected run-config request"); return; } - panorama::wire::v1::Response keepaliveResponse; - keepaliveResponse.mutable_header(); - keepaliveResponse.mutable_pong()->set_payload("Hey!"); - if (!writeResponse(sockets[1], keepaliveResponse, - &peerError)) { + if (!cancelAfterRunSend) { + ::close(sockets[1]); + peerClosedEndpoint = true; return; } - - panorama::wire::v1::Request overlayRunRequest; - if (!readRequest( - sockets[1], &overlayRunRequest, &peerError, - kPeerTimeoutMs, &requestBuffer) || - overlayRunRequest.body_case() != - panorama::wire::v1::Request::kOverlayLayout || - overlayRunRequest.overlay_layout() - .label_groups_size() == 0) { + const uint64_t value = 1; + if (::write(cancellationFd, &value, sizeof(value)) != + static_cast(sizeof(value))) { peerError = QStringLiteral( - "overlay failure test did not receive full overlay RunConfig"); + "failed to signal post-send cancellation"); return; } - ++overlayRunCount; - auto rejectedResponse = - baseResponse(overlayRunRequest); - rejectedResponse.mutable_error()->set_code( - panorama::wire::v1::ProtocolError::FAILURE); - rejectedResponse.mutable_error()->set_why( - "overlay rejected"); - if (!writeResponse(sockets[1], rejectedResponse, - &peerError)) { + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + PrinterProtocol::OperationContext context; + context.cancellationFd = cancellationFd; + QString error; + PrinterProtocol::MutationDetails mutation; + const bool applied = protocol.applyPresetMedia( + QStringLiteral("test-endpoint"), + QStringLiteral("new.h264"), -1, + &error, context, &mutation); + peer.join(); + if (cancellationFd >= 0) { + ::close(cancellationFd); + } + if (!peerClosedEndpoint) { + ::close(sockets[1]); + } + QVERIFY(!applied); + QCOMPARE(mutation.outcome, + PrinterProtocol::MutationOutcome::PartialOrUnknown); + if (cancelAfterRunSend) { + QCOMPARE(mutation.stage, + QStringLiteral("VerifyingConfig")); + QVERIFY(error.contains( + QStringLiteral("activated"), + Qt::CaseInsensitive)); + QVERIFY(error.contains( + QStringLiteral("readback"), + Qt::CaseInsensitive)); + QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); + } else { + QCOMPARE(mutation.stage, + QStringLiteral("ActivatingConfig")); + QVERIFY(error.contains( + QStringLiteral("accepted"), + Qt::CaseInsensitive)); + QVERIFY(error.contains( + QStringLiteral("rollback"), + Qt::CaseInsensitive)); + QVERIFY(error.contains( + QStringLiteral("disconnect"), + Qt::CaseInsensitive)); + } + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::incompleteUserConfigIsNotWritten_data() { + QTest::addColumn("brightnessOnly"); + QTest::newRow("missing-work-config") << false; + QTest::newRow("missing-display-config") << true; +} + +void PrinterProtocolTests::incompleteUserConfigIsNotWritten() { + QFETCH(bool, brightnessOnly); + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request getRequest; + if (!readRequest(sockets[1], &getRequest, &peerError) || + getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { + peerError = QStringLiteral("unexpected get-user-config request"); + return; + } + auto response = baseResponse(getRequest); + response.mutable_user_configuration(); + if (!writeResponse(sockets[1], response, &peerError)) { return; } - pollfd descriptor{}; descriptor.fd = sockets[1]; descriptor.events = POLLIN; - const int pollResult = - ::poll(&descriptor, 1, 200); - if (pollResult < 0) { - peerError = QStringLiteral( - "overlay replay check poll failed"); - return; - } - if (pollResult > 0 && - (descriptor.revents & POLLIN) != 0) { + const int pollResult = ::poll(&descriptor, 1, 100); + if (pollResult > 0 && (descriptor.revents & POLLIN) != 0) { char byte = 0; - const ssize_t received = - ::recv(sockets[1], &byte, sizeof(byte), - MSG_DONTWAIT); + const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); if (received > 0) { - peerError = QStringLiteral( - "overlay RunConfig was replayed after rejection"); + peerError = QStringLiteral("synthetic user configuration was written"); } + } else if (pollResult < 0 && errno != EINTR) { + peerError = QStringLiteral("failed to verify incomplete config boundary"); } }); - constexpr quint64 generation = 44; - const QString endpoint = - QStringLiteral("test-endpoint"); - DeviceWorker worker; - worker.updatePrinterGenerationGate(generation, true); - worker.configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), - generation); - worker.adoptPrinterFileDescriptorForTesting( - sockets[0], endpoint); - PrinterProtocol::PaseOverlayConfig overlay; - overlay.left.metrics = { - QStringLiteral("CPU Temperature")}; - overlay.left.badges = { - QStringLiteral("CPU Badge")}; - worker.restorePrinterOverlay(overlay, generation); - - QSignalSpy startedSpy( - &worker, &DeviceWorker::printerSessionStarted); - QSignalSpy stoppedSpy( - &worker, &DeviceWorker::printerSessionStopped); - QSignalSpy lostSpy( - &worker, &DeviceWorker::printerSessionLost); - QSignalSpy errorSpy( - &worker, &DeviceWorker::printerOperationError); - - worker.startPrinterDisplaySession(endpoint, generation); - QCOMPARE(worker.printerSessionState_, - DeviceWorker::PrinterSessionState:: - AwaitingOverlayActivation); - QCOMPARE(startedSpy.count(), 0); - - QVERIFY(QMetaObject::invokeMethod( - &worker, "sendPrinterKeepalive", - Qt::DirectConnection)); - QVERIFY(QMetaObject::invokeMethod( - &worker, "sendPrinterKeepalive", - Qt::DirectConnection)); - - peer.join(); - ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(overlayRunCount, 1); - QCOMPARE(worker.printerSessionState_, - DeviceWorker::PrinterSessionState::Lost); - QVERIFY(!worker.printerOverlayActivationPending_); - QCOMPARE(startedSpy.count(), 0); - QCOMPARE(stoppedSpy.count(), 1); - QCOMPARE(lostSpy.count(), 1); - QCOMPARE(errorSpy.count(), 1); - QVERIFY(!worker.printerKeepaliveTimer_->isActive()); - QVERIFY(!worker.printerMetricsTimer_->isActive()); - QVERIFY(!worker.printerRecoveryTimer_->isActive()); + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + QString error; + const PrinterProtocol::OperationContext context; + const bool success = brightnessOnly + ? protocol.setBrightness(QStringLiteral("test-endpoint"), 50, &error, context) + : protocol.applyPresetMedia(QStringLiteral("test-endpoint"), + QStringLiteral("new.h264"), -1, &error, context); + QVERIFY(!success); + QVERIFY(error.contains(QStringLiteral("synthetic"), Qt::CaseInsensitive)); + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } void PrinterProtocolTests:: -overlayLeaseRejectionBecomesLostWithoutRecovery() { +paseStandbyMutationIsRejectedBeforeUsb() { int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - QString peerError; - int leaseCount = 0; std::thread peer([&]() { - panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != - panorama::wire::v1::Request::kOverlayLayout || - !request.has_header() || - request.header().ByteSizeLong() != 0 || - request.overlay_layout().label_groups_size() == 0) { + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int pollResult = + ::poll(&descriptor, 1, 100); + if (pollResult > 0 && + (descriptor.revents & POLLIN) != 0) { + char byte = 0; + const ssize_t received = ::recv( + sockets[1], &byte, sizeof(byte), + MSG_DONTWAIT); + if (received > 0) { + peerError = QStringLiteral( + "standby mutation reached USB"); + } + } else if (pollResult < 0 && errno != EINTR) { peerError = QStringLiteral( - "overlay lease rejection test did not receive an untracked layout"); - return; + "standby boundary poll failed"); } - ++leaseCount; - panorama::wire::v1::Response response; - response.mutable_error()->set_code( - panorama::wire::v1::ProtocolError::FAILURE); - response.mutable_error()->set_why( - "renderer rejected overlay lease"); - writeResponse(sockets[1], response, &peerError); }); - constexpr quint64 generation = 45; + PrinterProtocol protocol(500); const QString endpoint = - QStringLiteral("test-endpoint"); - DeviceWorker worker; - worker.updatePrinterGenerationGate(generation, true); - worker.configurePrinterDevice( - endpoint, QStringLiteral("test-serial"), - generation); - worker.adoptPrinterFileDescriptorForTesting( + QStringLiteral( + "/dev/usb/lp-pase-standby-rejected"); + protocol.adoptFileDescriptorForTesting( sockets[0], endpoint); - worker.printerSessionState_ = - DeviceWorker::PrinterSessionState::Active; - worker.printerOverlayConfig_.left.metrics = { - QStringLiteral("CPU Temperature")}; - worker.printerOverlayLeaseRefreshNext_ = true; - worker.printerMetricsTimer_->start(10000); - worker.printerRecoveryTimer_->start(10000); - - QSignalSpy lostSpy( - &worker, &DeviceWorker::printerSessionLost); - QSignalSpy errorSpy( - &worker, &DeviceWorker::printerOperationError); - - QVERIFY(QMetaObject::invokeMethod( - &worker, "sendPrinterKeepalive", - Qt::DirectConnection)); + PrinterProtocol::PaseApplyConfig config; + config.display.standbyPresent = true; + config.display.standbyEnabled = false; + QString error; + PrinterProtocol::MutationDetails mutation; + QVERIFY(!protocol.applyPaseConfiguration( + endpoint, config, &error, + PrinterProtocol::OperationContext{}, + &mutation)); + QCOMPARE( + mutation.outcome, + PrinterProtocol::MutationOutcome::Rejected); + QVERIFY(error.contains( + QStringLiteral("standby"), + Qt::CaseInsensitive)); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QCOMPARE(leaseCount, 1); - QCOMPARE(worker.printerSessionState_, - DeviceWorker::PrinterSessionState::Lost); - QCOMPARE(lostSpy.count(), 1); - QCOMPARE(errorSpy.count(), 1); - QVERIFY(!worker.printerOverlayLeaseRefreshNext_); - QVERIFY(!worker.printerKeepaliveTimer_->isActive()); - QVERIFY(!worker.printerMetricsTimer_->isActive()); - QVERIFY(!worker.printerRecoveryTimer_->isActive()); } -void PrinterProtocolTests::applyMediaPreservesUnknownFields() { +void PrinterProtocolTests::unsafeMediaNamesAreRejected() { + const QStringList unsafeNames = { + QStringLiteral("."), + QStringLiteral(".."), + QStringLiteral(".hidden.h264"), + QStringLiteral("folder/file.h264"), + QStringLiteral("name with spaces.h264") + }; + const PrinterProtocol::OperationContext context; + for (const QString &name : unsafeNames) { + PrinterProtocol protocol(100); + QString error; + QVERIFY2(!protocol.applyPresetMedia(QStringLiteral("unused"), name, -1, + &error, context), + qPrintable(name)); + QVERIFY2(error.contains(QStringLiteral("not supported"), Qt::CaseInsensitive), + qPrintable(error)); + } +} + +void PrinterProtocolTests::uploadRejectsSymlinkAndHashMismatchBeforeUsb() { + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + const QString sourcePath = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("source.h264")); + const QByteArray sourceBytes(4096, '\x6a'); + QFile source(sourcePath); + QVERIFY(source.open(QIODevice::WriteOnly | QIODevice::Truncate)); + QCOMPARE(source.write(sourceBytes), + static_cast(sourceBytes.size())); + source.close(); + const QString symlinkPath = + QDir(temporaryDirectory.path()).filePath(QStringLiteral("link.h264")); + const QByteArray encodedSource = QFile::encodeName(sourcePath); + const QByteArray encodedLink = QFile::encodeName(symlinkPath); + QCOMPARE(::symlink(encodedSource.constData(), encodedLink.constData()), 0); + int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + PrinterProtocol protocol; + const QString devicePath = QStringLiteral("/dev/usb/lp-pase-safe-open"); + protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); - QString peerError; - std::thread peer([&]() { - panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, &peerError) || - getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("unexpected get-user-config request"); - return; - } - auto getResponse = baseResponse(getRequest); - auto *config = getResponse.mutable_user_configuration(); - config->mutable_poweron_config()->set_media_file("keep-poweron.h264"); - config->mutable_display_config()->set_backlight_brightness(55); - config->mutable_filter_config()->set_alpha(73); - config->mutable_work_config()->set_media_mode( - panorama::wire::v1::WorkConfiguration::MEDIA_DUAL); - config->mutable_work_config()->set_loop_mode( - panorama::wire::v1::WorkConfiguration::LOOP_ALL); - config->mutable_work_config()->set_single_mode_media_file("old.h264"); - config->GetReflection()->MutableUnknownFields(config)->AddVarint(99, 123456); - config->mutable_work_config()->GetReflection() - ->MutableUnknownFields(config->mutable_work_config()) - ->AddLengthDelimited(77, "nested-unknown"); - if (!writeResponse(sockets[1], getResponse, &peerError)) { - return; - } - - panorama::wire::v1::Request applyRequest; - if (!readRequest(sockets[1], &applyRequest, &peerError) || - applyRequest.body_case() != panorama::wire::v1::Request::kUserConfiguration) { - peerError = QStringLiteral("unexpected user-config apply request"); - return; - } - const auto &applied = applyRequest.user_configuration(); - if (applied.work_config().media_mode() != - panorama::wire::v1::WorkConfiguration::MEDIA_SINGLE || - applied.work_config().loop_mode() != - panorama::wire::v1::WorkConfiguration::LOOP_SINGLE || - applied.work_config().single_mode_media_file() != "new.h264" || - applied.poweron_config().media_file() != "keep-poweron.h264" || - applied.display_config().backlight_brightness() != 55 || - applied.filter_config().alpha() != 73 || - !hasUnknownField(applied.GetReflection()->GetUnknownFields(applied), 99) || - !hasUnknownField(applied.work_config().GetReflection()->GetUnknownFields( - applied.work_config()), - 77)) { - peerError = QStringLiteral("user config was not mutated in place"); - return; - } - auto applyResponse = baseResponse(applyRequest); - applyResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], applyResponse, &peerError)) { - return; - } - - panorama::wire::v1::Request runRequest; - if (!readRequest(sockets[1], &runRequest, &peerError) || - runRequest.body_case() != panorama::wire::v1::Request::kOverlayLayout) { - peerError = QStringLiteral("unexpected run-config request"); - return; - } - auto runResponse = baseResponse(runRequest); - runResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], runResponse, - &peerError)) { - return; - } + QString uploadedName; + QString error; + PrinterProtocol::MutationDetails mutation; + QVERIFY(!protocol.uploadMedia( + devicePath, symlinkPath, + QStringLiteral("safe.mp4.h264_2240x1080"), &uploadedName, &error, + {}, PrinterProtocol::OperationContext{}, &mutation, + QString::fromLatin1( + QCryptographicHash::hash(sourceBytes, QCryptographicHash::Sha256) + .toHex()))); + QVERIFY(error.contains(QStringLiteral("does not exist"), + Qt::CaseInsensitive)); + QCOMPARE(mutation.outcome, PrinterProtocol::MutationOutcome::NotStarted); + QCOMPARE(mutation.stage, QStringLiteral("Validating")); - panorama::wire::v1::Request readbackRequest; - if (!readRequest(sockets[1], &readbackRequest, - &peerError) || - readbackRequest.body_case() != - panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral( - "unexpected user-config readback request"); - return; - } - auto readbackResponse = - baseResponse(readbackRequest); - *readbackResponse.mutable_user_configuration() = - applyRequest.user_configuration(); - writeResponse( - sockets[1], readbackResponse, &peerError); - }); + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + QCOMPARE(::poll(&descriptor, 1, 50), 0); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QString error; - const PrinterProtocol::OperationContext context; - QVERIFY2(protocol.applyPresetMedia(QStringLiteral("test-endpoint"), - QStringLiteral("new.h264"), -1, - &error, context), - qPrintable(error)); - peer.join(); + error.clear(); + mutation = {}; + QVERIFY(!protocol.uploadMedia( + devicePath, sourcePath, + QStringLiteral("safe.mp4.h264_2240x1080"), &uploadedName, &error, + {}, PrinterProtocol::OperationContext{}, &mutation, + QString(64, QLatin1Char('0')))); + QVERIFY(error.contains(QStringLiteral("changed"), Qt::CaseInsensitive)); + QCOMPARE(mutation.outcome, PrinterProtocol::MutationOutcome::NotStarted); + QCOMPARE(mutation.stage, QStringLiteral("Validating")); + descriptor.revents = 0; + QCOMPARE(::poll(&descriptor, 1, 50), 0); ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::rejectedApplyDoesNotSendRunConfig() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); +void PrinterProtocolTests::duplexInputReceivesAckDuringOutput() { + QList events; + PrinterProtocol::DuplexTestEvent input; + input.direction = PrinterProtocol::DuplexTestDirection::Input; + input.payload = QByteArrayLiteral("ack"); + events.append(input); + PrinterProtocol::DuplexTestEvent output; + output.direction = PrinterProtocol::DuplexTestDirection::Output; + events.append(output); - QString peerError; - std::thread peer([&]() { - panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, &peerError) || - getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("unexpected get-user-config request"); - return; - } - auto getResponse = baseResponse(getRequest); - getResponse.mutable_user_configuration()->mutable_work_config() - ->set_single_mode_media_file("old.h264"); - if (!writeResponse(sockets[1], getResponse, &peerError)) { - return; - } + const PrinterProtocol::DuplexTestResult result = + PrinterProtocol::runDuplexTransportScenarioForTesting( + events, QByteArrayLiteral("request")); + QVERIFY2(result.writeSucceeded, qPrintable(result.error)); + QVERIFY(result.responseReceived); + QCOMPARE(result.response, QByteArrayLiteral("ack")); + QCOMPARE(result.inputSubmissions, 1); + QCOMPARE(result.outputSubmissions, 1); + QCOMPARE(result.maximumConcurrentInputs, 1); + QCOMPARE(result.inputCompletions, 1); + QCOMPARE(result.zeroLengthInputCompletions, 0); + QCOMPARE(result.inputErrors, 0); + QCOMPARE(result.inputRearmsDuringOutput, 0); +} - panorama::wire::v1::Request applyRequest; - if (!readRequest(sockets[1], &applyRequest, &peerError) || - applyRequest.body_case() != panorama::wire::v1::Request::kUserConfiguration) { - peerError = QStringLiteral("unexpected user-config apply request"); - return; - } - auto applyResponse = baseResponse(applyRequest); - applyResponse.mutable_error()->set_code(panorama::wire::v1::ProtocolError::FAILURE); - applyResponse.mutable_error()->set_why("rejected apply"); - if (!writeResponse(sockets[1], applyResponse, &peerError)) { - return; - } +void PrinterProtocolTests:: + duplexZeroLengthInputDefersRearmUntilOutputCompletes() { + QList events; + PrinterProtocol::DuplexTestEvent emptyInput; + emptyInput.direction = PrinterProtocol::DuplexTestDirection::Input; + events.append(emptyInput); + PrinterProtocol::DuplexTestEvent output; + output.direction = PrinterProtocol::DuplexTestDirection::Output; + output.deferredDispatches = 1; + events.append(output); + PrinterProtocol::DuplexTestEvent response; + response.direction = PrinterProtocol::DuplexTestDirection::Input; + response.payload = QByteArrayLiteral("ack-after-zlp"); + events.append(response); - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int pollResult = ::poll(&descriptor, 1, 100); - if (pollResult > 0 && (descriptor.revents & POLLIN) != 0) { - char byte = 0; - const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); - if (received > 0) { - peerError = QStringLiteral("run-config was sent after rejected apply"); - } - } else if (pollResult < 0 && errno != EINTR) { - peerError = QStringLiteral("failed to verify rejected apply boundary"); - } - }); + const PrinterProtocol::DuplexTestResult result = + PrinterProtocol::runDuplexTransportScenarioForTesting( + events, QByteArrayLiteral("request")); + QVERIFY2(result.writeSucceeded, qPrintable(result.error)); + QVERIFY(result.responseReceived); + QCOMPARE(result.response, QByteArrayLiteral("ack-after-zlp")); + QCOMPARE(result.inputSubmissions, 2); + QCOMPARE(result.outputSubmissions, 1); + QCOMPARE(result.maximumConcurrentInputs, 1); + QCOMPARE(result.inputCompletions, 2); + QCOMPARE(result.zeroLengthInputCompletions, 1); + QCOMPARE(result.inputErrors, 0); + QCOMPARE(result.inputRearmsDuringOutput, 0); +} - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QString error; - const PrinterProtocol::OperationContext context; - QVERIFY(!protocol.applyPresetMedia(QStringLiteral("test-endpoint"), - QStringLiteral("new.h264"), -1, - &error, context)); - QVERIFY(error.contains(QStringLiteral("rejected apply"))); - peer.join(); - ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +void PrinterProtocolTests:: + duplexInputErrorDefersRearmWithoutStarvingOutput() { + QList events; + PrinterProtocol::DuplexTestEvent inputError; + inputError.direction = PrinterProtocol::DuplexTestDirection::Input; + inputError.status = PrinterProtocol::DuplexTestStatus::Error; + events.append(inputError); + PrinterProtocol::DuplexTestEvent output; + output.direction = PrinterProtocol::DuplexTestDirection::Output; + output.deferredDispatches = 1; + events.append(output); + PrinterProtocol::DuplexTestEvent response; + response.direction = PrinterProtocol::DuplexTestDirection::Input; + response.payload = QByteArrayLiteral("ack-after-error"); + events.append(response); + + const PrinterProtocol::DuplexTestResult result = + PrinterProtocol::runDuplexTransportScenarioForTesting( + events, QByteArrayLiteral("request")); + QVERIFY2(result.writeSucceeded, qPrintable(result.error)); + QVERIFY(result.responseReceived); + QCOMPARE(result.response, QByteArrayLiteral("ack-after-error")); + QCOMPARE(result.inputSubmissions, 2); + QCOMPARE(result.outputSubmissions, 1); + QCOMPARE(result.maximumConcurrentInputs, 1); + QCOMPARE(result.inputCompletions, 2); + QCOMPARE(result.zeroLengthInputCompletions, 0); + QCOMPARE(result.inputErrors, 1); + QCOMPARE(result.inputRearmsDuringOutput, 0); } -void PrinterProtocolTests::userConfigOutcomeUnknownAfterFullSendIsPartial_data() { - QTest::addColumn("disconnectAfterSend"); - QTest::newRow("user-config-cancelled-after-send") << false; - QTest::newRow("user-config-disconnected-after-send") << true; +void PrinterProtocolTests::duplexInputRetryBudgetIsBounded() { + QList events; + PrinterProtocol::DuplexTestEvent output; + output.direction = PrinterProtocol::DuplexTestDirection::Output; + events.append(output); + for (int completion = 0; completion < 10; ++completion) { + PrinterProtocol::DuplexTestEvent inputError; + inputError.direction = + PrinterProtocol::DuplexTestDirection::Input; + inputError.status = + PrinterProtocol::DuplexTestStatus::Error; + events.append(inputError); + } + + const PrinterProtocol::DuplexTestResult result = + PrinterProtocol::runDuplexTransportScenarioForTesting( + events, QByteArrayLiteral("request"), 5000, 100); + QVERIFY2(result.writeSucceeded, qPrintable(result.error)); + QVERIFY(!result.responseReceived); + QVERIFY2(result.error.contains( + QStringLiteral("persistent"), + Qt::CaseInsensitive), + qPrintable(result.error)); + QCOMPARE(result.outputSubmissions, 1); + QCOMPARE(result.maximumConcurrentInputs, 1); + QCOMPARE(result.inputCompletions, 10); + QCOMPARE(result.zeroLengthInputCompletions, 0); + QCOMPARE(result.inputErrors, 10); + QCOMPARE(result.inputRearmsDuringOutput, 0); + QVERIFY(result.persistentInputFailure); } -void PrinterProtocolTests::userConfigOutcomeUnknownAfterFullSendIsPartial() { - QFETCH(bool, disconnectAfterSend); +void PrinterProtocolTests::uploadIsResponseDriven() { + QTemporaryFile media; + QVERIFY(media.open()); + const QByteArray contents(300000, '\x5a'); + QCOMPARE(media.write(contents), static_cast(contents.size())); + media.flush(); + int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int cancellationFd = disconnectAfterSend - ? -1 - : ::eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); - QVERIFY(disconnectAfterSend || cancellationFd >= 0); QString peerError; - bool peerClosedEndpoint = false; + quint64 transferTrackId = 0; + quint64 nextTrackId = 0; std::thread peer([&]() { - panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, &peerError) || - getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("unexpected get-user-config request"); - return; - } - auto getResponse = baseResponse(getRequest); - getResponse.mutable_user_configuration()->mutable_work_config() - ->set_single_mode_media_file("old.h264"); - if (!writeResponse(sockets[1], getResponse, &peerError)) { - return; - } - - panorama::wire::v1::Request applyRequest; - if (!readRequest(sockets[1], &applyRequest, &peerError) || - applyRequest.body_case() != panorama::wire::v1::Request::kUserConfiguration || - applyRequest.user_configuration().work_config().single_mode_media_file() != - "new.h264") { - peerError = QStringLiteral("user configuration was not fully sent"); - return; - } + const QList expected = { + panorama::wire::v1::Request::kTransferBegin, + panorama::wire::v1::Request::kTransferChunk, + panorama::wire::v1::Request::kTransferChunk, + panorama::wire::v1::Request::kTransferEnd, + panorama::wire::v1::Request::kPing + }; + for (int index = 0; index < expected.size(); ++index) { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != expected.at(index)) { + if (peerError.isEmpty()) { + peerError = QStringLiteral("unexpected upload request %1").arg(index); + } + return; + } + if (!request.has_header() || + request.header().track_id() == 0) { + peerError = QStringLiteral( + "upload request does not have a tracked header"); + return; + } + const quint32 expectedVersion = index <= 3 ? 0U : 1U; + if (request.header().version() != expectedVersion) { + peerError = QStringLiteral( + "unexpected request version %1 at index %2") + .arg(request.header().version()) + .arg(index); + return; + } + if (index <= 3) { + if (transferTrackId == 0) { + transferTrackId = request.header().track_id(); + } else if (request.header().track_id() != + transferTrackId) { + peerError = QStringLiteral( + "FileTransmit changed track ID within one transfer"); + return; + } + } else { + nextTrackId = request.header().track_id(); + if (nextTrackId == transferTrackId) { + peerError = QStringLiteral( + "the command after FileTransmit reused its track ID"); + return; + } + } - if (disconnectAfterSend) { - ::close(sockets[1]); - peerClosedEndpoint = true; - return; - } else { - const uint64_t value = 1; - if (::write(cancellationFd, &value, sizeof(value)) != - static_cast(sizeof(value))) { - peerError = QStringLiteral("failed to signal user-config cancellation"); + auto response = baseResponse(request); + if (index == 0) { + if (request.transfer_begin().file_name() != + "test.png.h264_2240x1080" || + request.transfer_begin().file_size() != + static_cast(contents.size())) { + peerError = QStringLiteral("invalid upload begin fields"); + return; + } + response.mutable_transfer_begin_status()->set_status( + panorama::wire::v1::TransferStatus::OK); + } else if (index < 3) { + const size_t expectedSize = index == 1 + ? static_cast(0x40000) + : static_cast(contents.size() - 0x40000); + if (request.transfer_chunk().file_data().size() != expectedSize) { + peerError = QStringLiteral("invalid upload chunk size"); + return; + } + response.mutable_transfer_chunk_status()->set_status( + panorama::wire::v1::TransferStatus::OK); + } else if (index == 3) { + if (request.transfer_end().file_type() != "media" || + request.transfer_end().checksum() != 0) { + peerError = QStringLiteral("invalid upload end fields"); + return; + } + response.mutable_transfer_end_status()->set_status( + panorama::wire::v1::TransferStatus::OK); + } else { + response.mutable_pong()->set_payload( + request.ping().payload()); + } + if (!writeResponse(sockets[1], response, &peerError)) { return; } } - - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); - if (pollResult <= 0) { - peerError = QStringLiteral("uncertain user-config session stayed open"); - return; - } - char byte = 0; - const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); - if (received != 0) { - peerError = received > 0 - ? QStringLiteral("unexpected write after uncertain user-config outcome") - : QStringLiteral("failed to confirm uncertain session close"); - } }); PrinterProtocol protocol(500); protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - PrinterProtocol::OperationContext context; - context.cancellationFd = cancellationFd; + QString uploadedName; QString error; - QElapsedTimer timer; - timer.start(); + qint64 finalProgress = 0; + const PrinterProtocol::OperationContext context; PrinterProtocol::MutationDetails mutation; - const bool success = protocol.applyPresetMedia( - QStringLiteral("test-endpoint"), QStringLiteral("new.h264"), -1, - &error, context, &mutation); - const qint64 elapsedMs = timer.elapsed(); + QVERIFY2(protocol.uploadMedia( + QStringLiteral("test-endpoint"), media.fileName(), + QStringLiteral("test.png.h264_2240x1080"), + &uploadedName, &error, + [&finalProgress](qint64 sent, qint64) { finalProgress = sent; }, + context, &mutation), + qPrintable(error)); + QCOMPARE(uploadedName, QStringLiteral("test.png.h264_2240x1080")); + QCOMPARE(finalProgress, static_cast(contents.size())); + QCOMPARE(mutation.outcome, PrinterProtocol::MutationOutcome::Succeeded); + QCOMPARE(mutation.stage, QStringLiteral("Ending")); + QCOMPARE(mutation.bytesSent, static_cast(contents.size())); + QCOMPARE(mutation.totalBytes, static_cast(contents.size())); + QString pingPayload; + QVERIFY2(protocol.trackedPingForTesting( + QStringLiteral("test-endpoint"), &pingPayload, &error, + context), + qPrintable(error)); + QCOMPARE(pingPayload, QStringLiteral("hello?")); peer.join(); - if (!peerClosedEndpoint) { - ::close(sockets[1]); - } - if (cancellationFd >= 0) { - ::close(cancellationFd); - } - - QVERIFY(!success); - QCOMPARE(mutation.outcome, - PrinterProtocol::MutationOutcome::PartialOrUnknown); - QCOMPARE(mutation.stage, QStringLiteral("WritingConfig")); - QVERIFY(error.contains(QStringLiteral("fully sent"), Qt::CaseInsensitive)); - QVERIFY(error.contains(QStringLiteral("not confirmed"), Qt::CaseInsensitive)); - QVERIFY(error.contains(QStringLiteral("rollback"), Qt::CaseInsensitive)); - if (disconnectAfterSend) { - QVERIFY(error.contains(QStringLiteral("disconnect"), Qt::CaseInsensitive)); - } else { - QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); - } - QVERIFY(elapsedMs < 500); + ::close(sockets[1]); + QVERIFY(transferTrackId != 0); + QVERIFY(nextTrackId != 0); + QVERIFY(nextTrackId != transferTrackId); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::runConfigFailureAfterAcceptedUserConfigIsPartial_data() { - QTest::addColumn("cancelAfterRunSend"); - QTest::newRow("run-out-disconnected") << false; - QTest::newRow("run-cancelled-after-send") << true; -} +void PrinterProtocolTests::uploadDataUsesDedicatedWriteDeadline() { + QTemporaryFile media; + QVERIFY(media.open()); + const QByteArray contents(17 * 0x40000, '\x4d'); + QCOMPARE(media.write(contents), static_cast(contents.size())); + media.flush(); -void PrinterProtocolTests::runConfigFailureAfterAcceptedUserConfigIsPartial() { - QFETCH(bool, cancelAfterRunSend); int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int cancellationFd = cancelAfterRunSend - ? ::eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK) - : -1; - QVERIFY(!cancelAfterRunSend || cancellationFd >= 0); + const int socketBufferSize = 4096; + QVERIFY(::setsockopt(sockets[0], SOL_SOCKET, SO_SNDBUF, + &socketBufferSize, sizeof(socketBufferSize)) == 0); + QVERIFY(::setsockopt(sockets[1], SOL_SOCKET, SO_RCVBUF, + &socketBufferSize, sizeof(socketBufferSize)) == 0); QString peerError; - bool peerClosedEndpoint = false; std::thread peer([&]() { - panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, &peerError) || - getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("unexpected get-user-config request"); - return; - } - auto getResponse = baseResponse(getRequest); - getResponse.mutable_user_configuration()->mutable_work_config() - ->set_single_mode_media_file("old.h264"); - if (!writeResponse(sockets[1], getResponse, &peerError)) { - return; - } + constexpr int delayedRequestIndex = 17; + constexpr int requestCount = 19; + for (int index = 0; index < requestCount; ++index) { + if (index == delayedRequestIndex) { + pollfd requestReady{}; + requestReady.fd = sockets[1]; + requestReady.events = POLLIN; + if (::poll(&requestReady, 1, kPeerTimeoutMs) != 1 || + (requestReady.revents & POLLIN) == 0) { + peerError = QStringLiteral( + "delayed upload request did not reach the peer"); + return; + } - panorama::wire::v1::Request applyRequest; - if (!readRequest(sockets[1], &applyRequest, &peerError) || - applyRequest.body_case() != panorama::wire::v1::Request::kUserConfiguration || - applyRequest.user_configuration().work_config().single_mode_media_file() != - "new.h264") { - peerError = QStringLiteral("unexpected user-config apply request"); - return; - } - auto applyResponse = baseResponse(applyRequest); - applyResponse.mutable_acknowledgement(); - if (!writeResponse(sockets[1], applyResponse, &peerError)) { - return; - } + const int timerFd = ::timerfd_create( + CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK); + if (timerFd < 0) { + peerError = QStringLiteral("failed to create delay timer"); + return; + } + itimerspec delay{}; + delay.it_value.tv_nsec = 120 * 1000 * 1000; + if (::timerfd_settime(timerFd, 0, &delay, nullptr) != 0) { + peerError = QStringLiteral("failed to arm delay timer"); + ::close(timerFd); + return; + } + pollfd timerReady{}; + timerReady.fd = timerFd; + timerReady.events = POLLIN; + if (::poll(&timerReady, 1, 500) != 1 || + (timerReady.revents & POLLIN) == 0) { + peerError = QStringLiteral("bounded delay timer did not fire"); + ::close(timerFd); + return; + } + quint64 expirations = 0; + if (::read(timerFd, &expirations, sizeof(expirations)) != + static_cast(sizeof(expirations))) { + peerError = QStringLiteral("failed to consume delay timer"); + ::close(timerFd); + return; + } + ::close(timerFd); + } - panorama::wire::v1::Request runRequest; - if (!readRequest(sockets[1], &runRequest, &peerError) || - runRequest.body_case() != panorama::wire::v1::Request::kOverlayLayout) { - peerError = QStringLiteral("unexpected run-config request"); - return; - } - if (!cancelAfterRunSend) { - ::close(sockets[1]); - peerClosedEndpoint = true; - return; - } - const uint64_t value = 1; - if (::write(cancellationFd, &value, sizeof(value)) != - static_cast(sizeof(value))) { - peerError = QStringLiteral( - "failed to signal post-send cancellation"); - return; + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError)) { + return; + } + auto response = baseResponse(request); + if (index == 0) { + if (request.body_case() != + panorama::wire::v1::Request::kTransferBegin) { + peerError = QStringLiteral("missing upload begin request"); + return; + } + response.mutable_transfer_begin_status() + ->set_status( + panorama::wire::v1::TransferStatus::OK); + } else if (index == requestCount - 1) { + if (request.body_case() != + panorama::wire::v1::Request::kTransferEnd) { + peerError = QStringLiteral("missing upload end request"); + return; + } + response.mutable_transfer_end_status() + ->set_status( + panorama::wire::v1::TransferStatus::OK); + } else { + if (request.body_case() != + panorama::wire::v1::Request::kTransferChunk || + request.transfer_chunk().file_data().size() != + static_cast(0x40000)) { + peerError = QStringLiteral("invalid upload data request"); + return; + } + response.mutable_transfer_chunk_status() + ->set_status( + panorama::wire::v1::TransferStatus::OK); + } + if (!writeResponse(sockets[1], response, &peerError)) { + return; + } } }); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - PrinterProtocol::OperationContext context; - context.cancellationFd = cancellationFd; + PrinterProtocol protocol(60); + protocol.setFileTransmitDataWriteTimeoutForTesting(500); + protocol.adoptFileDescriptorForTesting(sockets[0], + QStringLiteral("test-endpoint")); + QString uploadedName; QString error; PrinterProtocol::MutationDetails mutation; - const bool applied = protocol.applyPresetMedia( - QStringLiteral("test-endpoint"), - QStringLiteral("new.h264"), -1, - &error, context, &mutation); + const PrinterProtocol::OperationContext context; + const bool uploadOk = protocol.uploadMedia( + QStringLiteral("test-endpoint"), media.fileName(), + QStringLiteral("large.mp4.h264_2240x1080"), + &uploadedName, &error, {}, context, &mutation); + peer.join(); - if (cancellationFd >= 0) { - ::close(cancellationFd); - } - if (!peerClosedEndpoint) { - ::close(sockets[1]); - } - QVERIFY(!applied); - QCOMPARE(mutation.outcome, - PrinterProtocol::MutationOutcome::PartialOrUnknown); - if (cancelAfterRunSend) { - QCOMPARE(mutation.stage, - QStringLiteral("VerifyingConfig")); - QVERIFY(error.contains( - QStringLiteral("activated"), - Qt::CaseInsensitive)); - QVERIFY(error.contains( - QStringLiteral("readback"), - Qt::CaseInsensitive)); - QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); - } else { - QCOMPARE(mutation.stage, - QStringLiteral("ActivatingConfig")); - QVERIFY(error.contains( - QStringLiteral("accepted"), - Qt::CaseInsensitive)); - QVERIFY(error.contains( - QStringLiteral("rollback"), - Qt::CaseInsensitive)); - QVERIFY(error.contains( - QStringLiteral("disconnect"), - Qt::CaseInsensitive)); - } + ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); -} + QVERIFY2(uploadOk, qPrintable(error)); + QCOMPARE(mutation.outcome, PrinterProtocol::MutationOutcome::Succeeded); + QCOMPARE(mutation.bytesSent, static_cast(contents.size())); + QCOMPARE(uploadedName, + QStringLiteral("large.mp4.h264_2240x1080")); -void PrinterProtocolTests::incompleteUserConfigIsNotWritten_data() { - QTest::addColumn("brightnessOnly"); - QTest::newRow("missing-work-config") << false; - QTest::newRow("missing-display-config") << true; } -void PrinterProtocolTests::incompleteUserConfigIsNotWritten() { - QFETCH(bool, brightnessOnly); +void PrinterProtocolTests::uploadWaitsForDelayedBoundaryAckWithIdleKeepalive() { + QTemporaryFile media; + QVERIFY(media.open()); + const QByteArray contents(17 * 0x40000, '\x6b'); + QCOMPARE(media.write(contents), static_cast(contents.size())); + media.flush(); + int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QString peerError; + quint64 transferTrackId = 0; std::thread peer([&]() { - panorama::wire::v1::Request getRequest; - if (!readRequest(sockets[1], &getRequest, &peerError) || - getRequest.body_case() != panorama::wire::v1::Request::kUserConfigurationQuery) { - peerError = QStringLiteral("unexpected get-user-config request"); - return; - } - auto response = baseResponse(getRequest); - response.mutable_user_configuration(); - if (!writeResponse(sockets[1], response, &peerError)) { - return; - } - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int pollResult = ::poll(&descriptor, 1, 100); - if (pollResult > 0 && (descriptor.revents & POLLIN) != 0) { - char byte = 0; - const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); - if (received > 0) { - peerError = QStringLiteral("synthetic user configuration was written"); + constexpr int delayedRequestIndex = 17; + constexpr int requestCount = 19; + for (int index = 0; index < requestCount; ++index) { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError, + kPeerTimeoutMs * 2)) { + return; + } + if (!request.has_header() || + request.header().track_id() == 0) { + peerError = QStringLiteral( + "delayed upload request is not tracked"); + return; + } + if (transferTrackId == 0) { + transferTrackId = request.header().track_id(); + } else if (request.header().track_id() != + transferTrackId) { + peerError = QStringLiteral( + "delayed FileTransmit changed track ID"); + return; } - } else if (pollResult < 0 && errno != EINTR) { - peerError = QStringLiteral("failed to verify incomplete config boundary"); - } - }); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QString error; - const PrinterProtocol::OperationContext context; - const bool success = brightnessOnly - ? protocol.setBrightness(QStringLiteral("test-endpoint"), 50, &error, context) - : protocol.applyPresetMedia(QStringLiteral("test-endpoint"), - QStringLiteral("new.h264"), -1, &error, context); - QVERIFY(!success); - QVERIFY(error.contains(QStringLiteral("synthetic"), Qt::CaseInsensitive)); - peer.join(); - ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); -} + if (index == delayedRequestIndex) { + const int timerFd = ::timerfd_create( + CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK); + if (timerFd < 0) { + peerError = QStringLiteral( + "failed to create delayed ACK timer"); + return; + } + itimerspec delay{}; + delay.it_value.tv_sec = 2; + delay.it_value.tv_nsec = 200 * 1000 * 1000; + if (::timerfd_settime(timerFd, 0, &delay, nullptr) != 0) { + peerError = QStringLiteral( + "failed to arm delayed ACK timer"); + ::close(timerFd); + return; + } + pollfd waits[2]{}; + waits[0].fd = sockets[1]; + waits[0].events = POLLIN; + waits[1].fd = timerFd; + waits[1].events = POLLIN; + const int pollResult = ::poll(waits, 2, 3000); + if (pollResult < 1 || + (waits[0].revents & POLLIN) == 0) { + peerError = QStringLiteral( + "FileTransmit did not send idle Ping before delayed DataStatus"); + ::close(timerFd); + return; + } + panorama::wire::v1::Request keepalive; + if (!readRequest(sockets[1], &keepalive, &peerError) || + !keepalive.has_header() || + keepalive.header().ByteSizeLong() != 0 || + keepalive.body_case() != + panorama::wire::v1::Request::kPing || + keepalive.ping().payload() != "hello?") { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "FileTransmit idle frame was not UDB Ping"); + } + ::close(timerFd); + return; + } + auto keepaliveResponse = baseResponse(keepalive); + keepaliveResponse.mutable_pong()->set_payload( + keepalive.ping().payload()); + if (!writeResponse(sockets[1], keepaliveResponse, + &peerError)) { + ::close(timerFd); + return; + } + pollfd timerReady{}; + timerReady.fd = timerFd; + timerReady.events = POLLIN; + if (::poll(&timerReady, 1, 1000) != 1 || + (timerReady.revents & POLLIN) == 0) { + peerError = QStringLiteral( + "delayed DataStatus timer did not fire after idle Ping"); + ::close(timerFd); + return; + } + quint64 expirations = 0; + if (::read(timerFd, &expirations, + sizeof(expirations)) != + static_cast(sizeof(expirations))) { + peerError = QStringLiteral( + "failed to consume delayed ACK timer"); + ::close(timerFd); + return; + } + ::close(timerFd); + } -void PrinterProtocolTests:: -paseStandbyMutationIsRejectedBeforeUsb() { - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); - QString peerError; - std::thread peer([&]() { - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int pollResult = - ::poll(&descriptor, 1, 100); - if (pollResult > 0 && - (descriptor.revents & POLLIN) != 0) { - char byte = 0; - const ssize_t received = ::recv( - sockets[1], &byte, sizeof(byte), - MSG_DONTWAIT); - if (received > 0) { - peerError = QStringLiteral( - "standby mutation reached USB"); + auto response = baseResponse(request); + if (index == 0) { + if (request.body_case() != + panorama::wire::v1::Request::kTransferBegin) { + peerError = QStringLiteral( + "missing delayed upload begin"); + return; + } + response.mutable_transfer_begin_status() + ->set_status( + panorama::wire::v1::TransferStatus::OK); + } else if (index == requestCount - 1) { + if (request.body_case() != + panorama::wire::v1::Request::kTransferEnd) { + peerError = QStringLiteral( + "missing delayed upload end"); + return; + } + response.mutable_transfer_end_status() + ->set_status( + panorama::wire::v1::TransferStatus::OK); + } else { + if (request.body_case() != + panorama::wire::v1::Request::kTransferChunk || + request.transfer_chunk().file_data().size() != + static_cast(0x40000)) { + peerError = QStringLiteral( + "invalid delayed upload data request"); + return; + } + response.mutable_transfer_chunk_status() + ->set_status( + panorama::wire::v1::TransferStatus::OK); + } + if (!writeResponse(sockets[1], response, &peerError)) { + return; } - } else if (pollResult < 0 && errno != EINTR) { - peerError = QStringLiteral( - "standby boundary poll failed"); } }); - PrinterProtocol protocol(500); - const QString endpoint = - QStringLiteral( - "/dev/usb/lp-pase-standby-rejected"); + PrinterProtocol protocol(60); + protocol.setFileTransmitDataWriteTimeoutForTesting(500); + protocol.setFileTransmitResponseTimeoutForTesting(3000); protocol.adoptFileDescriptorForTesting( - sockets[0], endpoint); - PrinterProtocol::PaseApplyConfig config; - config.display.standbyPresent = true; - config.display.standbyEnabled = false; + sockets[0], QStringLiteral("test-endpoint")); + PrinterProtocol::OperationContext context; + context.maintainKeepalive = true; + QString uploadedName; QString error; PrinterProtocol::MutationDetails mutation; - QVERIFY(!protocol.applyPaseConfiguration( - endpoint, config, &error, - PrinterProtocol::OperationContext{}, - &mutation)); - QCOMPARE( - mutation.outcome, - PrinterProtocol::MutationOutcome::Rejected); - QVERIFY(error.contains( - QStringLiteral("standby"), - Qt::CaseInsensitive)); + QElapsedTimer uploadTimer; + uploadTimer.start(); + const bool uploadOk = protocol.uploadMedia( + QStringLiteral("test-endpoint"), media.fileName(), + QStringLiteral("delayed.mp4.h264_2240x1080"), + &uploadedName, &error, {}, context, &mutation); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY2(uploadOk, qPrintable(error)); + QVERIFY(uploadTimer.elapsed() >= 2000); + QVERIFY(uploadTimer.elapsed() < 8000); + QVERIFY(transferTrackId != 0); + QCOMPARE(mutation.outcome, + PrinterProtocol::MutationOutcome::Succeeded); + QCOMPARE(mutation.bytesSent, + static_cast(contents.size())); + QCOMPARE(uploadedName, + QStringLiteral("delayed.mp4.h264_2240x1080")); } -void PrinterProtocolTests::unsafeMediaNamesAreRejected() { - const QStringList unsafeNames = { - QStringLiteral("."), - QStringLiteral(".."), - QStringLiteral(".hidden.h264"), - QStringLiteral("folder/file.h264"), - QStringLiteral("name with spaces.h264") - }; - const PrinterProtocol::OperationContext context; - for (const QString &name : unsafeNames) { - PrinterProtocol protocol(100); - QString error; - QVERIFY2(!protocol.applyPresetMedia(QStringLiteral("unused"), name, -1, - &error, context), - qPrintable(name)); - QVERIFY2(error.contains(QStringLiteral("not supported"), Qt::CaseInsensitive), - qPrintable(error)); - } -} - -void PrinterProtocolTests::uploadRejectsSymlinkAndHashMismatchBeforeUsb() { - QTemporaryDir temporaryDirectory; - QVERIFY(temporaryDirectory.isValid()); - const QString sourcePath = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("source.h264")); - const QByteArray sourceBytes(4096, '\x6a'); - QFile source(sourcePath); - QVERIFY(source.open(QIODevice::WriteOnly | QIODevice::Truncate)); - QCOMPARE(source.write(sourceBytes), - static_cast(sourceBytes.size())); - source.close(); - const QString symlinkPath = - QDir(temporaryDirectory.path()).filePath(QStringLiteral("link.h264")); - const QByteArray encodedSource = QFile::encodeName(sourcePath); - const QByteArray encodedLink = QFile::encodeName(symlinkPath); - QCOMPARE(::symlink(encodedSource.constData(), encodedLink.constData()), 0); - - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - PrinterProtocol protocol; - const QString devicePath = QStringLiteral("/dev/usb/lp-pase-safe-open"); - protocol.adoptFileDescriptorForTesting(sockets[0], devicePath); - - QString uploadedName; - QString error; - PrinterProtocol::MutationDetails mutation; - QVERIFY(!protocol.uploadMedia( - devicePath, symlinkPath, - QStringLiteral("safe.mp4.h264_2240x1080"), &uploadedName, &error, - {}, PrinterProtocol::OperationContext{}, &mutation, - QString::fromLatin1( - QCryptographicHash::hash(sourceBytes, QCryptographicHash::Sha256) - .toHex()))); - QVERIFY(error.contains(QStringLiteral("does not exist"), - Qt::CaseInsensitive)); - QCOMPARE(mutation.outcome, PrinterProtocol::MutationOutcome::NotStarted); - QCOMPARE(mutation.stage, QStringLiteral("Validating")); - - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - QCOMPARE(::poll(&descriptor, 1, 50), 0); - - error.clear(); - mutation = {}; - QVERIFY(!protocol.uploadMedia( - devicePath, sourcePath, - QStringLiteral("safe.mp4.h264_2240x1080"), &uploadedName, &error, - {}, PrinterProtocol::OperationContext{}, &mutation, - QString(64, QLatin1Char('0')))); - QVERIFY(error.contains(QStringLiteral("changed"), Qt::CaseInsensitive)); - QCOMPARE(mutation.outcome, PrinterProtocol::MutationOutcome::NotStarted); - QCOMPARE(mutation.stage, QStringLiteral("Validating")); - descriptor.revents = 0; - QCOMPARE(::poll(&descriptor, 1, 50), 0); - ::close(sockets[1]); -} - -void PrinterProtocolTests::duplexInputReceivesAckDuringOutput() { - QList events; - PrinterProtocol::DuplexTestEvent input; - input.direction = PrinterProtocol::DuplexTestDirection::Input; - input.payload = QByteArrayLiteral("ack"); - events.append(input); - PrinterProtocol::DuplexTestEvent output; - output.direction = PrinterProtocol::DuplexTestDirection::Output; - events.append(output); - - const PrinterProtocol::DuplexTestResult result = - PrinterProtocol::runDuplexTransportScenarioForTesting( - events, QByteArrayLiteral("request")); - QVERIFY2(result.writeSucceeded, qPrintable(result.error)); - QVERIFY(result.responseReceived); - QCOMPARE(result.response, QByteArrayLiteral("ack")); - QCOMPARE(result.inputSubmissions, 1); - QCOMPARE(result.outputSubmissions, 1); - QCOMPARE(result.maximumConcurrentInputs, 1); - QCOMPARE(result.inputCompletions, 1); - QCOMPARE(result.zeroLengthInputCompletions, 0); - QCOMPARE(result.inputErrors, 0); - QCOMPARE(result.inputRearmsDuringOutput, 0); -} - -void PrinterProtocolTests:: - duplexZeroLengthInputDefersRearmUntilOutputCompletes() { - QList events; - PrinterProtocol::DuplexTestEvent emptyInput; - emptyInput.direction = PrinterProtocol::DuplexTestDirection::Input; - events.append(emptyInput); - PrinterProtocol::DuplexTestEvent output; - output.direction = PrinterProtocol::DuplexTestDirection::Output; - output.deferredDispatches = 1; - events.append(output); - PrinterProtocol::DuplexTestEvent response; - response.direction = PrinterProtocol::DuplexTestDirection::Input; - response.payload = QByteArrayLiteral("ack-after-zlp"); - events.append(response); - - const PrinterProtocol::DuplexTestResult result = - PrinterProtocol::runDuplexTransportScenarioForTesting( - events, QByteArrayLiteral("request")); - QVERIFY2(result.writeSucceeded, qPrintable(result.error)); - QVERIFY(result.responseReceived); - QCOMPARE(result.response, QByteArrayLiteral("ack-after-zlp")); - QCOMPARE(result.inputSubmissions, 2); - QCOMPARE(result.outputSubmissions, 1); - QCOMPARE(result.maximumConcurrentInputs, 1); - QCOMPARE(result.inputCompletions, 2); - QCOMPARE(result.zeroLengthInputCompletions, 1); - QCOMPARE(result.inputErrors, 0); - QCOMPARE(result.inputRearmsDuringOutput, 0); -} - -void PrinterProtocolTests:: - duplexInputErrorDefersRearmWithoutStarvingOutput() { - QList events; - PrinterProtocol::DuplexTestEvent inputError; - inputError.direction = PrinterProtocol::DuplexTestDirection::Input; - inputError.status = PrinterProtocol::DuplexTestStatus::Error; - events.append(inputError); - PrinterProtocol::DuplexTestEvent output; - output.direction = PrinterProtocol::DuplexTestDirection::Output; - output.deferredDispatches = 1; - events.append(output); - PrinterProtocol::DuplexTestEvent response; - response.direction = PrinterProtocol::DuplexTestDirection::Input; - response.payload = QByteArrayLiteral("ack-after-error"); - events.append(response); - - const PrinterProtocol::DuplexTestResult result = - PrinterProtocol::runDuplexTransportScenarioForTesting( - events, QByteArrayLiteral("request")); - QVERIFY2(result.writeSucceeded, qPrintable(result.error)); - QVERIFY(result.responseReceived); - QCOMPARE(result.response, QByteArrayLiteral("ack-after-error")); - QCOMPARE(result.inputSubmissions, 2); - QCOMPARE(result.outputSubmissions, 1); - QCOMPARE(result.maximumConcurrentInputs, 1); - QCOMPARE(result.inputCompletions, 2); - QCOMPARE(result.zeroLengthInputCompletions, 0); - QCOMPARE(result.inputErrors, 1); - QCOMPARE(result.inputRearmsDuringOutput, 0); -} - -void PrinterProtocolTests::duplexInputRetryBudgetIsBounded() { - QList events; - PrinterProtocol::DuplexTestEvent output; - output.direction = PrinterProtocol::DuplexTestDirection::Output; - events.append(output); - for (int completion = 0; completion < 10; ++completion) { - PrinterProtocol::DuplexTestEvent inputError; - inputError.direction = - PrinterProtocol::DuplexTestDirection::Input; - inputError.status = - PrinterProtocol::DuplexTestStatus::Error; - events.append(inputError); - } - - const PrinterProtocol::DuplexTestResult result = - PrinterProtocol::runDuplexTransportScenarioForTesting( - events, QByteArrayLiteral("request"), 5000, 100); - QVERIFY2(result.writeSucceeded, qPrintable(result.error)); - QVERIFY(!result.responseReceived); - QVERIFY2(result.error.contains( - QStringLiteral("persistent"), - Qt::CaseInsensitive), - qPrintable(result.error)); - QCOMPARE(result.outputSubmissions, 1); - QCOMPARE(result.maximumConcurrentInputs, 1); - QCOMPARE(result.inputCompletions, 10); - QCOMPARE(result.zeroLengthInputCompletions, 0); - QCOMPARE(result.inputErrors, 10); - QCOMPARE(result.inputRearmsDuringOutput, 0); - QVERIFY(result.persistentInputFailure); -} - -void PrinterProtocolTests::uploadIsResponseDriven() { +void PrinterProtocolTests::uploadEndTimeoutIsFinalizationUnknown() { QTemporaryFile media; QVERIFY(media.open()); - const QByteArray contents(300000, '\x5a'); - QCOMPARE(media.write(contents), static_cast(contents.size())); + const QByteArray contents(4096, '\x71'); + QCOMPARE(media.write(contents), + static_cast(contents.size())); media.flush(); int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - + QVERIFY2(createSocketPair(sockets, &socketError), + qPrintable(socketError)); QString peerError; - quint64 transferTrackId = 0; - quint64 nextTrackId = 0; std::thread peer([&]() { const QList expected = { panorama::wire::v1::Request::kTransferBegin, panorama::wire::v1::Request::kTransferChunk, - panorama::wire::v1::Request::kTransferChunk, - panorama::wire::v1::Request::kTransferEnd, - panorama::wire::v1::Request::kPing + panorama::wire::v1::Request::kTransferEnd }; + quint64 transferTrackId = 0; for (int index = 0; index < expected.size(); ++index) { panorama::wire::v1::Request request; if (!readRequest(sockets[1], &request, &peerError) || request.body_case() != expected.at(index)) { if (peerError.isEmpty()) { - peerError = QStringLiteral("unexpected upload request %1").arg(index); + peerError = QStringLiteral( + "unexpected request before End timeout"); } return; } if (!request.has_header() || + request.header().version() != 0 || request.header().track_id() == 0) { peerError = QStringLiteral( - "upload request does not have a tracked header"); + "FileTransmit request does not match UDB header"); return; } - const quint32 expectedVersion = index <= 3 ? 0U : 1U; - if (request.header().version() != expectedVersion) { + if (transferTrackId == 0) { + transferTrackId = request.header().track_id(); + } else if (request.header().track_id() != transferTrackId) { peerError = QStringLiteral( - "unexpected request version %1 at index %2") - .arg(request.header().version()) - .arg(index); + "FileTransmit changed track before End timeout"); return; } - if (index <= 3) { - if (transferTrackId == 0) { - transferTrackId = request.header().track_id(); - } else if (request.header().track_id() != - transferTrackId) { - peerError = QStringLiteral( - "FileTransmit changed track ID within one transfer"); - return; - } + if (index == expected.size() - 1) { + continue; + } + auto response = baseResponse(request); + if (index == 0) { + response.mutable_transfer_begin_status() + ->set_status( + panorama::wire::v1::TransferStatus::OK); } else { - nextTrackId = request.header().track_id(); - if (nextTrackId == transferTrackId) { - peerError = QStringLiteral( - "the command after FileTransmit reused its track ID"); - return; - } + response.mutable_transfer_chunk_status() + ->set_status( + panorama::wire::v1::TransferStatus::OK); + } + if (!writeResponse(sockets[1], response, &peerError)) { + return; } + } + + pollfd closeDescriptor{}; + closeDescriptor.fd = sockets[1]; + closeDescriptor.events = POLLIN; + if (::poll(&closeDescriptor, 1, kPeerTimeoutMs) <= 0) { + peerError = QStringLiteral( + "End timeout did not close the transport epoch"); + return; + } + char byte = 0; + if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) != 0) { + peerError = QStringLiteral( + "End timeout transport did not close cleanly"); + } + }); + + PrinterProtocol protocol(80); + protocol.setFileTransmitResponseTimeoutForTesting(120); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + QString error; + QString uploadedName; + PrinterProtocol::MutationDetails mutation; + QVERIFY(!protocol.uploadMedia( + QStringLiteral("test-endpoint"), media.fileName(), + QStringLiteral("end-timeout.mp4.h264_2240x1080"), + &uploadedName, &error, {}, + PrinterProtocol::OperationContext{}, &mutation)); + QVERIFY(error.contains(QStringLiteral("timed out"), + Qt::CaseInsensitive)); + QCOMPARE(mutation.outcome, + PrinterProtocol::MutationOutcome::FinalizationUnknown); + QCOMPARE(mutation.stage, QStringLiteral("Ending")); + QCOMPARE(mutation.bytesSent, + static_cast(contents.size())); + QCOMPARE(mutation.totalBytes, + static_cast(contents.size())); + + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::uploadFailureClosesSession_data() { + QTest::addColumn("failureStage"); + QTest::newRow("begin") << 0; + QTest::newRow("data") << 1; + QTest::newRow("end") << 2; +} + +void PrinterProtocolTests::uploadFailureClosesSession() { + QFETCH(int, failureStage); + QTemporaryFile media; + QVERIFY(media.open()); + const QByteArray contents(4096, '\x33'); + QCOMPARE(media.write(contents), static_cast(contents.size())); + media.flush(); + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QString peerError; + std::thread peer([&]() { + const QList expected = { + panorama::wire::v1::Request::kTransferBegin, + panorama::wire::v1::Request::kTransferChunk, + panorama::wire::v1::Request::kTransferEnd + }; + for (int stage = 0; stage <= failureStage; ++stage) { + panorama::wire::v1::Request request; + if (!readRequest(sockets[1], &request, &peerError) || + request.body_case() != expected.at(stage)) { + peerError = QStringLiteral("unexpected request at failing stage %1").arg(stage); + return; + } auto response = baseResponse(request); - if (index == 0) { - if (request.transfer_begin().file_name() != - "test.png.h264_2240x1080" || - request.transfer_begin().file_size() != - static_cast(contents.size())) { - peerError = QStringLiteral("invalid upload begin fields"); - return; - } - response.mutable_transfer_begin_status()->set_status( - panorama::wire::v1::TransferStatus::OK); - } else if (index < 3) { - const size_t expectedSize = index == 1 - ? static_cast(0x40000) - : static_cast(contents.size() - 0x40000); - if (request.transfer_chunk().file_data().size() != expectedSize) { - peerError = QStringLiteral("invalid upload chunk size"); - return; - } - response.mutable_transfer_chunk_status()->set_status( - panorama::wire::v1::TransferStatus::OK); - } else if (index == 3) { - if (request.transfer_end().file_type() != "media" || - request.transfer_end().checksum() != 0) { - peerError = QStringLiteral("invalid upload end fields"); - return; - } - response.mutable_transfer_end_status()->set_status( - panorama::wire::v1::TransferStatus::OK); + const auto status = stage == failureStage + ? panorama::wire::v1::TransferStatus::FILE_ERROR + : panorama::wire::v1::TransferStatus::OK; + if (stage == 0) { + response.mutable_transfer_begin_status()->set_status(status); + } else if (stage == 1) { + response.mutable_transfer_chunk_status()->set_status(status); } else { - response.mutable_pong()->set_payload( - request.ping().payload()); + response.mutable_transfer_end_status()->set_status(status); } if (!writeResponse(sockets[1], response, &peerError)) { return; } } + + pollfd closeDescriptor{}; + closeDescriptor.fd = sockets[1]; + closeDescriptor.events = POLLIN; + if (::poll(&closeDescriptor, 1, kPeerTimeoutMs) <= 0) { + peerError = QStringLiteral( + "failed upload did not close its transport epoch"); + return; + } + char byte = 0; + if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) != 0) { + peerError = QStringLiteral( + "failed upload transport did not close cleanly"); + } + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + QString error; + QString uploadedName; + const PrinterProtocol::OperationContext context; + PrinterProtocol::MutationDetails mutation; + QVERIFY(!protocol.uploadMedia( + QStringLiteral("test-endpoint"), media.fileName(), + QStringLiteral("failure.mp4.h264_2240x1080"), + &uploadedName, &error, {}, context, &mutation)); + QVERIFY(error.contains(QStringLiteral("file error"), Qt::CaseInsensitive)); + QCOMPARE(mutation.outcome, + failureStage == 0 + ? PrinterProtocol::MutationOutcome::Rejected + : PrinterProtocol::MutationOutcome::PartialOrUnknown); + QCOMPARE(mutation.stage, + failureStage == 0 + ? QStringLiteral("Beginning") + : failureStage == 1 + ? QStringLiteral("Transferring") + : QStringLiteral("Ending")); + QCOMPARE(mutation.bytesSent, + failureStage == 2 ? static_cast(contents.size()) : 0); + QCOMPARE(mutation.totalBytes, static_cast(contents.size())); + + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::uploadCancellationStopsBeforeNextChunk() { + QTemporaryFile media; + QVERIFY(media.open()); + const QByteArray contents(600000, '\x44'); + QCOMPARE(media.write(contents), static_cast(contents.size())); + media.flush(); + + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + std::atomic_bool cancelled{false}; + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request beginRequest; + if (!readRequest(sockets[1], &beginRequest, &peerError) || + beginRequest.body_case() != + panorama::wire::v1::Request::kTransferBegin) { + peerError = QStringLiteral("missing upload begin before cancellation"); + return; + } + auto beginResponse = baseResponse(beginRequest); + beginResponse.mutable_transfer_begin_status()->set_status( + panorama::wire::v1::TransferStatus::OK); + if (!writeResponse(sockets[1], beginResponse, &peerError)) { + return; + } + + panorama::wire::v1::Request dataRequest; + if (!readRequest(sockets[1], &dataRequest, &peerError) || + dataRequest.body_case() != + panorama::wire::v1::Request::kTransferChunk) { + peerError = QStringLiteral("missing first data chunk before cancellation"); + return; + } + auto dataResponse = baseResponse(dataRequest); + dataResponse.mutable_transfer_chunk_status()->set_status( + panorama::wire::v1::TransferStatus::OK); + if (!writeResponse(sockets[1], dataResponse, &peerError)) { + return; + } + + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); + if (pollResult <= 0) { + peerError = QStringLiteral("upload endpoint did not close after cancellation"); + return; + } + char byte = 0; + const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); + if (received > 0) { + peerError = QStringLiteral("another data chunk was sent after cancellation"); + } else if (received < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + peerError = QStringLiteral("failed to inspect cancelled upload endpoint"); + } }); PrinterProtocol protocol(500); protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QString uploadedName; + PrinterProtocol::OperationContext context; + context.isCancelled = [&cancelled]() { + return cancelled.load(std::memory_order_acquire); + }; QString error; - qint64 finalProgress = 0; - const PrinterProtocol::OperationContext context; + QString uploadedName; PrinterProtocol::MutationDetails mutation; - QVERIFY2(protocol.uploadMedia( - QStringLiteral("test-endpoint"), media.fileName(), - QStringLiteral("test.png.h264_2240x1080"), - &uploadedName, &error, - [&finalProgress](qint64 sent, qint64) { finalProgress = sent; }, - context, &mutation), - qPrintable(error)); - QCOMPARE(uploadedName, QStringLiteral("test.png.h264_2240x1080")); - QCOMPARE(finalProgress, static_cast(contents.size())); - QCOMPARE(mutation.outcome, PrinterProtocol::MutationOutcome::Succeeded); - QCOMPARE(mutation.stage, QStringLiteral("Ending")); - QCOMPARE(mutation.bytesSent, static_cast(contents.size())); + QVERIFY(!protocol.uploadMedia( + QStringLiteral("test-endpoint"), media.fileName(), + QStringLiteral("cancel.mp4.h264_2240x1080"), + &uploadedName, &error, + [&cancelled](qint64 sent, qint64) { + if (sent > 0) { + cancelled.store(true, std::memory_order_release); + } + }, + context, &mutation)); + QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); + QCOMPARE(mutation.outcome, + PrinterProtocol::MutationOutcome::PartialOrUnknown); + QCOMPARE(mutation.stage, QStringLiteral("Transferring")); + QCOMPARE(mutation.bytesSent, qint64(0x40000)); QCOMPARE(mutation.totalBytes, static_cast(contents.size())); - QString pingPayload; - QVERIFY2(protocol.trackedPingForTesting( - QStringLiteral("test-endpoint"), &pingPayload, &error, - context), - qPrintable(error)); - QCOMPARE(pingPayload, QStringLiteral("hello?")); peer.join(); ::close(sockets[1]); - QVERIFY(transferTrackId != 0); - QVERIFY(nextTrackId != 0); - QVERIFY(nextTrackId != transferTrackId); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } -void PrinterProtocolTests::uploadDataUsesDedicatedWriteDeadline() { +void PrinterProtocolTests::uploadSourceMutationIsRejected_data() { + QTest::addColumn("growFile"); + QTest::newRow("grow") << true; + QTest::newRow("shrink") << false; +} + +void PrinterProtocolTests::uploadSourceMutationIsRejected() { + QFETCH(bool, growFile); QTemporaryFile media; QVERIFY(media.open()); - const QByteArray contents(17 * 0x40000, '\x4d'); + const QByteArray contents(300000, '\x55'); QCOMPARE(media.write(contents), static_cast(contents.size())); media.flush(); int sockets[2] = {-1, -1}; QString socketError; QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - const int socketBufferSize = 4096; - QVERIFY(::setsockopt(sockets[0], SOL_SOCKET, SO_SNDBUF, - &socketBufferSize, sizeof(socketBufferSize)) == 0); - QVERIFY(::setsockopt(sockets[1], SOL_SOCKET, SO_RCVBUF, - &socketBufferSize, sizeof(socketBufferSize)) == 0); - QString peerError; std::thread peer([&]() { - constexpr int delayedRequestIndex = 17; - constexpr int requestCount = 19; - for (int index = 0; index < requestCount; ++index) { - if (index == delayedRequestIndex) { - pollfd requestReady{}; - requestReady.fd = sockets[1]; - requestReady.events = POLLIN; - if (::poll(&requestReady, 1, kPeerTimeoutMs) != 1 || - (requestReady.revents & POLLIN) == 0) { - peerError = QStringLiteral( - "delayed upload request did not reach the peer"); - return; - } + panorama::wire::v1::Request beginRequest; + if (!readRequest(sockets[1], &beginRequest, &peerError)) { + return; + } + auto beginResponse = baseResponse(beginRequest); + beginResponse.mutable_transfer_begin_status()->set_status( + panorama::wire::v1::TransferStatus::OK); + if (!writeResponse(sockets[1], beginResponse, &peerError)) { + return; + } - const int timerFd = ::timerfd_create( - CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK); - if (timerFd < 0) { - peerError = QStringLiteral("failed to create delay timer"); - return; - } - itimerspec delay{}; - delay.it_value.tv_nsec = 120 * 1000 * 1000; - if (::timerfd_settime(timerFd, 0, &delay, nullptr) != 0) { - peerError = QStringLiteral("failed to arm delay timer"); - ::close(timerFd); - return; - } - pollfd timerReady{}; - timerReady.fd = timerFd; - timerReady.events = POLLIN; - if (::poll(&timerReady, 1, 500) != 1 || - (timerReady.revents & POLLIN) == 0) { - peerError = QStringLiteral("bounded delay timer did not fire"); - ::close(timerFd); - return; - } - quint64 expirations = 0; - if (::read(timerFd, &expirations, sizeof(expirations)) != - static_cast(sizeof(expirations))) { - peerError = QStringLiteral("failed to consume delay timer"); - ::close(timerFd); - return; - } - ::close(timerFd); - } + panorama::wire::v1::Request dataRequest; + if (!readRequest(sockets[1], &dataRequest, &peerError) || + dataRequest.transfer_chunk().file_data().size() != 0x40000) { + peerError = QStringLiteral("unexpected first chunk before source mutation"); + return; + } + auto dataResponse = baseResponse(dataRequest); + dataResponse.mutable_transfer_chunk_status()->set_status( + panorama::wire::v1::TransferStatus::OK); + if (!writeResponse(sockets[1], dataResponse, &peerError)) { + return; + } - panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError)) { + pollfd descriptor{}; + descriptor.fd = sockets[1]; + descriptor.events = POLLIN; + const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); + if (pollResult <= 0) { + peerError = QStringLiteral("upload endpoint remained open after source mutation"); + return; + } + char byte = 0; + const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); + if (received > 0) { + peerError = QStringLiteral("upload sent bytes beyond the stable declared source"); + } + }); + + PrinterProtocol protocol(500); + protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); + bool mutated = false; + QString error; + QString uploadedName; + const PrinterProtocol::OperationContext context; + PrinterProtocol::MutationDetails mutation; + QVERIFY(!protocol.uploadMedia( + QStringLiteral("test-endpoint"), media.fileName(), + QStringLiteral("mutation.mp4.h264_2240x1080"), + &uploadedName, &error, + [&](qint64 sent, qint64) { + if (mutated || sent <= 0) { return; } - auto response = baseResponse(request); - if (index == 0) { - if (request.body_case() != - panorama::wire::v1::Request::kTransferBegin) { - peerError = QStringLiteral("missing upload begin request"); - return; - } - response.mutable_transfer_begin_status() - ->set_status( - panorama::wire::v1::TransferStatus::OK); - } else if (index == requestCount - 1) { - if (request.body_case() != - panorama::wire::v1::Request::kTransferEnd) { - peerError = QStringLiteral("missing upload end request"); - return; + QFile mutation(media.fileName()); + if (growFile) { + if (mutation.open(QIODevice::WriteOnly | QIODevice::Append)) { + mutated = mutation.write(QByteArray(100, '\x66')) == 100 && + mutation.flush(); } - response.mutable_transfer_end_status() - ->set_status( - panorama::wire::v1::TransferStatus::OK); - } else { - if (request.body_case() != - panorama::wire::v1::Request::kTransferChunk || - request.transfer_chunk().file_data().size() != - static_cast(0x40000)) { - peerError = QStringLiteral("invalid upload data request"); - return; + } else if (mutation.open(QIODevice::ReadWrite)) { + mutated = mutation.resize(1024) && mutation.flush(); + } + }, + context, &mutation)); + QVERIFY(mutated); + QVERIFY(error.contains(QStringLiteral("changed"), Qt::CaseInsensitive)); + QCOMPARE(mutation.outcome, + PrinterProtocol::MutationOutcome::PartialOrUnknown); + QCOMPARE(mutation.stage, QStringLiteral("Transferring")); + QCOMPARE(mutation.bytesSent, qint64(0x40000)); + QCOMPARE(mutation.totalBytes, static_cast(contents.size())); + peer.join(); + ::close(sockets[1]); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); +} + +void PrinterProtocolTests::mediaReadWireGoldenFixtures() { + const QByteArray rawPath = + QByteArrayLiteral( + "/userdata/user/a.mp4.h264_2240x1080"); + + panorama::wire::v1::Request request; + request.mutable_header()->set_track_id(7); + auto *read = request.mutable_media_read_chunk(); + read->set_remote_path( + rawPath.constData(), + static_cast(rawPath.size())); + read->set_session_id(9); + read->set_offset(255); + std::string serialized; + QVERIFY(request.SerializeToString(&serialized)); + QCOMPARE( + QByteArray( + serialized.data(), + static_cast(serialized.size())).toHex(), + QByteArrayLiteral( + "0a021007b2192a0a232f75736572646174612f757365722f" + "612e6d70342e683236345f323234307831303830100918ff01")); + + panorama::wire::v1::Response response; + response.mutable_header()->set_version(1); + response.mutable_header()->set_track_id(7); + auto *chunk = response.mutable_media_read_chunk(); + chunk->set_remote_path( + rawPath.constData(), + static_cast(rawPath.size())); + chunk->set_session_id(9); + chunk->set_offset(255); + chunk->set_file_size(512); + chunk->set_data("abc"); + serialized.clear(); + QVERIFY(response.SerializeToString(&serialized)); + QCOMPARE( + QByteArray( + serialized.data(), + static_cast(serialized.size())).toHex(), + QByteArrayLiteral( + "0a0408011007aa323212232f75736572646174612f757365722f" + "612e6d70342e683236345f323234307831303830180920ff0128" + "80043203616263")); + + const auto *requestField = + panorama::wire::v1::Request::descriptor() + ->FindFieldByName("media_read_chunk"); + const auto *responseField = + panorama::wire::v1::Response::descriptor() + ->FindFieldByName("media_read_chunk"); + QVERIFY(requestField); + QVERIFY(responseField); + QCOMPARE(requestField->number(), 406); + QCOMPARE(responseField->number(), 805); + + QByteArray boundaryBytes; + for (int index = 0; index < 513; ++index) { + boundaryBytes.append( + static_cast((index * 29 + 7) & 0xff)); + } + const QByteArray transformed = + PrinterProtocol::applyMediaPullXorForTesting( + boundaryBytes, 255); + QVERIFY(transformed != boundaryBytes); + QCOMPARE( + PrinterProtocol::applyMediaPullXorForTesting( + transformed, 255), + boundaryBytes); +} + +void PrinterProtocolTests::mediaPullDecodesBoundedChunks() { + const QString mediaName = + QStringLiteral( + "fixture.mp4.h264_2240x1080"); + const QByteArray rawPath = + QByteArrayLiteral( + "/userdata/user/fixture.mp4.h264_2240x1080"); + QByteArray decoded; + for (int index = 0; index < 777; ++index) { + decoded.append( + static_cast((index * 37 + 11) & 0xff)); + } + const QByteArray raw = + PrinterProtocol::applyMediaPullXorForTesting( + decoded, 0); + const QList chunkSizes = {255, 1, 257, 264}; + + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + QString peerError; + quint64 observedSession = 0; + QSet observedTracks; + std::thread peer([&]() { + panorama::wire::v1::Request catalogRequest; + if (!readRequest( + sockets[1], &catalogRequest, &peerError) || + catalogRequest.body_case() != + panorama::wire::v1::Request:: + kMediaCatalogQuery || + catalogRequest.header().version() != 1) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "media pull did not start with a fresh catalog query"); + } + return; + } + const auto catalog = mediaCatalogResponse( + catalogRequest, rawPath, + static_cast(decoded.size())); + if (!writeResponse( + sockets[1], catalog, &peerError)) { + return; + } + + quint64 expectedOffset = 0; + for (int index = 0; + index < chunkSizes.size(); ++index) { + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kMediaReadChunk || + !request.has_header() || + request.header().version() != 0 || + request.header().track_id() == 0) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "invalid bounded media read request %1") + .arg(index); } - response.mutable_transfer_chunk_status() - ->set_status( - panorama::wire::v1::TransferStatus::OK); + return; } - if (!writeResponse(sockets[1], response, &peerError)) { + if (observedTracks.contains( + request.header().track_id())) { + peerError = QStringLiteral( + "media pull reused a track identifier"); + return; + } + observedTracks.insert( + request.header().track_id()); + + const auto &read = + request.media_read_chunk(); + const QByteArray requestedPath( + read.remote_path().data(), + static_cast( + read.remote_path().size())); + if (requestedPath != rawPath || + read.offset() != expectedOffset || + read.session_id() == 0 || + (observedSession != 0 && + read.session_id() != observedSession)) { + peerError = QStringLiteral( + "media pull request identity changed at chunk %1") + .arg(index); + return; + } + observedSession = read.session_id(); + + auto response = baseResponse(request); + response.mutable_header()->set_version( + index % 2 == 0 ? 0 : 1); + auto *chunk = + response.mutable_media_read_chunk(); + chunk->set_remote_path( + rawPath.constData(), + static_cast(rawPath.size())); + chunk->set_session_id(observedSession); + chunk->set_offset(expectedOffset); + chunk->set_file_size( + static_cast(decoded.size())); + const QByteArray bytes = raw.mid( + static_cast(expectedOffset), + chunkSizes.at(index)); + chunk->set_data( + bytes.constData(), + static_cast(bytes.size())); + if (!writeResponse( + sockets[1], response, &peerError)) { return; } + expectedOffset += + static_cast(bytes.size()); } }); - PrinterProtocol protocol(60); - protocol.setFileTransmitDataWriteTimeoutForTesting(500); - protocol.adoptFileDescriptorForTesting(sockets[0], - QStringLiteral("test-endpoint")); - QString uploadedName; - QString error; - PrinterProtocol::MutationDetails mutation; - const PrinterProtocol::OperationContext context; - const bool uploadOk = protocol.uploadMedia( - QStringLiteral("test-endpoint"), media.fileName(), - QStringLiteral("large.mp4.h264_2240x1080"), - &uploadedName, &error, {}, context, &mutation); + PrinterProtocol protocol(500); + protocol.setMediaPullLimitsForTesting( + 1024 * 1024, 16, 5000); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + QByteArray received; + QList progressOffsets; + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), mediaName, + decoded.size(), + [&received]( + qint64 offset, const QByteArray &chunk, + QString *errorMessage) { + if (offset != received.size()) { + if (errorMessage) { + *errorMessage = + QStringLiteral( + "decoded sink offset mismatch"); + } + return false; + } + received.append(chunk); + return true; + }, + [&progressOffsets](qint64 completed, qint64) { + progressOffsets.append(completed); + }, + PrinterProtocol::OperationContext{}); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QVERIFY2(uploadOk, qPrintable(error)); - QCOMPARE(mutation.outcome, PrinterProtocol::MutationOutcome::Succeeded); - QCOMPARE(mutation.bytesSent, static_cast(contents.size())); - QCOMPARE(uploadedName, - QStringLiteral("large.mp4.h264_2240x1080")); + QVERIFY2(result.success, qPrintable(result.error)); + QVERIFY(!result.cancelled); + QCOMPARE(result.mediaName, mediaName); + QCOMPARE(result.fileSize, + static_cast(decoded.size())); + QCOMPARE(result.bytesDecoded, + static_cast(decoded.size())); + QCOMPARE(result.chunkCount, chunkSizes.size()); + QCOMPARE(received, decoded); + QCOMPARE(observedTracks.size(), chunkSizes.size()); + QVERIFY(observedSession != 0); + QCOMPARE( + progressOffsets, + QList({255, 256, 513, 777})); + QCOMPARE( + result.rawSha256, + QString::fromLatin1( + QCryptographicHash::hash( + raw, QCryptographicHash::Sha256) + .toHex())); + QCOMPARE( + result.decodedSha256, + QString::fromLatin1( + QCryptographicHash::hash( + decoded, QCryptographicHash::Sha256) + .toHex())); +} +void PrinterProtocolTests::mediaPullPathValidation_data() { + QTest::addColumn("rawPath"); + QTest::addColumn("mediaName"); + QTest::addColumn("accepted"); + + const QString name = + QStringLiteral( + "safe.mp4.h264_2240x1080"); + QTest::newRow("exact") + << QByteArrayLiteral( + "/userdata/user/safe.mp4.h264_2240x1080") + << name << true; + QTest::newRow("wrong-prefix") + << QByteArrayLiteral( + "/userdata/users/safe.mp4.h264_2240x1080") + << name << false; + QTest::newRow("traversal") + << QByteArrayLiteral( + "/userdata/user/../safe.mp4.h264_2240x1080") + << name << false; + QTest::newRow("nested") + << QByteArrayLiteral( + "/userdata/user/sub/safe.mp4.h264_2240x1080") + << name << false; + QTest::newRow("backslash") + << QByteArrayLiteral( + "/userdata/user/safe.mp4.h264_2240x1080\\suffix") + << name << false; + QByteArray nulPath = + QByteArrayLiteral( + "/userdata/user/safe.mp4.h264_2240x1080"); + nulPath.insert( + QByteArrayLiteral("/userdata/user/").size(), + '\0'); + QTest::newRow("nul") + << nulPath << name << false; + QByteArray controlPath = + QByteArrayLiteral( + "/userdata/user/safe.mp4.h264_2240x1080"); + controlPath.insert( + QByteArrayLiteral("/userdata/user/").size(), + '\x1f'); + QTest::newRow("control") + << controlPath << name << false; + QTest::newRow("mismatched-basename") + << QByteArrayLiteral( + "/userdata/user/other.mp4.h264_2240x1080") + << name << false; + QTest::newRow("unsafe-name") + << QByteArrayLiteral( + "/userdata/user/safe.mp4.h264_2240x1080") + << QStringLiteral("../safe.mp4.h264_2240x1080") + << false; } -void PrinterProtocolTests::uploadWaitsForDelayedBoundaryAckWithIdleKeepalive() { - QTemporaryFile media; - QVERIFY(media.open()); - const QByteArray contents(17 * 0x40000, '\x6b'); - QCOMPARE(media.write(contents), static_cast(contents.size())); - media.flush(); +void PrinterProtocolTests::mediaPullPathValidation() { + QFETCH(QByteArray, rawPath); + QFETCH(QString, mediaName); + QFETCH(bool, accepted); + QCOMPARE( + PrinterProtocol::validateMediaPullPathForTesting( + rawPath, mediaName), + accepted); + if (accepted) { + return; + } int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + PrinterProtocol protocol(250); + protocol.setMediaPullLimitsForTesting( + 1024, 4, 2000); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + QByteArray received; QString peerError; - quint64 transferTrackId = 0; - std::thread peer([&]() { - constexpr int delayedRequestIndex = 17; - constexpr int requestCount = 19; - for (int index = 0; index < requestCount; ++index) { + std::thread peer; + const bool safeName = + PrinterProtocol::isSafeUploadMediaName(mediaName); + if (safeName) { + peer = std::thread([&]() { panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError, - kPeerTimeoutMs * 2)) { - return; - } - if (!request.has_header() || - request.header().track_id() == 0) { - peerError = QStringLiteral( - "delayed upload request is not tracked"); + if (!readRequest( + sockets[1], &request, &peerError)) { return; } - if (transferTrackId == 0) { - transferTrackId = request.header().track_id(); - } else if (request.header().track_id() != - transferTrackId) { - peerError = QStringLiteral( - "delayed FileTransmit changed track ID"); + const auto response = + mediaCatalogResponse( + request, rawPath, 3); + if (!writeResponse( + sockets[1], response, &peerError)) { return; } + verifyNoPeerPayload( + sockets[1], 100, &peerError); + }); + } - if (index == delayedRequestIndex) { - const int timerFd = ::timerfd_create( - CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK); - if (timerFd < 0) { - peerError = QStringLiteral( - "failed to create delayed ACK timer"); - return; - } - itimerspec delay{}; - delay.it_value.tv_sec = 2; - delay.it_value.tv_nsec = 200 * 1000 * 1000; - if (::timerfd_settime(timerFd, 0, &delay, nullptr) != 0) { - peerError = QStringLiteral( - "failed to arm delayed ACK timer"); - ::close(timerFd); - return; - } - pollfd waits[2]{}; - waits[0].fd = sockets[1]; - waits[0].events = POLLIN; - waits[1].fd = timerFd; - waits[1].events = POLLIN; - const int pollResult = ::poll(waits, 2, 3000); - if (pollResult < 1 || - (waits[0].revents & POLLIN) == 0) { - peerError = QStringLiteral( - "FileTransmit did not send idle Ping before delayed DataStatus"); - ::close(timerFd); - return; - } - panorama::wire::v1::Request keepalive; - if (!readRequest(sockets[1], &keepalive, &peerError) || - !keepalive.has_header() || - keepalive.header().ByteSizeLong() != 0 || - keepalive.body_case() != - panorama::wire::v1::Request::kPing || - keepalive.ping().payload() != "hello?") { - if (peerError.isEmpty()) { - peerError = QStringLiteral( - "FileTransmit idle frame was not UDB Ping"); - } - ::close(timerFd); - return; - } - auto keepaliveResponse = baseResponse(keepalive); - keepaliveResponse.mutable_pong()->set_payload( - keepalive.ping().payload()); - if (!writeResponse(sockets[1], keepaliveResponse, - &peerError)) { - ::close(timerFd); - return; - } - pollfd timerReady{}; - timerReady.fd = timerFd; - timerReady.events = POLLIN; - if (::poll(&timerReady, 1, 1000) != 1 || - (timerReady.revents & POLLIN) == 0) { - peerError = QStringLiteral( - "delayed DataStatus timer did not fire after idle Ping"); - ::close(timerFd); - return; - } - quint64 expirations = 0; - if (::read(timerFd, &expirations, - sizeof(expirations)) != - static_cast(sizeof(expirations))) { - peerError = QStringLiteral( - "failed to consume delayed ACK timer"); - ::close(timerFd); - return; - } - ::close(timerFd); - } + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), mediaName, 3, + [&received]( + qint64, const QByteArray &chunk, QString *) { + received.append(chunk); + return true; + }, + {}, PrinterProtocol::OperationContext{}); + QVERIFY(!result.success); + QVERIFY(!result.error.isEmpty()); + QVERIFY(received.isEmpty()); + if (safeName) { + peer.join(); + QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + } else { + QVERIFY2( + verifyNoPeerPayload( + sockets[1], 0, &peerError), + qPrintable(peerError)); + } + ::close(sockets[1]); +} - auto response = baseResponse(request); - if (index == 0) { - if (request.body_case() != - panorama::wire::v1::Request::kTransferBegin) { - peerError = QStringLiteral( - "missing delayed upload begin"); - return; - } - response.mutable_transfer_begin_status() - ->set_status( - panorama::wire::v1::TransferStatus::OK); - } else if (index == requestCount - 1) { - if (request.body_case() != - panorama::wire::v1::Request::kTransferEnd) { - peerError = QStringLiteral( - "missing delayed upload end"); - return; - } - response.mutable_transfer_end_status() - ->set_status( - panorama::wire::v1::TransferStatus::OK); - } else { - if (request.body_case() != - panorama::wire::v1::Request::kTransferChunk || - request.transfer_chunk().file_data().size() != - static_cast(0x40000)) { - peerError = QStringLiteral( - "invalid delayed upload data request"); - return; - } - response.mutable_transfer_chunk_status() - ->set_status( - panorama::wire::v1::TransferStatus::OK); - } - if (!writeResponse(sockets[1], response, &peerError)) { - return; - } - } - }); +void PrinterProtocolTests:: +mediaPullCatalogPreflightIsStrict_data() { + QTest::addColumn("variant"); + QTest::newRow("read-only") << 0; + QTest::newRow("preset") << 1; + QTest::newRow("duplicate") << 2; + QTest::newRow("size-changed") << 3; + QTest::newRow("missing") << 4; +} - PrinterProtocol protocol(60); - protocol.setFileTransmitDataWriteTimeoutForTesting(500); - protocol.setFileTransmitResponseTimeoutForTesting(3000); - protocol.adoptFileDescriptorForTesting( - sockets[0], QStringLiteral("test-endpoint")); - PrinterProtocol::OperationContext context; - context.maintainKeepalive = true; - QString uploadedName; - QString error; - PrinterProtocol::MutationDetails mutation; - QElapsedTimer uploadTimer; - uploadTimer.start(); - const bool uploadOk = protocol.uploadMedia( - QStringLiteral("test-endpoint"), media.fileName(), - QStringLiteral("delayed.mp4.h264_2240x1080"), - &uploadedName, &error, {}, context, &mutation); +void PrinterProtocolTests:: +mediaPullCatalogPreflightIsStrict() { + QFETCH(int, variant); + const QString mediaName = + QStringLiteral( + "preflight.mp4.h264_2240x1080"); + const QByteArray rawPath = + QByteArrayLiteral( + "/userdata/user/preflight.mp4.h264_2240x1080"); + + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, &peerError)) { + return; + } + panorama::wire::v1::Response response = + baseResponse(request); + if (variant != 4) { + response = mediaCatalogResponse( + request, rawPath, + variant == 3 ? 3 : 4, + variant == 0, variant == 1); + } else { + response.mutable_media_catalog(); + } + if (variant == 2) { + auto *duplicate = + response.mutable_media_catalog() + ->add_media_file_list(); + duplicate->set_file_path( + rawPath.constData(), + static_cast(rawPath.size())); + duplicate->set_file_size(4); + } + if (!writeResponse( + sockets[1], response, &peerError)) { + return; + } + verifyNoPeerPayload( + sockets[1], 100, &peerError); + }); + + PrinterProtocol protocol(250); + protocol.setMediaPullLimitsForTesting( + 1024, 4, 2000); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + QByteArray received; + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), mediaName, 4, + [&received]( + qint64, const QByteArray &chunk, QString *) { + received.append(chunk); + return true; + }, + {}, PrinterProtocol::OperationContext{}); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); - QVERIFY2(uploadOk, qPrintable(error)); - QVERIFY(uploadTimer.elapsed() >= 2000); - QVERIFY(uploadTimer.elapsed() < 8000); - QVERIFY(transferTrackId != 0); - QCOMPARE(mutation.outcome, - PrinterProtocol::MutationOutcome::Succeeded); - QCOMPARE(mutation.bytesSent, - static_cast(contents.size())); - QCOMPARE(uploadedName, - QStringLiteral("delayed.mp4.h264_2240x1080")); + QVERIFY(!result.success); + QVERIFY(!result.error.isEmpty()); + QVERIFY(received.isEmpty()); + QCOMPARE(result.bytesDecoded, qint64(0)); } -void PrinterProtocolTests::uploadEndTimeoutIsFinalizationUnknown() { - QTemporaryFile media; - QVERIFY(media.open()); - const QByteArray contents(4096, '\x71'); - QCOMPARE(media.write(contents), - static_cast(contents.size())); - media.flush(); +void PrinterProtocolTests:: +mediaPullRejectsInvalidResponse_data() { + QTest::addColumn("variant"); + QTest::newRow("file-error") << 0; + QTest::newRow("path-echo") << 1; + QTest::newRow("session-echo") << 2; + QTest::newRow("offset-echo") << 3; + QTest::newRow("file-size-echo") << 4; + QTest::newRow("zero-progress") << 5; + QTest::newRow("bytes-after-size") << 6; + QTest::newRow("unsupported-version") << 7; + QTest::newRow("wrong-body") << 8; + QTest::newRow("outer-error") << 9; + QTest::newRow("size-change") << 10; + QTest::newRow("wrong-track") << 11; +} + +void PrinterProtocolTests:: +mediaPullRejectsInvalidResponse() { + QFETCH(int, variant); + const QString mediaName = + QStringLiteral( + "invalid.mp4.h264_2240x1080"); + const QByteArray rawPath = + QByteArrayLiteral( + "/userdata/user/invalid.mp4.h264_2240x1080"); + const QByteArray decoded = + QByteArray::fromHex("01020304"); + const QByteArray raw = + PrinterProtocol::applyMediaPullXorForTesting( + decoded, 0); int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), - qPrintable(socketError)); + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); QString peerError; std::thread peer([&]() { - const QList expected = { - panorama::wire::v1::Request::kTransferBegin, - panorama::wire::v1::Request::kTransferChunk, - panorama::wire::v1::Request::kTransferEnd - }; - quint64 transferTrackId = 0; - for (int index = 0; index < expected.size(); ++index) { - panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != expected.at(index)) { - if (peerError.isEmpty()) { - peerError = QStringLiteral( - "unexpected request before End timeout"); - } - return; - } - if (!request.has_header() || - request.header().version() != 0 || - request.header().track_id() == 0) { + panorama::wire::v1::Request catalogRequest; + if (!readRequest( + sockets[1], &catalogRequest, &peerError)) { + return; + } + if (!writeResponse( + sockets[1], + mediaCatalogResponse( + catalogRequest, rawPath, + static_cast(decoded.size())), + &peerError)) { + return; + } + + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, &peerError) || + request.body_case() != + panorama::wire::v1::Request:: + kMediaReadChunk) { + if (peerError.isEmpty()) { peerError = QStringLiteral( - "FileTransmit request does not match UDB header"); - return; + "missing media read request"); } - if (transferTrackId == 0) { - transferTrackId = request.header().track_id(); - } else if (request.header().track_id() != transferTrackId) { - peerError = QStringLiteral( - "FileTransmit changed track before End timeout"); + return; + } + const auto writeChunk = + [&](const panorama::wire::v1::Request &chunkRequest, + quint64 offset, quint64 size, + const QByteArray &data) { + auto response = + baseResponse(chunkRequest); + auto *chunk = + response.mutable_media_read_chunk(); + chunk->set_remote_path( + rawPath.constData(), + static_cast(rawPath.size())); + chunk->set_session_id( + chunkRequest.media_read_chunk() + .session_id()); + chunk->set_offset(offset); + chunk->set_file_size(size); + chunk->set_data( + data.constData(), + static_cast(data.size())); + return response; + }; + + if (variant == 10) { + auto first = writeChunk( + request, 0, decoded.size(), + raw.left(2)); + if (!writeResponse( + sockets[1], first, &peerError)) { return; } - if (index == expected.size() - 1) { - continue; - } - auto response = baseResponse(request); - if (index == 0) { - response.mutable_transfer_begin_status() - ->set_status( - panorama::wire::v1::TransferStatus::OK); - } else { - response.mutable_transfer_chunk_status() - ->set_status( - panorama::wire::v1::TransferStatus::OK); - } - if (!writeResponse(sockets[1], response, &peerError)) { + panorama::wire::v1::Request second; + if (!readRequest( + sockets[1], &second, &peerError)) { return; } - } - - pollfd closeDescriptor{}; - closeDescriptor.fd = sockets[1]; - closeDescriptor.events = POLLIN; - if (::poll(&closeDescriptor, 1, kPeerTimeoutMs) <= 0) { - peerError = QStringLiteral( - "End timeout did not close the transport epoch"); + auto changed = writeChunk( + second, 2, decoded.size() + 1, + raw.mid(2)); + writeResponse( + sockets[1], changed, &peerError); return; } - char byte = 0; - if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) != 0) { - peerError = QStringLiteral( - "End timeout transport did not close cleanly"); + + auto response = writeChunk( + request, 0, decoded.size(), raw); + auto *chunk = + response.mutable_media_read_chunk(); + switch (variant) { + case 0: + chunk->set_status( + panorama::wire::v1:: + MediaReadChunkResponse::FILE_ERROR); + break; + case 1: + chunk->set_remote_path( + "/userdata/user/other.mp4.h264_2240x1080"); + break; + case 2: + chunk->set_session_id( + request.media_read_chunk() + .session_id() + + 1); + break; + case 3: + chunk->set_offset(1); + break; + case 4: + chunk->set_file_size(decoded.size() + 1); + break; + case 5: + chunk->clear_data(); + break; + case 6: + chunk->set_data( + (raw + QByteArrayLiteral("x")) + .constData(), + static_cast(raw.size() + 1)); + break; + case 7: + response.mutable_header()->set_version(2); + break; + case 8: + response.clear_media_read_chunk(); + response.mutable_acknowledgement(); + break; + case 9: + response.mutable_error()->set_code( + panorama::wire::v1:: + ProtocolError::FAILURE); + response.mutable_error()->set_why( + "read rejected"); + break; + case 11: + response.mutable_header()->set_track_id( + request.header().track_id() + 1); + break; + default: + break; } + writeResponse( + sockets[1], response, &peerError); }); - PrinterProtocol protocol(80); - protocol.setFileTransmitResponseTimeoutForTesting(120); + PrinterProtocol protocol(250); + protocol.setMediaPullLimitsForTesting( + 1024, 4, 3000); protocol.adoptFileDescriptorForTesting( sockets[0], QStringLiteral("test-endpoint")); - QString error; - QString uploadedName; - PrinterProtocol::MutationDetails mutation; - QVERIFY(!protocol.uploadMedia( - QStringLiteral("test-endpoint"), media.fileName(), - QStringLiteral("end-timeout.mp4.h264_2240x1080"), - &uploadedName, &error, {}, - PrinterProtocol::OperationContext{}, &mutation)); - QVERIFY(error.contains(QStringLiteral("timed out"), - Qt::CaseInsensitive)); - QCOMPARE(mutation.outcome, - PrinterProtocol::MutationOutcome::FinalizationUnknown); - QCOMPARE(mutation.stage, QStringLiteral("Ending")); - QCOMPARE(mutation.bytesSent, - static_cast(contents.size())); - QCOMPARE(mutation.totalBytes, - static_cast(contents.size())); + QByteArray received; + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), mediaName, + decoded.size(), + [&received]( + qint64, const QByteArray &chunk, QString *) { + received.append(chunk); + return true; + }, + {}, PrinterProtocol::OperationContext{}); peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY(!result.success); + QVERIFY(!result.error.isEmpty()); + QCOMPARE( + received.size(), variant == 10 ? 2 : 0); + QCOMPARE( + result.bytesDecoded, + variant == 10 ? qint64(2) : qint64(0)); } -void PrinterProtocolTests::uploadFailureClosesSession_data() { - QTest::addColumn("failureStage"); - QTest::newRow("begin") << 0; - QTest::newRow("data") << 1; - QTest::newRow("end") << 2; +void PrinterProtocolTests:: +mediaPullCancellationIsBounded() { + { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + PrinterProtocol protocol(100); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + PrinterProtocol::OperationContext context; + context.isCancelled = []() { return true; }; + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), + QStringLiteral( + "cancel.mp4.h264_2240x1080"), + 4, + [](qint64, const QByteArray &, QString *) { + return true; + }, + {}, context); + QVERIFY(!result.success); + QVERIFY(result.cancelled); + QCOMPARE(result.bytesDecoded, qint64(0)); + QString peerError; + QVERIFY2( + verifyNoPeerPayload( + sockets[1], 0, &peerError), + qPrintable(peerError)); + ::close(sockets[1]); + } + + { + const QString mediaName = + QStringLiteral( + "cancel.mp4.h264_2240x1080"); + const QByteArray rawPath = + QByteArrayLiteral( + "/userdata/user/cancel.mp4.h264_2240x1080"); + const QByteArray decoded = + QByteArray::fromHex("01020304"); + const QByteArray raw = + PrinterProtocol::applyMediaPullXorForTesting( + decoded, 0); + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + std::atomic_bool cancelled{false}; + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request catalog; + if (!readRequest( + sockets[1], &catalog, + &peerError) || + !writeResponse( + sockets[1], + mediaCatalogResponse( + catalog, rawPath, + static_cast( + decoded.size())), + &peerError)) { + return; + } + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, + &peerError)) { + return; + } + auto response = baseResponse(request); + auto *chunk = + response.mutable_media_read_chunk(); + chunk->set_remote_path( + rawPath.constData(), + static_cast(rawPath.size())); + chunk->set_session_id( + request.media_read_chunk() + .session_id()); + chunk->set_offset(0); + chunk->set_file_size(decoded.size()); + chunk->set_data( + raw.constData(), 2); + if (!writeResponse( + sockets[1], response, + &peerError)) { + return; + } + verifyNoPeerPayload( + sockets[1], 100, &peerError); + }); + + PrinterProtocol protocol(250); + protocol.setMediaPullLimitsForTesting( + 1024, 4, 3000); + protocol.adoptFileDescriptorForTesting( + sockets[0], + QStringLiteral("test-endpoint")); + QByteArray received; + PrinterProtocol::OperationContext context; + context.isCancelled = + [&cancelled]() { + return cancelled.load(); + }; + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), + mediaName, decoded.size(), + [&received]( + qint64, const QByteArray &chunk, + QString *) { + received.append(chunk); + return true; + }, + [&cancelled](qint64, qint64) { + cancelled.store(true); + }, + context); + peer.join(); + ::close(sockets[1]); + QVERIFY2( + peerError.isEmpty(), + qPrintable(peerError)); + QVERIFY(!result.success); + QVERIFY(result.cancelled); + QCOMPARE(received, decoded.left(2)); + QCOMPARE(result.bytesDecoded, qint64(2)); + QCOMPARE(result.chunkCount, 1); + } } -void PrinterProtocolTests::uploadFailureClosesSession() { - QFETCH(int, failureStage); - QTemporaryFile media; - QVERIFY(media.open()); - const QByteArray contents(4096, '\x33'); - QCOMPARE(media.write(contents), static_cast(contents.size())); - media.flush(); +void PrinterProtocolTests:: +mediaPullChunkAndDeadlineLimits() { + const QString mediaName = + QStringLiteral( + "limits.mp4.h264_2240x1080"); + const QByteArray rawPath = + QByteArrayLiteral( + "/userdata/user/limits.mp4.h264_2240x1080"); + const QByteArray decoded = + QByteArray::fromHex("01020304"); + const QByteArray raw = + PrinterProtocol::applyMediaPullXorForTesting( + decoded, 0); - int sockets[2] = {-1, -1}; - QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - QString peerError; - std::thread peer([&]() { - const QList expected = { - panorama::wire::v1::Request::kTransferBegin, - panorama::wire::v1::Request::kTransferChunk, - panorama::wire::v1::Request::kTransferEnd - }; - for (int stage = 0; stage <= failureStage; ++stage) { + { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request catalog; + if (!readRequest( + sockets[1], &catalog, + &peerError) || + !writeResponse( + sockets[1], + mediaCatalogResponse( + catalog, rawPath, + static_cast( + decoded.size())), + &peerError)) { + return; + } panorama::wire::v1::Request request; - if (!readRequest(sockets[1], &request, &peerError) || - request.body_case() != expected.at(stage)) { - peerError = QStringLiteral("unexpected request at failing stage %1").arg(stage); + if (!readRequest( + sockets[1], &request, + &peerError)) { return; } auto response = baseResponse(request); - const auto status = stage == failureStage - ? panorama::wire::v1::TransferStatus::FILE_ERROR - : panorama::wire::v1::TransferStatus::OK; - if (stage == 0) { - response.mutable_transfer_begin_status()->set_status(status); - } else if (stage == 1) { - response.mutable_transfer_chunk_status()->set_status(status); - } else { - response.mutable_transfer_end_status()->set_status(status); + auto *chunk = + response.mutable_media_read_chunk(); + chunk->set_remote_path( + rawPath.constData(), + static_cast(rawPath.size())); + chunk->set_session_id( + request.media_read_chunk() + .session_id()); + chunk->set_offset(0); + chunk->set_file_size(decoded.size()); + chunk->set_data( + raw.constData(), 2); + if (!writeResponse( + sockets[1], response, + &peerError)) { + return; } - if (!writeResponse(sockets[1], response, &peerError)) { + verifyNoPeerPayload( + sockets[1], 100, &peerError); + }); + + PrinterProtocol protocol(250); + protocol.setMediaPullLimitsForTesting( + 1024, 1, 3000); + protocol.adoptFileDescriptorForTesting( + sockets[0], + QStringLiteral("test-endpoint")); + QByteArray received; + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), + mediaName, decoded.size(), + [&received]( + qint64, const QByteArray &chunk, + QString *) { + received.append(chunk); + return true; + }, + {}, PrinterProtocol::OperationContext{}); + peer.join(); + ::close(sockets[1]); + QVERIFY2( + peerError.isEmpty(), + qPrintable(peerError)); + QVERIFY(!result.success); + QVERIFY(result.error.contains( + QStringLiteral("chunk"), + Qt::CaseInsensitive)); + QCOMPARE(received, decoded.left(2)); + QCOMPARE(result.chunkCount, 1); + } + + { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request catalog; + if (!readRequest( + sockets[1], &catalog, + &peerError)) { return; } - } + ::poll(nullptr, 0, 5); + if (!writeResponse( + sockets[1], + mediaCatalogResponse( + catalog, rawPath, + static_cast( + decoded.size())), + &peerError)) { + return; + } + verifyNoPeerPayload( + sockets[1], 100, &peerError); + }); - pollfd closeDescriptor{}; - closeDescriptor.fd = sockets[1]; - closeDescriptor.events = POLLIN; - if (::poll(&closeDescriptor, 1, kPeerTimeoutMs) <= 0) { - peerError = QStringLiteral( - "failed upload did not close its transport epoch"); - return; - } - char byte = 0; - if (::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT) != 0) { - peerError = QStringLiteral( - "failed upload transport did not close cleanly"); - } - }); + PrinterProtocol protocol(250); + protocol.setMediaPullLimitsForTesting( + 1024, 4, 1); + protocol.adoptFileDescriptorForTesting( + sockets[0], + QStringLiteral("test-endpoint")); + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), + mediaName, decoded.size(), + [](qint64, const QByteArray &, QString *) { + return true; + }, + {}, PrinterProtocol::OperationContext{}); + peer.join(); + ::close(sockets[1]); + QVERIFY2( + peerError.isEmpty(), + qPrintable(peerError)); + QVERIFY(!result.success); + QVERIFY(result.error.contains( + QStringLiteral("deadline"), + Qt::CaseInsensitive)); + QCOMPARE(result.bytesDecoded, qint64(0)); + } + + { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + QString peerError; + std::thread peer([&]() { + panorama::wire::v1::Request catalog; + if (!readRequest( + sockets[1], &catalog, + &peerError) || + !writeResponse( + sockets[1], + mediaCatalogResponse( + catalog, rawPath, + static_cast( + decoded.size())), + &peerError)) { + return; + } + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, + &peerError)) { + return; + } + auto response = baseResponse(request); + auto *chunk = + response.mutable_media_read_chunk(); + chunk->set_remote_path( + rawPath.constData(), + static_cast(rawPath.size())); + chunk->set_session_id( + request.media_read_chunk() + .session_id()); + chunk->set_offset(0); + chunk->set_file_size(decoded.size()); + chunk->set_data( + raw.constData(), + static_cast(raw.size())); + writeResponse( + sockets[1], response, &peerError); + }); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - QString error; - QString uploadedName; - const PrinterProtocol::OperationContext context; - PrinterProtocol::MutationDetails mutation; - QVERIFY(!protocol.uploadMedia( - QStringLiteral("test-endpoint"), media.fileName(), - QStringLiteral("failure.mp4.h264_2240x1080"), - &uploadedName, &error, {}, context, &mutation)); - QVERIFY(error.contains(QStringLiteral("file error"), Qt::CaseInsensitive)); - QCOMPARE(mutation.outcome, - failureStage == 0 - ? PrinterProtocol::MutationOutcome::Rejected - : PrinterProtocol::MutationOutcome::PartialOrUnknown); - QCOMPARE(mutation.stage, - failureStage == 0 - ? QStringLiteral("Beginning") - : failureStage == 1 - ? QStringLiteral("Transferring") - : QStringLiteral("Ending")); - QCOMPARE(mutation.bytesSent, - failureStage == 2 ? static_cast(contents.size()) : 0); - QCOMPARE(mutation.totalBytes, static_cast(contents.size())); + PrinterProtocol protocol(250); + protocol.setMediaPullLimitsForTesting( + 1024, 4, 3000); + protocol.adoptFileDescriptorForTesting( + sockets[0], + QStringLiteral("test-endpoint")); + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), + mediaName, decoded.size(), + [](qint64, const QByteArray &, + QString *errorMessage) { + if (errorMessage) { + *errorMessage = + QStringLiteral( + "synthetic sink failure"); + } + return false; + }, + {}, PrinterProtocol::OperationContext{}); + peer.join(); + ::close(sockets[1]); + QVERIFY2( + peerError.isEmpty(), + qPrintable(peerError)); + QVERIFY(!result.success); + QCOMPARE( + result.error, + QStringLiteral( + "synthetic sink failure")); + QCOMPARE(result.bytesDecoded, qint64(0)); + QCOMPARE(result.chunkCount, 0); + QVERIFY(result.rawSha256.isEmpty()); + QVERIFY(result.decodedSha256.isEmpty()); + } - peer.join(); - ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + { + int sockets[2] = {-1, -1}; + QString socketError; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + PrinterProtocol protocol(100); + protocol.setMediaPullLimitsForTesting( + 3, 4, 1000); + protocol.adoptFileDescriptorForTesting( + sockets[0], + QStringLiteral("test-endpoint")); + const auto result = protocol.pullUserMedia( + QStringLiteral("test-endpoint"), + mediaName, decoded.size(), + [](qint64, const QByteArray &, QString *) { + return true; + }, + {}, PrinterProtocol::OperationContext{}); + QVERIFY(!result.success); + QCOMPARE(result.bytesDecoded, qint64(0)); + QString peerError; + QVERIFY2( + verifyNoPeerPayload( + sockets[1], 0, &peerError), + qPrintable(peerError)); + ::close(sockets[1]); + } } -void PrinterProtocolTests::uploadCancellationStopsBeforeNextChunk() { - QTemporaryFile media; - QVERIFY(media.open()); - const QByteArray contents(600000, '\x44'); - QCOMPARE(media.write(contents), static_cast(contents.size())); - media.flush(); - +void PrinterProtocolTests:: +mediaReferencePreflightReadsAllSlots() { + const QString mediaName = + QStringLiteral( + "reference.mp4.h264_2240x1080"); + const QString replacementName = + QStringLiteral( + "replacement.mp4.h264_2240x1080"); + const QByteArray rawPath = + QByteArrayLiteral( + "/userdata/user/reference.mp4.h264_2240x1080"); int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - std::atomic_bool cancelled{false}; + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + QString peerError; + quint64 catalogTrack = 0; + quint64 configTrack = 0; std::thread peer([&]() { - panorama::wire::v1::Request beginRequest; - if (!readRequest(sockets[1], &beginRequest, &peerError) || - beginRequest.body_case() != - panorama::wire::v1::Request::kTransferBegin) { - peerError = QStringLiteral("missing upload begin before cancellation"); - return; - } - auto beginResponse = baseResponse(beginRequest); - beginResponse.mutable_transfer_begin_status()->set_status( - panorama::wire::v1::TransferStatus::OK); - if (!writeResponse(sockets[1], beginResponse, &peerError)) { - return; - } - - panorama::wire::v1::Request dataRequest; - if (!readRequest(sockets[1], &dataRequest, &peerError) || - dataRequest.body_case() != - panorama::wire::v1::Request::kTransferChunk) { - peerError = QStringLiteral("missing first data chunk before cancellation"); + panorama::wire::v1::Request catalogRequest; + if (!readRequest( + sockets[1], &catalogRequest, &peerError) || + catalogRequest.body_case() != + panorama::wire::v1::Request:: + kMediaCatalogQuery || + catalogRequest.header().version() != 1 || + catalogRequest.header().track_id() == 0) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "reference preflight did not start with FileList"); + } return; } - auto dataResponse = baseResponse(dataRequest); - dataResponse.mutable_transfer_chunk_status()->set_status( - panorama::wire::v1::TransferStatus::OK); - if (!writeResponse(sockets[1], dataResponse, &peerError)) { + catalogTrack = + catalogRequest.header().track_id(); + auto catalogResponse = mediaCatalogResponse( + catalogRequest, rawPath, 123); + auto *replacement = + catalogResponse.mutable_media_catalog() + ->add_media_file_list(); + replacement->set_file_path( + "/userdata/user/replacement.mp4"); + replacement->set_file_ext( + ".h264_2240x1080"); + replacement->set_file_size(456); + if (!writeResponse( + sockets[1], catalogResponse, + &peerError)) { return; } - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); - if (pollResult <= 0) { - peerError = QStringLiteral("upload endpoint did not close after cancellation"); + panorama::wire::v1::Request configRequest; + if (!readRequest( + sockets[1], &configRequest, &peerError) || + configRequest.body_case() != + panorama::wire::v1::Request:: + kUserConfigurationQuery || + configRequest.header().version() != 1 || + configRequest.header().track_id() == 0) { + if (peerError.isEmpty()) { + peerError = QStringLiteral( + "reference preflight did not read UserConfig"); + } return; } - char byte = 0; - const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); - if (received > 0) { - peerError = QStringLiteral("another data chunk was sent after cancellation"); - } else if (received < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { - peerError = QStringLiteral("failed to inspect cancelled upload endpoint"); - } + configTrack = + configRequest.header().track_id(); + auto response = baseResponse(configRequest); + auto *configuration = + response.mutable_user_configuration(); + configuration->mutable_poweron_config() + ->set_media_file( + "/userdata/user/reference.mp4.h264_2240x1080"); + configuration->mutable_standby_config() + ->set_media_file( + "standby.mp4.h264_2240x1080"); + auto *work = + configuration->mutable_work_config(); + work->set_single_mode_media_file( + "reference.mp4.h264_2240x1080"); + work->set_dual_mode_left_media_file( + "/userdata/user/reference.mp4.h264_2240x1080"); + work->set_dual_mode_right_media_file( + "/userdata/user/right.mp4.h264_2240x1080"); + work->set_kaleidoscope_media_file( + "C:\\media\\kaleido.mp4.h264_2240x1080"); + auto *filter = + configuration->mutable_filter_config(); + filter->set_filter_file( + "/userdata/user/reference.mp4.h264_2240x1080"); + filter->set_dual_mode_left_file(""); + filter->set_dual_mode_right_file( + "/userdata/user/filter-right.mp4.h264_2240x1080"); + writeResponse( + sockets[1], response, &peerError); }); PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - PrinterProtocol::OperationContext context; - context.isCancelled = [&cancelled]() { - return cancelled.load(std::memory_order_acquire); - }; - QString error; - QString uploadedName; - PrinterProtocol::MutationDetails mutation; - QVERIFY(!protocol.uploadMedia( - QStringLiteral("test-endpoint"), media.fileName(), - QStringLiteral("cancel.mp4.h264_2240x1080"), - &uploadedName, &error, - [&cancelled](qint64 sent, qint64) { - if (sent > 0) { - cancelled.store(true, std::memory_order_release); - } - }, - context, &mutation)); - QVERIFY(error.contains(QStringLiteral("cancel"), Qt::CaseInsensitive)); - QCOMPARE(mutation.outcome, - PrinterProtocol::MutationOutcome::PartialOrUnknown); - QCOMPARE(mutation.stage, QStringLiteral("Transferring")); - QCOMPARE(mutation.bytesSent, qint64(0x40000)); - QCOMPARE(mutation.totalBytes, static_cast(contents.size())); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); + const auto result = + protocol.readUserMediaReferences( + QStringLiteral("test-endpoint"), + mediaName, + 123, + replacementName, + 456, + PrinterProtocol::OperationContext{}); + peer.join(); ::close(sockets[1]); QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); + QVERIFY2(result.success, qPrintable(result.error)); + QVERIFY(result.originalIdentityVerified); + QVERIFY(result.replacementIdentityVerified); + QCOMPARE(result.media.name, mediaName); + QCOMPARE(result.media.size, quint32(123)); + QVERIFY(!result.media.readOnly); + QCOMPARE( + result.media.source, + PrinterProtocol::MediaSource::User); + QCOMPARE( + result.references, + QStringList({ + mediaName, + QStringLiteral( + "standby.mp4.h264_2240x1080"), + mediaName, + mediaName, + QStringLiteral( + "right.mp4.h264_2240x1080"), + QStringLiteral( + "kaleido.mp4.h264_2240x1080"), + mediaName, + QString(), + QStringLiteral( + "filter-right.mp4.h264_2240x1080")})); + QCOMPARE( + result.referencingSlots, + QStringList({ + QStringLiteral("PowerOn"), + QStringLiteral("Single"), + QStringLiteral("DualLeft"), + QStringLiteral("FilterSingle")})); + QVERIFY(catalogTrack != 0); + QVERIFY(configTrack != 0); + QVERIFY(catalogTrack != configTrack); } -void PrinterProtocolTests::uploadSourceMutationIsRejected_data() { - QTest::addColumn("growFile"); - QTest::newRow("grow") << true; - QTest::newRow("shrink") << false; +void PrinterProtocolTests:: +mediaReferencePreflightRejectsUnsafeCatalog_data() { + QTest::addColumn("variant"); + QTest::newRow("read-only") << 0; + QTest::newRow("preset") << 1; + QTest::newRow("duplicate") << 2; + QTest::newRow("missing") << 3; + QTest::newRow("unsafe-name") << 4; + QTest::newRow("size-changed") << 5; + QTest::newRow("replacement-missing") << 6; } -void PrinterProtocolTests::uploadSourceMutationIsRejected() { - QFETCH(bool, growFile); - QTemporaryFile media; - QVERIFY(media.open()); - const QByteArray contents(300000, '\x55'); - QCOMPARE(media.write(contents), static_cast(contents.size())); - media.flush(); - +void PrinterProtocolTests:: +mediaReferencePreflightRejectsUnsafeCatalog() { + QFETCH(int, variant); + const QString safeName = + QStringLiteral( + "reference.mp4.h264_2240x1080"); + const QString requestedName = + variant == 4 + ? QStringLiteral( + "../reference.mp4.h264_2240x1080") + : safeName; + const QByteArray rawPath = + QByteArrayLiteral( + "/userdata/user/reference.mp4.h264_2240x1080"); int sockets[2] = {-1, -1}; QString socketError; - QVERIFY2(createSocketPair(sockets, &socketError), qPrintable(socketError)); - QString peerError; - std::thread peer([&]() { - panorama::wire::v1::Request beginRequest; - if (!readRequest(sockets[1], &beginRequest, &peerError)) { - return; - } - auto beginResponse = baseResponse(beginRequest); - beginResponse.mutable_transfer_begin_status()->set_status( - panorama::wire::v1::TransferStatus::OK); - if (!writeResponse(sockets[1], beginResponse, &peerError)) { - return; - } - - panorama::wire::v1::Request dataRequest; - if (!readRequest(sockets[1], &dataRequest, &peerError) || - dataRequest.transfer_chunk().file_data().size() != 0x40000) { - peerError = QStringLiteral("unexpected first chunk before source mutation"); - return; - } - auto dataResponse = baseResponse(dataRequest); - dataResponse.mutable_transfer_chunk_status()->set_status( - panorama::wire::v1::TransferStatus::OK); - if (!writeResponse(sockets[1], dataResponse, &peerError)) { - return; - } - - pollfd descriptor{}; - descriptor.fd = sockets[1]; - descriptor.events = POLLIN; - const int pollResult = ::poll(&descriptor, 1, kPeerTimeoutMs); - if (pollResult <= 0) { - peerError = QStringLiteral("upload endpoint remained open after source mutation"); - return; - } - char byte = 0; - const ssize_t received = ::recv(sockets[1], &byte, sizeof(byte), MSG_DONTWAIT); - if (received > 0) { - peerError = QStringLiteral("upload sent bytes beyond the stable declared source"); - } - }); + QVERIFY2( + createSocketPair(sockets, &socketError), + qPrintable(socketError)); + PrinterProtocol protocol(250); + protocol.adoptFileDescriptorForTesting( + sockets[0], QStringLiteral("test-endpoint")); - PrinterProtocol protocol(500); - protocol.adoptFileDescriptorForTesting(sockets[0], QStringLiteral("test-endpoint")); - bool mutated = false; - QString error; - QString uploadedName; - const PrinterProtocol::OperationContext context; - PrinterProtocol::MutationDetails mutation; - QVERIFY(!protocol.uploadMedia( - QStringLiteral("test-endpoint"), media.fileName(), - QStringLiteral("mutation.mp4.h264_2240x1080"), - &uploadedName, &error, - [&](qint64 sent, qint64) { - if (mutated || sent <= 0) { + QString peerError; + std::thread peer; + if (variant != 4) { + peer = std::thread([&]() { + panorama::wire::v1::Request request; + if (!readRequest( + sockets[1], &request, + &peerError)) { return; } - QFile mutation(media.fileName()); - if (growFile) { - if (mutation.open(QIODevice::WriteOnly | QIODevice::Append)) { - mutated = mutation.write(QByteArray(100, '\x66')) == 100 && - mutation.flush(); - } - } else if (mutation.open(QIODevice::ReadWrite)) { - mutated = mutation.resize(1024) && mutation.flush(); + panorama::wire::v1::Response response = + baseResponse(request); + if (variant != 3) { + response = mediaCatalogResponse( + request, rawPath, 123, + variant == 0, variant == 1); + } else { + response.mutable_media_catalog(); } - }, - context, &mutation)); - QVERIFY(mutated); - QVERIFY(error.contains(QStringLiteral("changed"), Qt::CaseInsensitive)); - QCOMPARE(mutation.outcome, - PrinterProtocol::MutationOutcome::PartialOrUnknown); - QCOMPARE(mutation.stage, QStringLiteral("Transferring")); - QCOMPARE(mutation.bytesSent, qint64(0x40000)); - QCOMPARE(mutation.totalBytes, static_cast(contents.size())); - peer.join(); + if (variant == 2) { + auto *duplicate = + response.mutable_media_catalog() + ->add_media_file_list(); + duplicate->set_file_path( + rawPath.constData(), + static_cast( + rawPath.size())); + duplicate->set_file_size(123); + } + if (!writeResponse( + sockets[1], response, + &peerError)) { + return; + } + verifyNoPeerPayload( + sockets[1], 100, &peerError); + }); + } + + const auto result = + protocol.readUserMediaReferences( + QStringLiteral("test-endpoint"), + requestedName, + variant == 5 ? 456 : 123, + variant == 6 + ? QStringLiteral( + "replacement.mp4.h264_2240x1080") + : QString(), + variant == 6 ? 456 : 0, + PrinterProtocol::OperationContext{}); + QVERIFY(!result.success); + QVERIFY(!result.error.isEmpty()); + QVERIFY(result.references.isEmpty()); + QVERIFY(result.referencingSlots.isEmpty()); + if (variant == 6) { + QVERIFY(result.originalIdentityVerified); + QVERIFY(!result.replacementIdentityVerified); + } + if (variant != 4) { + peer.join(); + QVERIFY2( + peerError.isEmpty(), + qPrintable(peerError)); + } else { + QVERIFY2( + verifyNoPeerPayload( + sockets[1], 0, &peerError), + qPrintable(peerError)); + } ::close(sockets[1]); - QVERIFY2(peerError.isEmpty(), qPrintable(peerError)); } int main(int argc, char **argv) { if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM")) { qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); } - QApplication application(argc, argv); + QCoreApplication application(argc, argv); PrinterProtocolTests tests; return QTest::qExec(&tests, argc, argv); } diff --git a/tests/printerprotocol_tests.pro b/tests/printerprotocol_tests.pro index f21c18d..818a77e 100644 --- a/tests/printerprotocol_tests.pro +++ b/tests/printerprotocol_tests.pro @@ -1,4 +1,4 @@ -QT += core dbus gui widgets testlib +QT += core dbus gui testlib CONFIG += c++17 console testcase link_pkgconfig CONFIG -= app_bundle @@ -57,19 +57,25 @@ HEADERS += \ $$PWD/../src/printerprotocol.h \ $$PWD/../src/runtimebridge.h \ $$PWD/../src/devicemanager.h \ + $$PWD/../src/firmwarebridge.h \ + $$PWD/../src/firmwarerecoveryjournal.h \ + $$PWD/../src/firmwareupdater.h \ $$PWD/../src/systemmonitor.h \ - $$PWD/../src/displaypage.h \ - $$PWD/../src/panoramapage.h \ - $$PWD/../src/splitconfig.h + $$PWD/../src/mediatransform.h \ + $$PWD/../src/replacejournal.h \ + $$PWD/../src/runtimecontract.h SOURCES += \ printerprotocol_tests.cpp \ $$PWD/../src/printerprotocol.cpp \ $$PWD/../src/runtimebridge.cpp \ $$PWD/../src/devicemanager.cpp \ + $$PWD/../src/firmwarebridge.cpp \ + $$PWD/../src/firmwarerecoveryjournal.cpp \ + $$PWD/../src/firmwareupdater.cpp \ $$PWD/../src/systemmonitor.cpp \ - $$PWD/../src/displaypage.cpp \ - $$PWD/../src/panoramapage.cpp \ - $$PWD/../src/splitconfig.cpp \ + $$PWD/../src/mediatransform.cpp \ + $$PWD/../src/replacejournal.cpp \ + $$PWD/../src/runtimecontract.cpp \ $$PWD/../src/core/protocol.cpp \ $$PWD/../src/core/device.cpp \ $$PWD/../src/core/adb.cpp \ diff --git a/tests/quick/linuxtraycontroller_tests.cpp b/tests/quick/linuxtraycontroller_tests.cpp new file mode 100644 index 0000000..800c512 --- /dev/null +++ b/tests/quick/linuxtraycontroller_tests.cpp @@ -0,0 +1,542 @@ +#include "linuxtraycontroller.h" +#include "windowchromecontroller.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const QString kWatcherService = + QStringLiteral("org.kde.StatusNotifierWatcher"); +const QString kWatcherPath = + QStringLiteral("/StatusNotifierWatcher"); +const QString kWatcherInterface = + QStringLiteral("org.kde.StatusNotifierWatcher"); +const QString kNotificationsService = + QStringLiteral("org.freedesktop.Notifications"); +const QString kNotificationsPath = + QStringLiteral("/org/freedesktop/Notifications"); +const QString kQuitProbeArgument = + QStringLiteral("--internal-tray-quit-probe"); + +class TrayCloseFilter final : public QObject { +public: + explicit TrayCloseFilter( + WindowChromeController *windowChrome, + QObject *parent = nullptr) + : QObject(parent), windowChrome_(windowChrome) {} + +protected: + bool eventFilter( + QObject *watched, QEvent *event) override { + // Mirror Main.qml: reject a normal close and hide while tray support + // is available. Explicit tray Quit must still end the event loop. + if (event->type() == QEvent::Close && + windowChrome_->handleCloseRequest()) { + event->ignore(); + return true; + } + return QObject::eventFilter(watched, event); + } + +private: + WindowChromeController *windowChrome_ = nullptr; +}; + +class MockStatusNotifierWatcher final : public QObject { + Q_OBJECT + Q_CLASSINFO( + "D-Bus Interface", + "org.kde.StatusNotifierWatcher") + Q_PROPERTY(bool IsStatusNotifierHostRegistered + READ hostRegistered) + +public: + bool hostRegistered() const { + return hostRegistered_; + } + int registrationCount() const { + return registrationCount_; + } + QString registeredItem() const { + return registeredItem_; + } + + void setHostRegistered(bool registered) { + if (hostRegistered_ == registered) { + return; + } + hostRegistered_ = registered; + if (hostRegistered_) { + emit StatusNotifierHostRegistered(); + } else { + emit StatusNotifierHostUnregistered(); + } + } + +public slots: + void RegisterStatusNotifierItem( + const QString &serviceOrPath) { + ++registrationCount_; + registeredItem_ = serviceOrPath; + } + +signals: + void StatusNotifierHostRegistered(); + void StatusNotifierHostUnregistered(); + +private: + bool hostRegistered_ = true; + int registrationCount_ = 0; + QString registeredItem_; +}; + +class MockNotifications final : public QObject { + Q_OBJECT + Q_CLASSINFO( + "D-Bus Interface", + "org.freedesktop.Notifications") + +public: + int notifyCount() const { + return notifyCount_; + } + QString lastSummary() const { + return lastSummary_; + } + QString lastBody() const { + return lastBody_; + } + +public slots: + uint Notify( + const QString &appName, uint replacesId, + const QString &appIcon, const QString &summary, + const QString &body, const QStringList &actions, + const QVariantMap &hints, int timeout) { + Q_UNUSED(appName); + Q_UNUSED(replacesId); + Q_UNUSED(appIcon); + Q_UNUSED(actions); + Q_UNUSED(hints); + Q_UNUSED(timeout); + ++notifyCount_; + lastSummary_ = summary; + lastBody_ = body; + return 41; + } + +private: + int notifyCount_ = 0; + QString lastSummary_; + QString lastBody_; +}; + +bool registerMockWatcher( + QDBusConnection bus, + MockStatusNotifierWatcher *watcher) { + const auto flags = + QDBusConnection::ExportAllProperties | + QDBusConnection::ExportAllSlots | + QDBusConnection::ExportAllSignals; + return bus.registerObject( + kWatcherPath, watcher, flags) && + bus.registerService(kWatcherService); +} + +void unregisterMockWatcher(QDBusConnection bus) { + bus.unregisterService(kWatcherService); + bus.unregisterObject(kWatcherPath); +} + +int runTrayQuitProbe(QGuiApplication &application) { + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + return 70; + } + + QWindow window; + WindowChromeController windowChrome; + windowChrome.setWindow(&window); + windowChrome.setTrayAvailable(true); + TrayCloseFilter closeFilter(&windowChrome); + window.installEventFilter(&closeFilter); + window.show(); + + LinuxTrayController tray; + QObject::connect( + &tray, &LinuxTrayController::quitRequested, + &application, + []() { QCoreApplication::exit(0); }, + Qt::QueuedConnection); + + QTimer watchdog; + watchdog.setSingleShot(true); + QObject::connect( + &watchdog, &QTimer::timeout, + &application, [&application]() { + application.exit(75); + }); + watchdog.start(2000); + + QTimer::singleShot(0, &application, [bus]() mutable { + QDBusMessage quit = QDBusMessage::createMethodCall( + bus.baseService(), + QStringLiteral("/StatusNotifierItem/Menu"), + QStringLiteral("com.canonical.dbusmenu"), + QStringLiteral("Event")); + quit.setArguments({ + linuxtray::MenuModel::Quit, + QStringLiteral("clicked"), + QVariant::fromValue( + QDBusVariant(QVariant(QString()))), + static_cast(0), + }); + bus.asyncCall(quit); + }); + + return application.exec(); +} + +} // namespace + +class LinuxTrayControllerTests final : public QObject { + Q_OBJECT + +private slots: + void initTestCase(); + void menuModelHasStableContract(); + void menuModelFiltersProperties(); + void menuModelDispatchesOnlyClickActions(); + void menuLabelsAdvanceRevision(); + void dbusTypesMatchStatusNotifierSpecifications(); + void watcherLifecycleAndActions(); + void trayQuitTerminatesGuiEventLoop(); + void notificationsUseFreedesktopService(); + void noWatcherFallsBackToUnavailable(); +}; + +void LinuxTrayControllerTests::initTestCase() { + linuxtray::registerDBusTypes(); +} + +void LinuxTrayControllerTests::menuModelHasStableContract() { + linuxtray::MenuModel model; + + QCOMPARE( + model.children(linuxtray::MenuModel::Root), + QList({ + linuxtray::MenuModel::Open, + linuxtray::MenuModel::Separator, + linuxtray::MenuModel::Quit, + })); + QVERIFY(model.children( + linuxtray::MenuModel::Open).isEmpty()); + + const QVariantMap open = model.properties( + linuxtray::MenuModel::Open); + QCOMPARE( + open.value(QStringLiteral("label")).toString(), + QStringLiteral("Open")); + QVERIFY( + open.value(QStringLiteral("enabled")).toBool()); + QVERIFY( + open.value(QStringLiteral("visible")).toBool()); + + const QVariantMap separator = model.properties( + linuxtray::MenuModel::Separator); + QCOMPARE( + separator.value(QStringLiteral("type")).toString(), + QStringLiteral("separator")); + + const QVariantMap quit = model.properties( + linuxtray::MenuModel::Quit); + QCOMPARE( + quit.value(QStringLiteral("label")).toString(), + QStringLiteral("Quit")); +} + +void LinuxTrayControllerTests::menuModelFiltersProperties() { + linuxtray::MenuModel model; + const QVariantMap filtered = model.properties( + linuxtray::MenuModel::Open, + {QStringLiteral("label")}); + QCOMPARE(filtered.size(), 1); + QCOMPARE( + filtered.value(QStringLiteral("label")).toString(), + QStringLiteral("Open")); + + QVERIFY(model.properties( + linuxtray::MenuModel::Open, + {QStringLiteral("unknown")}).isEmpty()); +} + +void LinuxTrayControllerTests:: +menuModelDispatchesOnlyClickActions() { + linuxtray::MenuModel model; + using Action = linuxtray::MenuModel::Action; + + QCOMPARE( + model.actionForEvent( + linuxtray::MenuModel::Open, + QStringLiteral("clicked")), + Action::Show); + QCOMPARE( + model.actionForEvent( + linuxtray::MenuModel::Quit, + QStringLiteral("clicked")), + Action::Quit); + QCOMPARE( + model.actionForEvent( + linuxtray::MenuModel::Separator, + QStringLiteral("clicked")), + Action::None); + QCOMPARE( + model.actionForEvent( + linuxtray::MenuModel::Open, + QStringLiteral("hovered")), + Action::None); +} + +void LinuxTrayControllerTests::menuLabelsAdvanceRevision() { + linuxtray::MenuModel model; + const quint32 initial = model.revision(); + + model.setLabels( + QStringLiteral("Open"), + QStringLiteral("Quit")); + QCOMPARE(model.revision(), initial); + + model.setLabels( + QStringLiteral("Открыть"), + QStringLiteral("Выйти")); + QCOMPARE(model.revision(), initial + 1); + QCOMPARE( + model.properties(linuxtray::MenuModel::Open) + .value(QStringLiteral("label")).toString(), + QStringLiteral("Открыть")); + QCOMPARE( + model.properties(linuxtray::MenuModel::Quit) + .value(QStringLiteral("label")).toString(), + QStringLiteral("Выйти")); + + model.setLabels(QString(), QString()); + QCOMPARE(model.revision(), initial + 2); + QCOMPARE( + model.properties(linuxtray::MenuModel::Open) + .value(QStringLiteral("label")).toString(), + QStringLiteral("Open")); +} + +void LinuxTrayControllerTests:: +dbusTypesMatchStatusNotifierSpecifications() { + const auto signature = [](QMetaType type) { + const char *value = + QDBusMetaType::typeToSignature(type); + return QString::fromLatin1(value ? value : ""); + }; + + QCOMPARE( + signature( + QMetaType::fromType()), + QStringLiteral("(iiay)")); + QCOMPARE( + signature( + QMetaType::fromType< + linuxtray::IconPixmapList>()), + QStringLiteral("a(iiay)")); + QCOMPARE( + signature( + QMetaType::fromType()), + QStringLiteral("(sa(iiay)ss)")); + QCOMPARE( + signature( + QMetaType::fromType()), + QStringLiteral("(ia{sv}av)")); + QCOMPARE( + signature( + QMetaType::fromType< + linuxtray::MenuItemPropertiesList>()), + QStringLiteral("a(ia{sv})")); + QCOMPARE( + signature( + QMetaType::fromType< + linuxtray::MenuItemsPropertiesRemovedList>()), + QStringLiteral("a(ias)")); + QCOMPARE( + signature( + QMetaType::fromType< + linuxtray::MenuEventList>()), + QStringLiteral("a(isvu)")); +} + +void LinuxTrayControllerTests:: +watcherLifecycleAndActions() { + QDBusConnection bus = QDBusConnection::sessionBus(); + QVERIFY(bus.isConnected()); + + MockStatusNotifierWatcher firstWatcher; + QVERIFY(registerMockWatcher(bus, &firstWatcher)); + + LinuxTrayController tray; + QSignalSpy availableSpy( + &tray, &LinuxTrayController::availableChanged); + QSignalSpy showSpy( + &tray, &LinuxTrayController::showRequested); + QSignalSpy quitSpy( + &tray, &LinuxTrayController::quitRequested); + + QTRY_VERIFY(tray.available()); + QTRY_COMPARE(firstWatcher.registrationCount(), 1); + QCOMPARE( + firstWatcher.registeredItem(), + QStringLiteral("/StatusNotifierItem")); + + firstWatcher.setHostRegistered(false); + QTRY_VERIFY(!tray.available()); + firstWatcher.setHostRegistered(true); + QTRY_VERIFY(tray.available()); + + QDBusMessage activate = + QDBusMessage::createMethodCall( + bus.baseService(), + QStringLiteral("/StatusNotifierItem"), + QStringLiteral( + "org.kde.StatusNotifierItem"), + QStringLiteral("Activate")); + activate.setArguments({0, 0}); + auto activateReply = bus.asyncCall(activate); + auto *activateWatcher = + new QDBusPendingCallWatcher(activateReply, this); + QTRY_COMPARE(showSpy.count(), 1); + QTRY_VERIFY(activateWatcher->isFinished()); + QVERIFY( + !QDBusPendingReply<>(*activateWatcher).isError()); + delete activateWatcher; + + QDBusMessage quit = + QDBusMessage::createMethodCall( + bus.baseService(), + QStringLiteral("/StatusNotifierItem/Menu"), + QStringLiteral("com.canonical.dbusmenu"), + QStringLiteral("Event")); + quit.setArguments({ + linuxtray::MenuModel::Quit, + QStringLiteral("clicked"), + QVariant::fromValue( + QDBusVariant(QVariant(QString()))), + static_cast(0), + }); + auto quitReply = bus.asyncCall(quit); + auto *quitWatcher = + new QDBusPendingCallWatcher(quitReply, this); + QTRY_COMPARE(quitSpy.count(), 1); + QTRY_VERIFY(quitWatcher->isFinished()); + QVERIFY( + !QDBusPendingReply<>(*quitWatcher).isError()); + delete quitWatcher; + + unregisterMockWatcher(bus); + QTRY_VERIFY(!tray.available()); + + MockStatusNotifierWatcher secondWatcher; + QVERIFY(registerMockWatcher(bus, &secondWatcher)); + QTRY_VERIFY(tray.available()); + QTRY_COMPARE(secondWatcher.registrationCount(), 1); + QVERIFY(availableSpy.count() >= 3); + + unregisterMockWatcher(bus); + QTRY_VERIFY(!tray.available()); +} + +void LinuxTrayControllerTests:: +trayQuitTerminatesGuiEventLoop() { + const QString dbusRunSession = + QStandardPaths::findExecutable( + QStringLiteral("dbus-run-session")); + QVERIFY2( + !dbusRunSession.isEmpty(), + "dbus-run-session is required for the tray quit probe"); + + QProcess probe; + probe.start( + dbusRunSession, + {QStringLiteral("--"), + QCoreApplication::applicationFilePath(), + kQuitProbeArgument}); + QVERIFY2( + probe.waitForFinished(5000), + qPrintable(probe.errorString())); + QCOMPARE(probe.exitStatus(), QProcess::NormalExit); + QCOMPARE( + probe.exitCode(), + 0); +} + +void LinuxTrayControllerTests:: +notificationsUseFreedesktopService() { + QDBusConnection bus = QDBusConnection::sessionBus(); + QVERIFY(bus.isConnected()); + + MockNotifications notifications; + const auto flags = + QDBusConnection::ExportAllSlots; + QVERIFY(bus.registerObject( + kNotificationsPath, ¬ifications, flags)); + QVERIFY(bus.registerService( + kNotificationsService)); + + LinuxTrayController tray; + QSignalSpy failureSpy( + &tray, &LinuxTrayController::notificationFailed); + tray.showNotification( + QStringLiteral("Connected"), + QStringLiteral("PASE display session is ready"), + 1000); + + QTRY_COMPARE(notifications.notifyCount(), 1); + QCOMPARE( + notifications.lastSummary(), + QStringLiteral("Connected")); + QCOMPARE( + notifications.lastBody(), + QStringLiteral("PASE display session is ready")); + QCOMPARE(failureSpy.count(), 0); + + bus.unregisterService(kNotificationsService); + bus.unregisterObject(kNotificationsPath); +} + +void LinuxTrayControllerTests:: +noWatcherFallsBackToUnavailable() { + LinuxTrayController tray; + QVERIFY(!tray.available()); +} + +int main(int argc, char **argv) { + qputenv( + "QT_QPA_PLATFORM", + QByteArrayLiteral("offscreen")); + QGuiApplication application(argc, argv); + if (application.arguments().contains( + kQuitProbeArgument)) { + return runTrayQuitProbe(application); + } + + LinuxTrayControllerTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "linuxtraycontroller_tests.moc" diff --git a/tests/quick/linuxtraycontroller_tests.pro b/tests/quick/linuxtraycontroller_tests.pro new file mode 100644 index 0000000..8691e1f --- /dev/null +++ b/tests/quick/linuxtraycontroller_tests.pro @@ -0,0 +1,21 @@ +QT += core gui dbus testlib + +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = linuxtraycontroller-tests + +INCLUDEPATH += $$PWD/../../src/quick + +DESTDIR = $$PWD/../../build/linuxtray-tests +OBJECTS_DIR = $$PWD/../../build/linuxtray-tests/obj +MOC_DIR = $$PWD/../../build/linuxtray-tests/moc + +HEADERS += \ + ../../src/quick/linuxtraycontroller.h \ + ../../src/quick/windowchromecontroller.h + +SOURCES += \ + ../../src/quick/linuxtraycontroller.cpp \ + ../../src/quick/windowchromecontroller.cpp \ + linuxtraycontroller_tests.cpp diff --git a/tests/quick/qml/tst_firmwarefilepickerlayout.qml b/tests/quick/qml/tst_firmwarefilepickerlayout.qml new file mode 100644 index 0000000..13286b1 --- /dev/null +++ b/tests/quick/qml/tst_firmwarefilepickerlayout.qml @@ -0,0 +1,55 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../../qml/components" as Components + +TestCase { + id: testCase + + name: "FirmwareFilePickerLayout" + when: windowShown + width: 900 + height: 700 + + QtObject { + id: controllerMock + + property url homeFolder: Qt.resolvedUrl(".") + property string packagePath: "" + + function setPackagePath(path) { + packagePath = path + } + } + + Component { + id: pickerComponent + + Components.FirmwareFilePicker { + controller: controllerMock + } + } + + function test_pickerIsBoundedAndReturnsExplicitSelection() { + const picker = createTemporaryObject( + pickerComponent, testCase) + verify(picker !== null) + + picker.openPicker() + tryVerify(() => picker.opened) + verify(picker.width <= testCase.width) + verify(picker.height <= testCase.height) + + const selected = Qt.resolvedUrl("firmware-test.zip") + picker.selectedFile = selected + picker.selectedName = "firmware-test.zip" + picker.acceptSelection() + + tryVerify(() => !picker.opened) + compare( + controllerMock.packagePath, + String(selected)) + } +} diff --git a/tests/quick/qml/tst_homepagelayout.qml b/tests/quick/qml/tst_homepagelayout.qml new file mode 100644 index 0000000..7d990bd --- /dev/null +++ b/tests/quick/qml/tst_homepagelayout.qml @@ -0,0 +1,104 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../../qml/pages" as Pages + +TestCase { + id: testCase + name: "HomePageLayout" + when: windowShown + width: 1250 + height: 850 + + QtObject { + id: runtimeMock + + property bool displaySessionActive: true + property string connectionStatus: "Ready" + property string currentScreenMode: "Full Screen" + property string currentPlayMode: "Single" + property var displayedMedia: ["sample.mp4"] + property bool displayStateValid: true + property int brightness: 75 + property string diagnostic: "" + } + + QtObject { + id: metricsMock + + property bool sampled: true + property string cpuName: "Test CPU" + property real cpuUsage: 25 + property bool cpuUsageAvailable: true + property real cpuTemperature: 50 + property bool cpuTemperatureAvailable: true + property real cpuFrequencyMHz: 4200 + property bool cpuFrequencyAvailable: true + property string gpuName: "Test GPU" + property real gpuUsage: 35 + property bool gpuUsageAvailable: true + property real gpuTemperature: 60 + property bool gpuTemperatureAvailable: true + property real gpuFrequencyMHz: 2300 + property bool gpuFrequencyAvailable: true + property real ramUsage: 45 + property bool ramUsageAvailable: true + property int ramUsedMB: 16384 + property int ramTotalMB: 32768 + property real diskUsage: 55 + property bool diskUsageAvailable: true + property int diskUsedGB: 550 + property int diskTotalGB: 1000 + property bool networkAvailable: true + property real rxSpeedKBs: 2048 + property real txSpeedKBs: 512 + } + + Component { + id: homeComponent + + Pages.HomePage { + runtime: runtimeMock + systemMetrics: metricsMock + deviceIconSource: "" + } + } + + function test_metricsGridUsesResponsiveColumns() { + const wide = createTemporaryObject( + homeComponent, testCase, + {"width": 1200, "height": 800}) + verify(wide !== null) + wait(0) + const wideGrid = + findChild(wide, "systemMetricsGrid") + verify(wideGrid !== null) + compare(wideGrid.columns, 4) + + const compact = createTemporaryObject( + homeComponent, testCase, + {"width": 900, "height": 800}) + verify(compact !== null) + wait(0) + const compactGrid = + findChild(compact, "systemMetricsGrid") + verify(compactGrid !== null) + compare(compactGrid.columns, 2) + + const cpuCard = + findChild(compact, "cpuMetricCard") + const memoryCard = + findChild(compact, "memoryMetricCard") + verify(cpuCard !== null) + verify(memoryCard !== null) + metricsMock.cpuUsageAvailable = false + metricsMock.ramUsageAvailable = false + wait(0) + compare(cpuCard.valueText, "—") + compare(memoryCard.valueText, "—") + compare(memoryCard.details, + "Memory data unavailable") + } +} diff --git a/tests/quick/qml/tst_mediaeditorlayout.qml b/tests/quick/qml/tst_mediaeditorlayout.qml new file mode 100644 index 0000000..1aba236 --- /dev/null +++ b/tests/quick/qml/tst_mediaeditorlayout.qml @@ -0,0 +1,235 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtTest + +import "../../../qml/components" as Components + +TestCase { + id: testCase + name: "MediaEditorLayout" + when: host.visible + + ApplicationWindow { + id: host + width: 1060 + height: 700 + visible: true + + QtObject { + id: controller + + property bool open: false + property bool busy: false + property bool submissionPending: false + property bool ready: true + property string sourceName: "example.mp4" + property string sourceKind: "LocalMedia" + property bool recoveredDeviceCopy: false + property string originalMediaName: "" + property bool replaceAllowed: false + property string replaceBlockReason: "" + property string submissionAction: "" + property url previewUrl: "" + property string error: "" + property string mode: "Fit" + property int zoomPercent: 100 + property int focusX: 5000 + property int focusY: 5000 + property int rotation: 0 + property string backgroundColor: "#000000" + + function reset() {} + function cancel() {} + function submit() {} + function submitSaveAsNew() {} + function submitReplace() {} + } + + Components.MediaEditor { + id: editor + controller: controller + } + } + + function init() { + controller.recoveredDeviceCopy = false + controller.sourceKind = "LocalMedia" + controller.originalMediaName = "" + controller.replaceAllowed = false + controller.replaceBlockReason = "" + controller.submissionPending = false + controller.submissionAction = "" + controller.open = false + wait(0) + controller.open = true + tryVerify(() => editor.opened) + } + + function cleanup() { + controller.open = false + wait(0) + } + + function test_contentIsVisibleAfterClosedToOpenTransition() { + const content = findChild(editor, "mediaEditorContent") + verify(content !== null) + verify(content.visible) + verify(content.opacity > 0) + verify(content.width > 0) + verify(content.height > 0) + compare(editor.contentItem, content) + } + + function test_previewCanvasKeepsDeviceAspectRatio() { + const canvas = findChild(editor, "mediaPreviewCanvas") + verify(canvas !== null) + verify(canvas.width > 0) + verify(canvas.height > 0) + const expectedRatio = 2240 / 1080 + verify(Math.abs(canvas.width / canvas.height - + expectedRatio) < 0.001) + verify(canvas.width <= 1000) + verify(canvas.height <= 420) + verify(editor.width <= host.width - 32) + verify(editor.height <= host.height - 32) + } + + function test_sizingModesExplainTheirEffect() { + const description = + findChild(editor, "mediaSizingDescription") + const horizontal = + findChild(editor, "cropHorizontalPosition") + const vertical = + findChild(editor, "cropVerticalPosition") + + compare(editor.sizingModes.length, 4) + compare(editor.sizingModes[0].value, "Fit") + compare(editor.sizingModes[1].value, "Fill") + compare(editor.sizingModes[2].value, "Crop") + compare(editor.sizingModes[3].value, "Stretch") + verify(description !== null) + verify(horizontal !== null) + verify(vertical !== null) + + controller.mode = "Stretch" + wait(0) + verify(description.text.indexOf("distorted") >= 0) + + controller.mode = "Crop" + controller.zoomPercent = 100 + controller.focusX = 2500 + controller.focusY = 7500 + wait(0) + compare(horizontal.value, 25) + compare(vertical.value, 75) + verify(!horizontal.enabled) + verify(!vertical.enabled) + + controller.zoomPercent = 160 + wait(0) + verify(horizontal.enabled) + verify(vertical.enabled) + + controller.mode = "Fit" + wait(0) + verify(!horizontal.enabled) + verify(!vertical.enabled) + } + + function test_recoveredCopyUsesExplicitActions() { + controller.recoveredDeviceCopy = true + controller.sourceKind = "RecoveredDeviceCopy" + controller.originalMediaName = "device-video.h264_2240x1080" + controller.replaceAllowed = true + wait(0) + + const notice = + findChild(editor, "recoveredDeviceCopyNotice") + const noticeText = + findChild(editor, "recoveredDeviceCopyNoticeText") + const upload = + findChild(editor, "mediaEditorUploadButton") + const saveAsNew = + findChild(editor, "mediaEditorSaveAsNewButton") + const replace = + findChild(editor, "mediaEditorReplaceButton") + + verify(notice !== null) + verify(notice.visible) + verify(noticeText !== null) + verify(noticeText.text.indexOf("2240") >= 0) + verify(noticeText.text.indexOf("Crop") >= 0) + verify(noticeText.text.indexOf("Zoom") >= 0) + verify(noticeText.text.indexOf( + "does not change the active display") >= 0) + verify(editor.recoveredTransformIsGeometryNeutral) + verify(upload !== null) + verify(!upload.visible) + verify(saveAsNew !== null) + verify(saveAsNew.visible) + verify(saveAsNew.enabled) + verify(replace !== null) + verify(replace.visible) + verify(replace.enabled) + + controller.submissionPending = true + controller.submissionAction = "Replace" + wait(0) + verify(!saveAsNew.enabled) + verify(!replace.enabled) + verify(replace.text.indexOf("Replacing") >= 0) + } + + function test_actionRowRemainsVisibleInShortWindow() { + controller.recoveredDeviceCopy = true + controller.replaceAllowed = true + wait(0) + + const content = + findChild(editor, "mediaEditorContent") + const actionRow = + findChild(editor, "mediaEditorActionRow") + const saveAsNew = + findChild(editor, "mediaEditorSaveAsNewButton") + verify(content !== null) + verify(actionRow !== null) + verify(saveAsNew !== null) + verify(actionRow.visible) + verify(saveAsNew.visible) + + const top = actionRow.mapToItem(content, 0, 0) + const bottom = actionRow.mapToItem( + content, 0, actionRow.height) + verify(top.y >= 0) + verify(bottom.y <= content.height + 0.5) + } + + function test_recoveredCopyWarnsOnlyForNeutralGeometry() { + controller.recoveredDeviceCopy = true + controller.mode = "Crop" + controller.zoomPercent = 100 + controller.rotation = 0 + wait(0) + + const noticeText = + findChild(editor, "recoveredDeviceCopyNoticeText") + verify(noticeText !== null) + verify(editor.recoveredTransformIsGeometryNeutral) + verify(noticeText.text.indexOf( + "will not visibly change") >= 0) + + controller.zoomPercent = 160 + wait(0) + verify(!editor.recoveredTransformIsGeometryNeutral) + verify(noticeText.text.indexOf( + "Save as new stores") >= 0) + + controller.mode = "Stretch" + controller.zoomPercent = 100 + controller.rotation = 180 + wait(0) + verify(!editor.recoveredTransformIsGeometryNeutral) + } +} diff --git a/tests/quick/qml/tst_mediaexportpickerlayout.qml b/tests/quick/qml/tst_mediaexportpickerlayout.qml new file mode 100644 index 0000000..3df302b --- /dev/null +++ b/tests/quick/qml/tst_mediaexportpickerlayout.qml @@ -0,0 +1,73 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtTest + +import "../../../qml/components" as Components + +TestCase { + id: testCase + name: "MediaExportPickerLayout" + when: host.visible + + ApplicationWindow { + id: host + width: 900 + height: 700 + visible: true + + QtObject { + id: workflow + + property string exportedMediaId: "" + property string exportedMediaName: "" + property string exportedFileName: "" + + function suggestedExportFileName(mediaName) { + return "suggested-device-copy.h264" + } + + function beginExport(mediaId, mediaName, + folder, fileName) { + exportedMediaId = mediaId + exportedMediaName = mediaName + exportedFileName = fileName + } + } + + Components.MediaExportPicker { + id: picker + workflow: workflow + homeFolder: Qt.resolvedUrl(".") + } + } + + function init() { + workflow.exportedMediaId = "" + workflow.exportedMediaName = "" + workflow.exportedFileName = "" + picker.close() + wait(0) + } + + function test_suggestedNameAndExplicitExport() { + picker.openFor("media-id", "device-media") + tryVerify(() => picker.opened) + + const fileName = + findChild(picker, "mediaExportFileName") + const exportButton = + findChild(picker, "mediaExportSaveButton") + verify(fileName !== null) + verify(exportButton !== null) + compare(fileName.text, "suggested-device-copy.h264") + verify(exportButton.enabled) + + mouseClick(exportButton) + tryCompare(workflow, "exportedMediaId", "media-id") + compare(workflow.exportedMediaName, "device-media") + compare(workflow.exportedFileName, + "suggested-device-copy.h264") + } +} diff --git a/tests/quick/qml/tst_mediafilepickerlayout.qml b/tests/quick/qml/tst_mediafilepickerlayout.qml new file mode 100644 index 0000000..6a522f9 --- /dev/null +++ b/tests/quick/qml/tst_mediafilepickerlayout.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtTest + +import "../../../qml/components" as Components + +TestCase { + id: testCase + name: "MediaFilePickerLayout" + when: host.visible + + ApplicationWindow { + id: host + width: 1060 + height: 700 + visible: true + + QtObject { + id: controller + + property url homeFolder: Qt.resolvedUrl(".") + property url lastOpened: "" + property bool pickerWasOpenWhenBegin: false + + function begin(source) { + lastOpened = source + pickerWasOpenWhenBegin = picker.opened + } + } + + Components.MediaFilePicker { + id: picker + editor: controller + } + } + + function init() { + controller.lastOpened = "" + controller.pickerWasOpenWhenBegin = false + picker.close() + wait(0) + } + + function test_pickerHasVisibleBoundedContent() { + picker.openPicker() + tryVerify(() => picker.opened) + + const header = + findChild(picker, "mediaFilePickerHeader") + const list = + findChild(picker, "mediaFilePickerList") + const openButton = + findChild(picker, "mediaFilePickerOpenButton") + + verify(header !== null) + verify(list !== null) + verify(openButton !== null) + verify(header.visible) + verify(list.visible) + verify(picker.width <= host.width - 48) + verify(picker.height <= host.height - 48) + verify(!openButton.enabled) + } + + function test_selectedFileOpensEditor() { + picker.openPicker() + tryVerify(() => picker.opened) + + const source = Qt.resolvedUrl("fixture.mp4") + picker.selectedFile = source + wait(0) + + const openButton = + findChild(picker, "mediaFilePickerOpenButton") + verify(openButton.enabled) + + picker.acceptSelection() + tryVerify(() => !picker.opened) + tryVerify(() => controller.lastOpened === source) + verify(!controller.pickerWasOpenWhenBegin) + } + + function test_navigationClearsStaleSelection() { + picker.openPicker() + tryVerify(() => picker.opened) + + picker.selectedFile = Qt.resolvedUrl("fixture.mp4") + verify(String(picker.selectedFile).length > 0) + + picker.navigate(Qt.resolvedUrl("..")) + compare(String(picker.selectedFile), "") + } +} diff --git a/tests/quick/qml/tst_panoramalayout.qml b/tests/quick/qml/tst_panoramalayout.qml new file mode 100644 index 0000000..9ed6e64 --- /dev/null +++ b/tests/quick/qml/tst_panoramalayout.qml @@ -0,0 +1,261 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQml.Models +import QtTest + +import "../../../qml/pages" as Pages + +TestCase { + id: testCase + name: "PanoramaLayout" + when: windowShown + width: 1100 + height: 760 + + ListModel { + id: mediaModel + + function canDelete(mediaName) { + return mediaName.length > 0 + } + + function deleteBlockReason(mediaName) { + return "" + } + + ListElement { + mediaName: "one.mp4.h264_2240x1080" + mediaId: "media-one" + mediaSize: 1024 + thumbnailUrl: "" + deleteAllowed: true + deleteBlockReason: "" + deviceCopyAllowed: true + deviceCopyBlockReason: "" + } + ListElement { + mediaName: "two.mp4.h264_2240x1080" + mediaId: "media-two" + mediaSize: 2048 + thumbnailUrl: "" + deleteAllowed: true + deleteBlockReason: "" + deviceCopyAllowed: true + deviceCopyBlockReason: "" + } + } + + ListModel { + id: operationModel + ListElement { + operationId: "one" + subject: "Upload" + operationState: "Succeeded" + message: "Done" + canRetry: false + } + } + + QtObject { + id: runtimeMock + + property bool displaySessionActive: true + property bool operationBusy: false + property bool compatible: true + property string activeOperationId: "" + property string operationSummary: "" + property real operationProgress: 0 + property var mediaModel: mediaModel + property var operationModel: operationModel + property var availableMetrics: [ + "CPU Temperature", "GPU Temperature" + ] + property int brightness: 75 + property bool mirrorMode: false + property bool waterfallMode: false + property bool displayStateValid: true + property bool backlightEnabled: true + property string currentScreenMode: "Full Screen" + property string currentPlayMode: "Single" + property var displayedMedia: [] + property var displayLeftMetrics: [] + property var displayRightMetrics: [] + property var displayLeftBadges: ["CPU Badge"] + property var displayRightBadges: ["GPU Badge"] + property var activeMetrics: [] + property string metricsAlignment: "Left" + property string metricsColor: "#dcdcdc" + property bool metricsEnabled: false + property bool samplingActive: false + property string diagnostic: "" + + signal displayChanged() + signal metricsChanged() + + function refreshMedia() {} + function deleteMedia(media) {} + function applyFullScreen(media, playMode, + metrics, badges) {} + function applySplitScreen(left, right, playMode, + leftMetrics, rightMetrics, + leftBadges, rightBadges) {} + function configureMetrics(enabled, metrics, + alignment, color) {} + function setBrightness(value) {} + function setBacklight(enabled) {} + function setOrientation(mirror, waterfall) {} + function retryOperation(operationId) {} + function cancelActiveOperation() {} + } + + QtObject { + id: editorMock + property url homeFolder: Qt.resolvedUrl(".") + function begin(url) {} + function beginDropped(urls) {} + } + + QtObject { + id: deviceMediaMock + + property bool busy: false + property bool overwriteConfirmationPending: false + property string overwriteFileName: "" + + signal stateChanged() + + function beginEdit(mediaId, mediaName) {} + function beginExport(mediaId, mediaName, folder, fileName) {} + function confirmOverwrite() {} + function cancelOverwrite() {} + function suggestedExportFileName(mediaName) { + return "device-copy.h264" + } + } + + Component { + id: panoramaComponent + Pages.PanoramaPage { + runtime: runtimeMock + editor: editorMock + deviceMedia: deviceMediaMock + } + } + + function test_boundedContentAndGroups() { + const compactPage = createTemporaryObject( + panoramaComponent, testCase, + {"width": 820, "height": 620}) + verify(compactPage !== null) + wait(0) + const compactContent = + findChild(compactPage, "panoramaContent") + const compactWorkspace = + findChild(compactPage, "displayWorkspace") + verify(compactContent !== null) + verify(compactWorkspace !== null) + compare(compactWorkspace.columns, 1) + verify(compactContent.width <= compactPage.availableWidth) + + const page = createTemporaryObject( + panoramaComponent, testCase, + {"width": 1000, "height": 700}) + verify(page !== null) + wait(0) + + const content = findChild(page, "panoramaContent") + const mediaGroup = findChild(page, "mediaLibraryGroup") + const operationGroup = + findChild(page, "recentOperationsGroup") + const displayLayoutGroup = + findChild(page, "displayLayoutGroup") + const metricsOverlayControls = + findChild(page, "metricsOverlayControls") + const workspace = + findChild(page, "displayWorkspace") + verify(content !== null) + verify(mediaGroup !== null) + verify(operationGroup !== null) + verify(displayLayoutGroup !== null) + verify(metricsOverlayControls !== null) + compare(findChild(page, "liveMetricsGroup"), null) + verify(workspace !== null) + compare(workspace.columns, 1) + compare(content.x, 24) + verify(content.width <= page.availableWidth) + verify(content.width >= page.availableWidth - 49) + verify(mediaGroup.height >= 235) + verify(operationGroup.height >= 115) + + const widePage = createTemporaryObject( + panoramaComponent, testCase, + {"width": 1250, "height": 700}) + verify(widePage !== null) + wait(0) + const wideWorkspace = + findChild(widePage, "displayWorkspace") + verify(wideWorkspace !== null) + compare(wideWorkspace.columns, 2) + } + + function test_playModeReflectsSnapshot() { + const page = createTemporaryObject( + panoramaComponent, testCase, + {"width": 1000, "height": 700}) + verify(page !== null) + const combo = findChild(page, "playModeCombo") + verify(combo !== null) + + runtimeMock.currentScreenMode = "Full Screen" + runtimeMock.currentPlayMode = "Loop" + runtimeMock.displayChanged() + wait(0) + compare(combo.currentText, "Loop") + + runtimeMock.currentPlayMode = "Single" + runtimeMock.displayChanged() + } + + function test_mediaActionsArePerCardAndBrightnessIsBounded() { + const page = createTemporaryObject( + panoramaComponent, testCase, + {"width": 1000, "height": 700}) + verify(page !== null) + wait(0) + + const actionButton = + findChild(page, "mediaActionButton") + const uploadButton = + findChild(page, "uploadMediaButton") + const brightness = + findChild(page, "brightnessSlider") + const fullCpuBadge = + findChild(page, "fullCpuBadge") + const fullGpuBadge = + findChild(page, "fullGpuBadge") + const leftCpuBadge = + findChild(page, "leftCpuBadge") + const rightGpuBadge = + findChild(page, "rightGpuBadge") + verify(actionButton !== null) + verify(actionButton.enabled) + verify(actionButton.width <= 30) + verify(actionButton.height <= 30) + verify(uploadButton !== null) + compare(uploadButton.contentItem.color.toString(), "#171a1e") + verify(fullCpuBadge !== null) + verify(fullGpuBadge !== null) + verify(leftCpuBadge !== null) + verify(rightGpuBadge !== null) + verify(fullCpuBadge.checked) + verify(!fullGpuBadge.checked) + verify(leftCpuBadge.checked) + verify(rightGpuBadge.checked) + page.toggleBadge("full", "GPU Badge") + compare(page.fullBadges, + ["CPU Badge", "GPU Badge"]) + verify(brightness !== null) + verify(brightness.width <= 380) + } +} diff --git a/tests/quick/qml/tst_settingslayout.qml b/tests/quick/qml/tst_settingslayout.qml new file mode 100644 index 0000000..153dfc6 --- /dev/null +++ b/tests/quick/qml/tst_settingslayout.qml @@ -0,0 +1,218 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../../qml/components" as Components +import "../../../qml/pages" as Pages + +TestCase { + id: testCase + name: "SettingsLayout" + when: windowShown + width: 1000 + height: 760 + + QtObject { + id: runtimeMock + + property bool operationBusy: false + property bool serviceAvailable: true + property bool legacyConnected: false + property bool printerClassDevicePresent: true + property bool displaySessionActive: true + property string diagnostic: "" + + function refreshAll() {} + function connectDevice(port) {} + function disconnectDevice() {} + function startKeepalive(interval) {} + } + + QtObject { + id: settingsMock + + property string language: "en" + property string devicePort: "" + property int keepaliveInterval: 10 + property var serialPorts: ["/dev/ttyACM0"] + property bool autostartEnabled: true + property bool autostartAvailable: true + property bool busy: false + property string errorMessage: "" + + function setLanguage(code) { + language = code + } + function setDevicePort(port) { + devicePort = port + } + function setKeepaliveInterval(interval) { + keepaliveInterval = interval + } + function refreshSerialPorts() {} + function setAutostartEnabled(enabled) { + autostartEnabled = enabled + } + function refreshAutostart() {} + } + + QtObject { + id: firmwareMock + + property bool serviceAvailable: true + property bool compatible: true + property bool ready: true + property url homeFolder: Qt.resolvedUrl(".") + property string packagePath: "" + property bool busy: false + property bool validationBusy: false + property bool flashBusy: false + property bool approvalAvailable: false + property bool flashSupported: false + property bool recoveryRequired: false + property bool canValidate: packagePath.length > 0 + property bool canFlash: false + property bool confirmationRequired: false + property int progress: 0 + property string phase: "Idle" + property string status: "Ready" + property string kind: "" + property string canonicalPath: "" + property string sha256: "" + property real sizeBytes: 0 + property string productCode: "" + property string firmwareVersion: "" + property string appVersion: "" + property string errorMessage: "" + property int recoveryAcknowledgementCount: 0 + + function setPackagePath(path) { + packagePath = path + } + function validatePackage() {} + function requestFlashConfirmation() {} + function cancelFlashConfirmation() {} + function confirmFlash() {} + function requestCancel() {} + function acknowledgeFirmwareRecovery() { + ++recoveryAcknowledgementCount + } + function refresh() {} + } + + Component { + id: settingsComponent + + Pages.SettingsPage { + runtime: runtimeMock + settings: settingsMock + firmware: firmwareMock + } + } + + Component { + id: firmwarePanelComponent + + Components.FirmwarePanel { + controller: firmwareMock + } + } + + function test_englishAndAutostartControlsAreFunctional() { + const page = createTemporaryObject( + settingsComponent, testCase, + {"width": 950, "height": 720}) + verify(page !== null) + wait(0) + + const language = + findChild(page, "languageCombo") + const autostart = + findChild(page, "autostartSwitch") + const content = + findChild(page, "settingsContent") + const openGitHub = + findChild(page, "openGitHubButton") + const firmwarePanel = + findChild(page, "firmwarePanel") + const firmwareChoose = + findChild(page, "firmwareChooseButton") + const firmwarePath = + findChild(page, "firmwarePackagePath") + const firmwareRecovery = + findChild(page, + "firmwareRecoveryAcknowledgeButton") + const serialPort = + findChild(page, "serialPortCombo") + const keepalive = + findChild(page, "keepaliveSpin") + const reconnect = + findChild(page, "reconnectDeviceButton") + verify(language !== null) + verify(autostart !== null) + verify(content !== null) + verify(openGitHub !== null) + verify(firmwarePanel !== null) + verify(firmwareChoose !== null) + verify(firmwarePath !== null) + verify(firmwareRecovery !== null) + verify(serialPort !== null) + verify(keepalive !== null) + verify(reconnect !== null) + compare(serialPort.currentText, "Auto") + compare(keepalive.value, 10) + compare(language.currentText, "English") + verify(autostart.checked) + compare(firmwareRecovery.text, + "I inspected the display; resume connection") + verify(!firmwareRecovery.visible) + verify(!firmwareRecovery.enabled) + + const githubPosition = + openGitHub.mapToItem(content, 0, 0) + verify(content.width - githubPosition.x + - openGitHub.width <= 30) + + settingsMock.language = "ru" + wait(0) + compare(language.currentText, "Russian") + } + + function test_firmwareRecoveryAcknowledgementVisibility() { + firmwareMock.busy = false + firmwareMock.recoveryRequired = false + firmwareMock.recoveryAcknowledgementCount = 0 + + const panel = createTemporaryObject( + firmwarePanelComponent, testCase, + {"width": 950, "height": 600, + "visible": true}) + verify(panel !== null) + wait(0) + + const recoveryButton = findChild( + panel, "firmwareRecoveryAcknowledgeButton") + verify(recoveryButton !== null) + compare( + recoveryButton.text, + "I inspected the display; resume connection") + verify(!recoveryButton.visible) + verify(!recoveryButton.enabled) + verify(!panel.recoveryActionAvailable) + + firmwareMock.recoveryRequired = true + wait(0) + verify(panel.recoveryActionAvailable) + verify(recoveryButton.enabled) + recoveryButton.clicked() + compare( + firmwareMock.recoveryAcknowledgementCount, 1) + + firmwareMock.busy = true + wait(0) + verify(!panel.recoveryActionAvailable) + verify(!recoveryButton.visible) + verify(!recoveryButton.enabled) + } +} diff --git a/tests/quick/quick_tests.pro b/tests/quick/quick_tests.pro new file mode 100644 index 0000000..466b007 --- /dev/null +++ b/tests/quick/quick_tests.pro @@ -0,0 +1,45 @@ +QT += concurrent core dbus gui testlib + +CONFIG += c++17 console testcase +TEMPLATE = app +TARGET = tryx-quick-tests + +INCLUDEPATH += $$PWD/../../src $$PWD/../../src/quick +INCLUDEPATH += $$PWD/../../include + +DESTDIR = $$PWD/../../build/quick-tests +OBJECTS_DIR = $$PWD/../../build/quick-tests/obj +MOC_DIR = $$PWD/../../build/quick-tests/moc + +HEADERS += \ + ../../src/applicationpaths.h \ + ../../src/systemmonitor.h \ + ../../src/runtimecontract.h \ + ../../src/mediatransform.h \ + ../../src/quick/appsettingscontroller.h \ + ../../src/quick/devicemediaworkflowcontroller.h \ + ../../src/quick/firmwarecontroller.h \ + ../../src/quick/mediacatalogmodel.h \ + ../../src/quick/mediaeditorcontroller.h \ + ../../src/quick/mediapreviewcontroller.h \ + ../../src/quick/operationlistmodel.h \ + ../../src/quick/runtimeclient.h \ + ../../src/quick/systemmetricsmodel.h \ + ../../src/quick/windowchromecontroller.h + +SOURCES += \ + ../../src/systemmonitor.cpp \ + ../../src/runtimecontract.cpp \ + ../../src/mediatransform.cpp \ + ../../src/core/config.cpp \ + ../../src/quick/appsettingscontroller.cpp \ + ../../src/quick/devicemediaworkflowcontroller.cpp \ + ../../src/quick/firmwarecontroller.cpp \ + ../../src/quick/mediacatalogmodel.cpp \ + ../../src/quick/mediaeditorcontroller.cpp \ + ../../src/quick/mediapreviewcontroller.cpp \ + ../../src/quick/operationlistmodel.cpp \ + ../../src/quick/runtimeclient.cpp \ + ../../src/quick/systemmetricsmodel.cpp \ + ../../src/quick/windowchromecontroller.cpp \ + tst_quickmodels.cpp diff --git a/tests/quick/tst_quickmodels.cpp b/tests/quick/tst_quickmodels.cpp new file mode 100644 index 0000000..2248ba6 --- /dev/null +++ b/tests/quick/tst_quickmodels.cpp @@ -0,0 +1,1881 @@ +#include + +#include "appsettingscontroller.h" +#include "applicationpaths.h" +#include "devicemediaworkflowcontroller.h" +#include "mediacatalogmodel.h" +#include "mediaeditorcontroller.h" +#include "mediapreviewcontroller.h" +#include "mediatransform.h" +#include "operationlistmodel.h" +#include "runtimeclient.h" +#include "systemmetricsmodel.h" +#include "windowchromecontroller.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QuickClientTests final : public QObject { + Q_OBJECT + +private slots: + void sharedApplicationDataPathUsesStableManagerNamespace(); + void transformDefaultsAreCanonical(); + void nonCropFieldsAreNeutral(); + void cropRotationResetsViewport(); + void previewUsesCanonicalTransformFilter(); + void renderedTransformPreservesDisplayGeometry(); + void previewGenerationIsDebounced(); + void sourceSnapshotIsImmutableAfterCopy(); + void sourceSnapshotPreservesExistingFinal(); + void stageHelperRejectsSymlinkDotDotEscape(); + void protectedInboxSourceSurvivesControllerLifetime(); + void stagingTimeoutDoesNotBlockTheController(); + void drainingStageHelperBlocksRepeatedStart(); + void previewControllerRendersImmutableExactFrame(); + void publicPreviewRejectsRawDeviceH264(); + void exportHelperIsAtomicAndDoesNotClobber(); + void qmlImportScannerFindsResolvedModules(); + void catalogRejectsStaleRevision(); + void catalogResolvesThumbnailFromConfiguredDataRoot(); + void catalogExposesDeviceCopyEligibility(); + void legacyConnectionPopulatesCurrentMediaModel(); + void legacyScreenConfigKeepsManager1Shape(); + void legacyTransformBoundaryIsExplicit(); + void legacyUploadRetainsSourceUntilTerminalSignal(); + void operationsExposeStableRoles(); + void operationsRejectStaleEvents(); + void operationAcknowledgementRequiresExactIdentity(); + void succeededOperationClearsBusyState(); + void deviceMediaWorkflowRejectsMismatchedClaimIdentity(); + void deviceMediaWorkflowSaveAsNewCompletesAndReleasesLease(); + void deviceMediaWorkflowInvalidationStopsReplaceMutations(); + void applyRequestPreservesConfirmedOverlaySettings(); + void metricsRequestUsesExplicitEnableDisableContract(); + void systemMetricsModelMapsAvailability(); + void appSettingsDefaultToEnglishAndPreserveConfig(); + void windowChromeRejectsOperationsWithoutWindow(); + void windowChromeHidesAndRestoresOnlyWithTray(); + +private: + static void preparePaseDeviceMedia( + RuntimeClient *runtime, const QString &mediaId, + const QString &mediaName, + const QString &deviceIdentity); + static TryxRuntimeDeviceMediaArtifact deviceMediaArtifact( + const QString &operationId, const QString &artifactId, + const QString &mediaId, const QString &mediaName, + const QString &deviceIdentity); +}; + +void QuickClientTests:: + sharedApplicationDataPathUsesStableManagerNamespace() { + QCOMPARE( + panorama::sharedApplicationDataLocation(), + QDir(QStandardPaths::writableLocation( + QStandardPaths::GenericDataLocation)) + .filePath(QStringLiteral( + "DXVSI/TRYX Panorama Manager"))); +} + +void QuickClientTests::preparePaseDeviceMedia( + RuntimeClient *runtime, const QString &mediaId, + const QString &mediaName, + const QString &deviceIdentity) { + runtime->serviceAvailable_ = true; + runtime->compatible_ = true; + runtime->connection_.revision = 1; + runtime->connection_.printerClassConnected = true; + runtime->connection_.printerClassDevicePresent = true; + runtime->connection_.displaySessionActive = true; + + TryxRuntimeMediaEntry entry; + entry.name = mediaName; + entry.source = 1; + entry.mediaId = mediaId; + + TryxRuntimeMediaCatalogSnapshot snapshot; + snapshot.revision = 1; + snapshot.deviceIdentity = deviceIdentity; + snapshot.entries = {entry}; + runtime->mediaModel()->applySnapshot(snapshot); +} + +TryxRuntimeDeviceMediaArtifact +QuickClientTests::deviceMediaArtifact( + const QString &operationId, const QString &artifactId, + const QString &mediaId, const QString &mediaName, + const QString &deviceIdentity) { + TryxRuntimeDeviceMediaArtifact artifact; + artifact.operationId = operationId; + artifact.artifactId = artifactId; + artifact.mediaId = mediaId; + artifact.deviceIdentity = deviceIdentity; + artifact.remoteName = mediaName; + artifact.size = 1; + artifact.decodedSha256 = + QString(64, QLatin1Char('0')); + artifact.localPath = + QDir(tryxRuntimeDeviceMediaOutboxPath()) + .filePath(QStringLiteral("offline-test.h264")); + artifact.logicalType = QStringLiteral("Video"); + artifact.leaseId = QStringLiteral("lease-1"); + artifact.leaseExpiresUtcMs = + QDateTime::currentMSecsSinceEpoch() + 60000; + return artifact; +} + +void QuickClientTests::transformDefaultsAreCanonical() { + RuntimeClient runtime(true); + MediaEditorController editor(&runtime); + + const TryxRuntimeMediaTransform transform = editor.transform(); + QCOMPARE(transform.schemaVersion, 1U); + QCOMPARE(transform.mode, QStringLiteral("Fit")); + QCOMPARE(transform.rotationQuarterTurns, 0U); + QCOMPARE(transform.zoomPermille, 1000U); + QCOMPARE(transform.focusX, 5000U); + QCOMPARE(transform.focusY, 5000U); + QCOMPARE(transform.backgroundRgb, 0U); +} + +void QuickClientTests::nonCropFieldsAreNeutral() { + RuntimeClient runtime(true); + MediaEditorController editor(&runtime); + + editor.setMode(QStringLiteral("Crop")); + editor.setZoomPercent(275); + editor.setFocusX(1500); + editor.setFocusY(8500); + editor.setBackgroundColor(QStringLiteral("#aabbcc")); + editor.setMode(QStringLiteral("Fill")); + + const TryxRuntimeMediaTransform transform = editor.transform(); + QCOMPARE(transform.mode, QStringLiteral("Fill")); + QCOMPARE(transform.zoomPermille, 1000U); + QCOMPARE(transform.focusX, 5000U); + QCOMPARE(transform.focusY, 5000U); + QCOMPARE(transform.backgroundRgb, 0U); +} + +void QuickClientTests::cropRotationResetsViewport() { + RuntimeClient runtime(true); + MediaEditorController editor(&runtime); + + editor.setMode(QStringLiteral("Crop")); + editor.setZoomPercent(400); + editor.setFocusX(0); + editor.setFocusY(10000); + editor.setRotation(90); + + const TryxRuntimeMediaTransform transform = editor.transform(); + QCOMPARE(transform.mode, QStringLiteral("Crop")); + QCOMPARE(transform.rotationQuarterTurns, 1U); + QCOMPARE(transform.zoomPermille, 1000U); + QCOMPARE(transform.focusX, 5000U); + QCOMPARE(transform.focusY, 5000U); +} + +void QuickClientTests::previewUsesCanonicalTransformFilter() { + TryxRuntimeMediaTransform transform; + transform.mode = QStringLiteral("Crop"); + transform.rotationQuarterTurns = 1; + transform.zoomPermille = 4000; + transform.focusX = 10000; + transform.focusY = 0; + + const QString canonical = + tryxMediaTransformFfmpegFilter( + transform, kTryxMediaTargetWidth, + kTryxMediaTargetHeight); + QVERIFY(!canonical.isEmpty()); + QCOMPARE( + MediaPreviewController::previewFilter(transform), + canonical + + QStringLiteral(",scale=1120:540:flags=lanczos")); + QVERIFY(canonical.startsWith( + QStringLiteral("transpose=clock,"))); + QVERIFY(canonical.contains( + QStringLiteral( + "crop=2240:1080:" + "'trunc((iw-2240)*10000/10000/2)*2':" + "'trunc((ih-1080)*0/10000/2)*2'"))); + + for (quint32 rotation = 0; rotation < 4; ++rotation) { + transform.rotationQuarterTurns = rotation; + QVERIFY(!MediaPreviewController::previewFilter( + transform) + .isEmpty()); + } +} + +void QuickClientTests:: + renderedTransformPreservesDisplayGeometry() { + const QString ffmpeg = QStandardPaths::findExecutable( + QStringLiteral("ffmpeg")); + if (ffmpeg.isEmpty()) { + QSKIP("ffmpeg is optional for the unit-test environment"); + } + + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + const auto render = [&]( + const QString &source, + const TryxRuntimeMediaTransform &transform, + const QString &outputPath) { + QProcess process; + process.setProgram(ffmpeg); + process.setArguments({ + QStringLiteral("-nostdin"), + QStringLiteral("-hide_banner"), + QStringLiteral("-loglevel"), + QStringLiteral("error"), + QStringLiteral("-y"), + QStringLiteral("-f"), + QStringLiteral("lavfi"), + QStringLiteral("-i"), + source, + QStringLiteral("-frames:v"), + QStringLiteral("1"), + QStringLiteral("-vf"), + tryxMediaTransformFfmpegFilter(transform), + outputPath, + }); + process.start(); + if (!process.waitForStarted(5000)) { + return process.errorString().toUtf8(); + } + if (!process.waitForFinished(30000)) { + process.kill(); + process.waitForFinished(5000); + return QByteArray("ffmpeg render timed out"); + } + const QByteArray diagnostic = process.readAll(); + if (process.exitStatus() != QProcess::NormalExit || + process.exitCode() != 0) { + return diagnostic.isEmpty() + ? QByteArray("ffmpeg render failed") + : diagnostic; + } + return QByteArray(); + }; + + TryxRuntimeMediaTransform fit = + tryxLegacyFitMediaTransform(); + fit.backgroundRgb = 0x00FF00; + const QString fitPath = + QDir(directory.path()).filePath( + QStringLiteral("anamorphic-fit.png")); + QByteArray diagnostic = render( + QStringLiteral( + "color=c=red:s=720x576:d=1,setsar=64/45"), + fit, fitPath); + QVERIFY2(diagnostic.isEmpty(), diagnostic.constData()); + + const QImage fitImage(fitPath); + QVERIFY(!fitImage.isNull()); + QCOMPARE( + fitImage.size(), + QSize(kTryxMediaTargetWidth, + kTryxMediaTargetHeight)); + const QColor leftPadding = fitImage.pixelColor(80, 540); + const QColor center = fitImage.pixelColor(1120, 540); + const QColor rightPadding = fitImage.pixelColor(2160, 540); + QVERIFY(leftPadding.green() > 160); + QVERIFY(leftPadding.red() < 100); + QVERIFY(center.red() > 160); + QVERIFY(center.green() < 100); + QVERIFY(rightPadding.green() > 160); + QVERIFY(rightPadding.red() < 100); + + TryxRuntimeMediaTransform rotatedFit = fit; + rotatedFit.rotationQuarterTurns = 1; + const QString rotatedPath = + QDir(directory.path()).filePath( + QStringLiteral("anamorphic-rotated-fit.png")); + diagnostic = render( + QStringLiteral( + "color=c=red:s=720x576:d=1,setsar=64/45"), + rotatedFit, rotatedPath); + QVERIFY2(diagnostic.isEmpty(), diagnostic.constData()); + + const QImage rotatedImage(rotatedPath); + QVERIFY(!rotatedImage.isNull()); + const QColor rotatedPadding = + rotatedImage.pixelColor(740, 540); + const QColor rotatedCenter = + rotatedImage.pixelColor(1120, 540); + QVERIFY(rotatedPadding.green() > 160); + QVERIFY(rotatedPadding.red() < 100); + QVERIFY(rotatedCenter.red() > 160); + QVERIFY(rotatedCenter.green() < 100); + + TryxRuntimeMediaTransform fill = + tryxLegacyFitMediaTransform(); + fill.mode = QStringLiteral("Fill"); + TryxRuntimeMediaTransform neutralCrop = fill; + neutralCrop.mode = QStringLiteral("Crop"); + QCOMPARE( + tryxMediaTransformFfmpegFilter(fill), + tryxMediaTransformFfmpegFilter(neutralCrop)); + + const QString fillPath = + QDir(directory.path()).filePath( + QStringLiteral("fill.png")); + const QString cropPath = + QDir(directory.path()).filePath( + QStringLiteral("neutral-crop.png")); + diagnostic = render( + QStringLiteral( + "testsrc=size=333x1000:rate=1:duration=1"), + fill, fillPath); + QVERIFY2(diagnostic.isEmpty(), diagnostic.constData()); + diagnostic = render( + QStringLiteral( + "testsrc=size=333x1000:rate=1:duration=1"), + neutralCrop, cropPath); + QVERIFY2(diagnostic.isEmpty(), diagnostic.constData()); + + const QImage fillImage(fillPath); + const QImage cropImage(cropPath); + QVERIFY(!fillImage.isNull()); + QVERIFY(!cropImage.isNull()); + QCOMPARE(fillImage, cropImage); +} + +void QuickClientTests::previewGenerationIsDebounced() { + MediaPreviewController preview; + const quint64 initialGeneration = + preview.requestedRenderGeneration_; + + TryxRuntimeMediaTransform transform; + transform.mode = QStringLiteral("Fill"); + preview.setTransform(transform); + QCOMPARE( + preview.requestedRenderGeneration_, + initialGeneration + 1); + QVERIFY(!preview.renderDebounce_.isActive()); + + preview.setTransform(transform); + QCOMPARE( + preview.requestedRenderGeneration_, + initialGeneration + 1); + + transform.rotationQuarterTurns = 3; + preview.setTransform(transform); + QCOMPARE( + preview.requestedRenderGeneration_, + initialGeneration + 2); +} + +void QuickClientTests::sourceSnapshotIsImmutableAfterCopy() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString sourcePath = + QDir(directory.path()).filePath( + QStringLiteral("source.mp4")); + const QString stagedPath = + QDir(directory.path()).filePath( + QStringLiteral("snapshot.mp4")); + const QByteArray original("immutable-source-payload"); + + QFile source(sourcePath); + QVERIFY(source.open(QIODevice::WriteOnly)); + QCOMPARE(source.write(original), original.size()); + source.close(); + + const QFileInfo sourceInfo(sourcePath); + const MediaPreviewController::StageResult result = + MediaPreviewController::copySourceSnapshot( + sourcePath, stagedPath, sourceInfo.size(), + sourceInfo.lastModified()); + QVERIFY2(result.error.isEmpty(), + qPrintable(result.error)); + + QVERIFY(source.open( + QIODevice::WriteOnly | QIODevice::Truncate)); + const QByteArray replacement("changed-after-preview"); + QCOMPARE(source.write(replacement), replacement.size()); + source.close(); + + QFile snapshot(stagedPath); + QVERIFY(snapshot.open(QIODevice::ReadOnly)); + QCOMPARE(snapshot.readAll(), original); + const QFileInfo snapshotInfo(stagedPath); + QVERIFY(!(snapshotInfo.permissions() & + (QFileDevice::ReadGroup | + QFileDevice::WriteGroup | + QFileDevice::ExeGroup | + QFileDevice::ReadOther | + QFileDevice::WriteOther | + QFileDevice::ExeOther))); +} + +void QuickClientTests::sourceSnapshotPreservesExistingFinal() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString sourcePath = + QDir(directory.path()).filePath( + QStringLiteral("source.mp4")); + const QString finalPath = + QDir(directory.path()).filePath( + QStringLiteral("existing.mp4")); + const QByteArray sourceBytes("new-snapshot"); + const QByteArray existingBytes("must-survive"); + + QFile source(sourcePath); + QVERIFY(source.open(QIODevice::WriteOnly)); + QCOMPARE(source.write(sourceBytes), sourceBytes.size()); + source.close(); + QFile existing(finalPath); + QVERIFY(existing.open(QIODevice::WriteOnly)); + QCOMPARE(existing.write(existingBytes), existingBytes.size()); + existing.close(); + + const QFileInfo sourceInfo(sourcePath); + const MediaPreviewController::StageResult result = + MediaPreviewController::copySourceSnapshot( + sourcePath, finalPath, sourceInfo.size(), + sourceInfo.lastModified()); + QVERIFY(!result.error.isEmpty()); + + QVERIFY(existing.open(QIODevice::ReadOnly)); + QCOMPARE(existing.readAll(), existingBytes); + QVERIFY(!QFileInfo::exists( + finalPath + QStringLiteral(".part"))); +} + +void QuickClientTests:: + stageHelperRejectsSymlinkDotDotEscape() { + const QString inboxPath = tryxRuntimeMediaInboxPath(); + if (inboxPath.isEmpty()) { + QSKIP("Qt RuntimeLocation is unavailable"); + } + QString directoryError; + QVERIFY2( + MediaPreviewController::ensurePrivateDirectoryTree( + inboxPath, &directoryError), + qPrintable(directoryError)); + + QTemporaryDir sourceDirectory; + QVERIFY(sourceDirectory.isValid()); + const QString sourcePath = + QDir(sourceDirectory.path()).filePath( + QStringLiteral("source.png")); + QFile source(sourcePath); + QVERIFY(source.open(QIODevice::WriteOnly)); + QCOMPARE(source.write("snapshot"), qint64(8)); + source.close(); + + const QString escapeParent = + QDir(QFileInfo(inboxPath).absolutePath()).filePath( + QStringLiteral("escape-parent")); + const QString escapeChild = + QDir(escapeParent).filePath( + QStringLiteral("child")); + QVERIFY(QDir().mkpath(escapeChild)); + const QString linkPath = + QDir(inboxPath).filePath( + QStringLiteral("link")); + QVERIFY(QFile::link(escapeChild, linkPath)); + QVERIFY(QFileInfo(linkPath).isSymLink()); + + const QString fileName = + QStringLiteral("%1.png") + .arg(QUuid::createUuid().toString( + QUuid::WithoutBraces)); + const QString escapedPath = + linkPath + QStringLiteral("/../") + fileName; + const QString escapedTarget = + QDir(escapeParent).filePath(fileName); + QVERIFY(!MediaPreviewController::isManagedInboxPath( + escapedPath)); + + const QFileInfo sourceInfo(sourcePath); + QCOMPARE( + MediaPreviewController::runStageCopyHelper({ + sourcePath, + escapedPath, + QString::number(sourceInfo.size()), + QString::number( + sourceInfo.lastModified() + .toMSecsSinceEpoch()), + }), + 2); + QVERIFY(!QFileInfo::exists(escapedTarget)); +} + +void QuickClientTests:: + protectedInboxSourceSurvivesControllerLifetime() { + const QString inboxPath = tryxRuntimeMediaInboxPath(); + if (inboxPath.isEmpty()) { + QSKIP("Qt RuntimeLocation is unavailable"); + } + QString directoryError; + QVERIFY2( + MediaPreviewController::ensurePrivateDirectoryTree( + inboxPath, &directoryError), + qPrintable(directoryError)); + const QString stagedPath = + QDir(inboxPath).filePath( + QStringLiteral("%1.png") + .arg(QUuid::createUuid().toString( + QUuid::WithoutBraces))); + + QFile staged(stagedPath); + QVERIFY(staged.open( + QIODevice::WriteOnly | QIODevice::NewOnly)); + QCOMPARE(staged.write("snapshot"), qint64(8)); + staged.close(); + QVERIFY(QFile::setPermissions( + stagedPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + + { + MediaPreviewController preview; + preview.stagedPath_ = stagedPath; + preview.sourceKind_ = + MediaPreviewController::SourceKind::InboxSnapshot; + preview.ready_ = true; + QVERIFY(preview.protectStagedSource()); + } + QVERIFY(QFileInfo::exists(stagedPath)); + QVERIFY(QFile::remove(stagedPath)); +} + +void QuickClientTests:: + stagingTimeoutDoesNotBlockTheController() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString sourcePath = + QDir(directory.path()).filePath( + QStringLiteral("source.png")); + QImage source(64, 64, QImage::Format_RGB32); + source.fill(Qt::red); + QVERIFY(source.save(sourcePath, "PNG")); + + const QString blockingHelper = + QDir(directory.path()).filePath( + QStringLiteral("blocking-helper")); + QFile helper(blockingHelper); + QVERIFY(helper.open(QIODevice::WriteOnly)); + const QByteArray script("#!/bin/sh\nexec sleep 30\n"); + QCOMPARE(helper.write(script), script.size()); + helper.close(); + QVERIFY(QFile::setPermissions( + blockingHelper, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); + + MediaPreviewController preview; + preview.stageCopyProgram_ = blockingHelper; + preview.stagingDeadline_.setInterval(50); + const QFileInfo sourceInfo(sourcePath); + preview.startStaging( + sourcePath, QStringLiteral("png"), + sourceInfo.size(), sourceInfo.lastModified()); + QTRY_VERIFY_WITH_TIMEOUT(!preview.busy(), 2000); + QVERIFY(preview.error().contains( + QStringLiteral("timed out"), Qt::CaseInsensitive)); + QVERIFY(preview.pendingStagePath_.isEmpty()); + QVERIFY(preview.stageProcess_ == nullptr); + + const QString inboxPath = tryxRuntimeMediaInboxPath(); + QCOMPARE( + QDir(inboxPath).entryList( + QDir::Files | QDir::NoDotAndDotDot), + QStringList{}); + QTRY_VERIFY_WITH_TIMEOUT( + !MediaPreviewController:: + stageHelperDrainInProgress(), + 2000); +} + +void QuickClientTests:: + drainingStageHelperBlocksRepeatedStart() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString sourcePath = + QDir(directory.path()).filePath( + QStringLiteral("source.png")); + QImage source(64, 64, QImage::Format_RGB32); + source.fill(Qt::red); + QVERIFY(source.save(sourcePath, "PNG")); + + const QString blockingHelper = + QDir(directory.path()).filePath( + QStringLiteral("blocking-helper")); + QFile helper(blockingHelper); + QVERIFY(helper.open(QIODevice::WriteOnly)); + const QByteArray script("#!/bin/sh\nexec sleep 30\n"); + QCOMPARE(helper.write(script), script.size()); + helper.close(); + QVERIFY(QFile::setPermissions( + blockingHelper, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); + + MediaPreviewController first; + first.stageCopyProgram_ = blockingHelper; + const QFileInfo sourceInfo(sourcePath); + first.startStaging( + sourcePath, QStringLiteral("png"), + sourceInfo.size(), sourceInfo.lastModified()); + QTRY_VERIFY_WITH_TIMEOUT( + first.stageProcess_ != nullptr && + first.stageProcess_->state() == + QProcess::Running, + 2000); + first.cancel(); + QVERIFY( + MediaPreviewController:: + stageHelperDrainInProgress()); + + MediaPreviewController second; + second.stageCopyProgram_ = blockingHelper; + second.startStaging( + sourcePath, QStringLiteral("png"), + sourceInfo.size(), sourceInfo.lastModified()); + QVERIFY(second.stageProcess_ == nullptr); + QVERIFY(second.error().contains( + QStringLiteral("still stopping"), + Qt::CaseInsensitive)); + second.startStaging( + sourcePath, QStringLiteral("png"), + sourceInfo.size(), sourceInfo.lastModified()); + QVERIFY(second.stageProcess_ == nullptr); + QVERIFY(second.error().contains( + QStringLiteral("still stopping"), + Qt::CaseInsensitive)); + + QTRY_VERIFY_WITH_TIMEOUT( + !MediaPreviewController:: + stageHelperDrainInProgress(), + 2000); +} + +void QuickClientTests:: + previewControllerRendersImmutableExactFrame() { + if (QStandardPaths::findExecutable( + QStringLiteral("ffmpeg")).isEmpty()) { + QSKIP("ffmpeg is optional for the unit-test environment"); + } + + QTemporaryDir sourceDirectory; + QVERIFY(sourceDirectory.isValid()); + const QString sourcePath = + QDir(sourceDirectory.path()).filePath( + QStringLiteral("source.png")); + QImage source(320, 240, QImage::Format_RGB32); + source.fill(QColor(QStringLiteral("#dd2211"))); + QVERIFY(source.save(sourcePath, "PNG")); + + QString stagedPath; + QString firstPreviewPath; + QString secondPreviewPath; + { + MediaPreviewController preview; + preview.load(QUrl::fromLocalFile(sourcePath)); + QTRY_VERIFY_WITH_TIMEOUT(!preview.busy(), 30000); + QVERIFY2(preview.ready(), qPrintable(preview.error())); + stagedPath = preview.sourcePath(); + firstPreviewPath = + preview.previewUrl().toLocalFile(); + QVERIFY(QFileInfo::exists(stagedPath)); + + QImage firstPreview(firstPreviewPath); + QVERIFY(!firstPreview.isNull()); + QCOMPARE(firstPreview.size(), QSize(1120, 540)); + const QColor firstCenter = + firstPreview.pixelColor( + firstPreview.width() / 2, + firstPreview.height() / 2); + QVERIFY(firstCenter.red() > 180); + QVERIFY(firstCenter.blue() < 80); + + QImage replacement(320, 240, QImage::Format_RGB32); + replacement.fill(QColor(QStringLiteral("#1144dd"))); + QVERIFY(replacement.save(sourcePath, "PNG")); + + TryxRuntimeMediaTransform fill; + fill.mode = QStringLiteral("Fill"); + preview.setTransform(fill); + QTRY_VERIFY_WITH_TIMEOUT(!preview.busy(), 30000); + QVERIFY2(preview.ready(), qPrintable(preview.error())); + secondPreviewPath = + preview.previewUrl().toLocalFile(); + QVERIFY(firstPreviewPath != secondPreviewPath); + + QImage secondPreview(secondPreviewPath); + QVERIFY(!secondPreview.isNull()); + QCOMPARE(secondPreview.size(), QSize(1120, 540)); + const QColor secondCenter = + secondPreview.pixelColor( + secondPreview.width() / 2, + secondPreview.height() / 2); + QVERIFY(secondCenter.red() > 180); + QVERIFY(secondCenter.blue() < 80); + } + QVERIFY(!QFileInfo::exists(stagedPath)); + QVERIFY(!QFileInfo::exists(firstPreviewPath)); + QVERIFY(!QFileInfo::exists(secondPreviewPath)); +} + +void QuickClientTests::publicPreviewRejectsRawDeviceH264() { + QVERIFY(!MediaPreviewController::isSupportedSuffix( + QStringLiteral("h264"))); + QVERIFY(MediaPreviewController::isSupportedSuffix( + QStringLiteral("mp4"))); +} + +void QuickClientTests:: + exportHelperIsAtomicAndDoesNotClobber() { + const QString outbox = + tryxRuntimeDeviceMediaOutboxPath(); + QVERIFY(!outbox.isEmpty()); + QVERIFY(QDir().mkpath(outbox)); + QVERIFY(QFile::setPermissions( + outbox, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); + + const QString sourcePath = + QDir(outbox).filePath( + QUuid::createUuid().toString( + QUuid::WithoutBraces) + + QStringLiteral(".h264")); + const QByteArray payload( + "validated recovered H264 test bytes"); + QFile source(sourcePath); + QVERIFY(source.open( + QIODevice::WriteOnly | QIODevice::NewOnly)); + QCOMPARE(source.write(payload), + static_cast(payload.size())); + source.close(); + QVERIFY(QFile::setPermissions( + sourcePath, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + + QTemporaryDir destinationDirectory; + QVERIFY(destinationDirectory.isValid()); + const QString destinationPath = + QDir(destinationDirectory.path()).filePath( + QStringLiteral("export.h264")); + const QString expectedSha = + QString::fromLatin1( + QCryptographicHash::hash( + payload, QCryptographicHash::Sha256) + .toHex()); + const QStringList arguments{ + sourcePath, + destinationPath, + QString::number(payload.size()), + expectedSha, + QStringLiteral("0"), + }; + + QCOMPARE( + DeviceMediaWorkflowController::runExportHelper( + arguments), + 0); + QFile exported(destinationPath); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), payload); + exported.close(); + + QVERIFY(exported.open( + QIODevice::WriteOnly | + QIODevice::Truncate)); + QCOMPARE(exported.write("keep"), qint64(4)); + exported.close(); + QCOMPARE( + DeviceMediaWorkflowController::runExportHelper( + arguments), + 3); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), QByteArray("keep")); + exported.close(); + + QStringList overwriteArguments = arguments; + overwriteArguments[4] = QStringLiteral("1"); + QCOMPARE( + DeviceMediaWorkflowController::runExportHelper( + overwriteArguments), + 0); + QVERIFY(exported.open(QIODevice::ReadOnly)); + QCOMPARE(exported.readAll(), payload); + exported.close(); + QVERIFY(QFile::remove(sourcePath)); +} + +void QuickClientTests::qmlImportScannerFindsResolvedModules() { + const QString scanner = QString::fromLocal8Bit( + qgetenv("TRYX_QMLIMPORTSCANNER")); + const QString qmlRoot = QString::fromLocal8Bit( + qgetenv("TRYX_QML_ROOT")); + const QString importPath = QString::fromLocal8Bit( + qgetenv("TRYX_QML_IMPORT_PATH")); + if (scanner.isEmpty() || qmlRoot.isEmpty() || + importPath.isEmpty()) { + QFAIL( + "qmlimportscanner paths are required; run make quick-check"); + } + + QProcess process; + process.setProgram(scanner); + process.setArguments({ + QStringLiteral("-rootPath"), qmlRoot, + QStringLiteral("-importPath"), importPath, + }); + process.start(); + QVERIFY2( + process.waitForStarted(5000), + qPrintable(process.errorString())); + QVERIFY2( + process.waitForFinished(30000), + "qmlimportscanner did not finish within 30 seconds"); + const QByteArray standardError = + process.readAllStandardError(); + QCOMPARE(process.exitStatus(), QProcess::NormalExit); + QVERIFY2( + process.exitCode() == 0, + standardError.constData()); + + QJsonParseError parseError; + const QJsonDocument document = + QJsonDocument::fromJson( + process.readAllStandardOutput(), &parseError); + QCOMPARE(parseError.error, QJsonParseError::NoError); + QVERIFY(document.isArray()); + const QJsonArray imports = document.array(); + QVERIFY2(!imports.isEmpty(), + "qmlimportscanner returned no imports"); + + const QSet requiredModules{ + QStringLiteral("Qt.labs.folderlistmodel"), + QStringLiteral("QtQuick"), + QStringLiteral("QtQuick.Controls"), + QStringLiteral("QtQuick.Controls.Material"), + QStringLiteral("QtQuick.Dialogs"), + QStringLiteral("QtQuick.Layouts"), + }; + QSet resolvedRequiredModules; + for (const QJsonValue &value : imports) { + QVERIFY(value.isObject()); + const QJsonObject import = value.toObject(); + const QString type = + import.value(QStringLiteral("type")).toString(); + if (type != QStringLiteral("module") && + type != QStringLiteral("directory")) { + continue; + } + const QString name = + import.value(QStringLiteral("name")) + .toString() + .trimmed(); + const QString path = + import.value(QStringLiteral("path")) + .toString() + .trimmed(); + QVERIFY2( + !name.isEmpty(), + "qmlimportscanner returned an unnamed module or directory"); + if (type == QStringLiteral("module") && + requiredModules.contains(name)) { + QVERIFY2( + !path.isEmpty(), + qPrintable( + QStringLiteral( + "qmlimportscanner did not resolve required module %1") + .arg(name))); + resolvedRequiredModules.insert(name); + } + } + for (const QString &module : requiredModules) { + QVERIFY2( + resolvedRequiredModules.contains(module), + qPrintable( + QStringLiteral( + "qmlimportscanner omitted required module %1") + .arg(module))); + } +} + +void QuickClientTests::catalogRejectsStaleRevision() { + MediaCatalogModel model; + TryxRuntimeMediaCatalogSnapshot newer; + newer.revision = 5; + newer.deviceIdentity = QStringLiteral("device-a"); + TryxRuntimeMediaEntry entry; + entry.name = QStringLiteral("new.mp4.h264_2240x1080"); + newer.entries.append(entry); + model.applySnapshot(newer); + + TryxRuntimeMediaCatalogSnapshot stale; + stale.revision = 4; + stale.deviceIdentity = QStringLiteral("device-b"); + model.applySnapshot(stale); + + QCOMPARE(model.revision(), 5U); + QCOMPARE(model.deviceIdentity(), QStringLiteral("device-a")); + QCOMPARE(model.rowCount(), 1); + QCOMPARE( + model.data(model.index(0), MediaCatalogModel::NameRole) + .toString(), + entry.name); +} + +void QuickClientTests:: + catalogResolvesThumbnailFromConfiguredDataRoot() { + QTemporaryDir temporaryData; + QVERIFY(temporaryData.isValid()); + + const QString thumbnailKey(64, QLatin1Char('a')); + const QString thumbnailDirectory = + QDir(temporaryData.path()).filePath( + QStringLiteral("media-catalog/thumbnails")); + QVERIFY(QDir().mkpath(thumbnailDirectory)); + const QString thumbnailPath = + QDir(thumbnailDirectory).filePath( + thumbnailKey + QStringLiteral(".jpg")); + QFile thumbnail(thumbnailPath); + QVERIFY(thumbnail.open(QIODevice::WriteOnly)); + QCOMPARE(thumbnail.write("thumbnail"), 9); + thumbnail.close(); + + TryxRuntimeMediaEntry entry; + entry.name = + QStringLiteral("user.mp4.h264_2240x1080"); + entry.thumbnailKey = thumbnailKey; + + TryxRuntimeMediaCatalogSnapshot snapshot; + snapshot.revision = 1; + snapshot.deviceIdentity = QStringLiteral("device-a"); + snapshot.entries = {entry}; + + MediaCatalogModel model(temporaryData.path()); + model.applySnapshot(snapshot); + + QCOMPARE( + model.data( + model.index(0), + MediaCatalogModel::ThumbnailUrlRole).toUrl(), + QUrl::fromLocalFile(thumbnailPath)); +} + +void QuickClientTests::catalogExposesDeviceCopyEligibility() { + MediaCatalogModel model; + const QHash roles = model.roleNames(); + QCOMPARE( + roles.value(MediaCatalogModel::MediaIdRole), + QByteArray("mediaId")); + QCOMPARE( + roles.value(MediaCatalogModel::DeviceCopyAllowedRole), + QByteArray("deviceCopyAllowed")); + QCOMPARE( + roles.value(MediaCatalogModel::DeviceCopyBlockReasonRole), + QByteArray("deviceCopyBlockReason")); + + TryxRuntimeMediaEntry userMedia; + userMedia.name = + QStringLiteral("user.mp4.h264_2240x1080"); + userMedia.source = 1; + userMedia.mediaId = QStringLiteral("media-user"); + + TryxRuntimeMediaEntry presetMedia; + presetMedia.name = + QStringLiteral("preset.mp4.h264_2240x1080"); + presetMedia.source = 0; + presetMedia.mediaId = QStringLiteral("media-preset"); + presetMedia.readOnly = true; + + TryxRuntimeMediaCatalogSnapshot snapshot; + snapshot.revision = 1; + snapshot.deviceIdentity = QStringLiteral("device-a"); + snapshot.entries = {userMedia, presetMedia}; + model.applySnapshot(snapshot); + + QVERIFY(model.canStageDeviceCopy(userMedia.mediaId)); + QVERIFY(model.data( + model.index(0), + MediaCatalogModel::DeviceCopyAllowedRole).toBool()); + QVERIFY(!model.canStageDeviceCopy(presetMedia.mediaId)); + QVERIFY(!model.data( + model.index(1), + MediaCatalogModel::DeviceCopyAllowedRole).toBool()); + QVERIFY(!model.deviceCopyBlockReason( + presetMedia.mediaId).isEmpty()); + QVERIFY(!model.canStageDeviceCopy(QStringLiteral("missing"))); +} + +void QuickClientTests:: + legacyConnectionPopulatesCurrentMediaModel() { + RuntimeClient runtime(true); + runtime.serviceAvailable_ = true; + runtime.compatible_ = true; + + TryxRuntimeSnapshot snapshot; + snapshot.revision = 4; + snapshot.connected = true; + snapshot.serial = QStringLiteral("LEGACY-1"); + snapshot.mediaFiles = { + QStringLiteral("first.mp4"), + QStringLiteral("first.mp4"), + QString(), + QStringLiteral("second.gif"), + }; + runtime.applyConnectionSnapshot(snapshot); + + QVERIFY(runtime.ready()); + QVERIFY(runtime.legacyConnected()); + QVERIFY(runtime.connectionStatus().contains( + QStringLiteral("Legacy"))); + QCOMPARE(runtime.mediaModel()->rowCount(), 2); + QCOMPARE( + runtime.mediaModel()->deviceIdentity(), + QStringLiteral("legacy:LEGACY-1")); + const QModelIndex first = + runtime.mediaModel()->index(0, 0); + QVERIFY(first.data( + MediaCatalogModel::DeleteAllowedRole).toBool()); + QVERIFY(!first.data( + MediaCatalogModel::DeviceCopyAllowedRole).toBool()); + QVERIFY(first.data( + MediaCatalogModel::MediaIdRole).toString().isEmpty()); +} + +void QuickClientTests:: + legacyScreenConfigKeepsManager1Shape() { + TryxRuntimeApplyRequest request; + request.media = { + QStringLiteral("left.mp4"), + QStringLiteral("right.mp4"), + }; + request.ratio = QStringLiteral("2:1"); + request.screenMode = + QStringLiteral("Screen Splitting"); + request.playMode = QStringLiteral("Single"); + request.sysinfoLabels = + {QStringLiteral("CPU Temperature")}; + request.settingsPosition = QStringLiteral("Top"); + request.settingsColor = QStringLiteral("#dcdcdc"); + request.settingsAlign = QStringLiteral("Left"); + request.settingsBadges = + {QStringLiteral("CPU Badge")}; + request.filterOpacity = 12; + request.presetId = QStringLiteral("custom"); + request.sysinfoLabels2 = + {QStringLiteral("GPU Temperature")}; + request.settingsBadges2 = + {QStringLiteral("GPU Badge")}; + request.waterfallMode = true; + + const QVariantList arguments = + RuntimeClient::legacyScreenConfigArguments(request); + QCOMPARE(arguments.size(), 14); + QCOMPARE(arguments.at(0).toStringList(), request.media); + QCOMPARE(arguments.at(2).toString(), request.screenMode); + QCOMPARE(arguments.at(9).toInt(), request.filterOpacity); + QCOMPARE( + arguments.at(11).toStringList(), + request.sysinfoLabels2); + QCOMPARE(arguments.at(13).toBool(), true); +} + +void QuickClientTests::legacyTransformBoundaryIsExplicit() { + RuntimeClient runtime(true); + runtime.serviceAvailable_ = true; + runtime.compatible_ = true; + runtime.connection_.revision = 1; + runtime.connection_.connected = true; + + TryxRuntimeMediaTransform transform = + tryxLegacyFitMediaTransform(); + transform.mode = QStringLiteral("Fill"); + const QString operationId = + runtime.queueUploadWithTransform( + QStringLiteral("/tmp/source.mp4"), transform); + QVERIFY(operationId.isEmpty()); + QVERIFY(runtime.diagnostic().contains( + QStringLiteral("default Fit"))); + QVERIFY(!runtime.operationBusy()); +} + +void QuickClientTests:: + legacyUploadRetainsSourceUntilTerminalSignal() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString sourcePath = + QDir(directory.path()).filePath( + QStringLiteral("source.mp4")); + QFile source(sourcePath); + QVERIFY(source.open( + QIODevice::WriteOnly | QIODevice::NewOnly)); + QCOMPARE(source.write("legacy-upload"), qint64(13)); + source.close(); + QVERIFY(QFile::setPermissions( + sourcePath, + QFileDevice::ReadOwner | + QFileDevice::WriteOwner)); + + RuntimeClient runtime(true); + runtime.serviceAvailable_ = true; + runtime.compatible_ = true; + runtime.connection_.revision = 1; + runtime.connection_.connected = true; + + QString claimError; + const QString operationId = + QStringLiteral("legacy-operation"); + QVERIFY(runtime.claimLegacyUploadSource( + operationId, sourcePath, &claimError)); + QVERIFY(claimError.isEmpty()); + const QString claimedPath = + runtime.legacyUpload_.claimedPath; + QVERIFY(QFileInfo::exists(sourcePath)); + QVERIFY(QFileInfo::exists(claimedPath)); + QVERIFY(runtime.operationBusy()); + + QSignalSpy rejected( + &runtime, + &RuntimeClient::operationRequestRejected); + QSignalSpy accepted( + &runtime, + &RuntimeClient::operationRequestAccepted); + runtime.onLegacyUploadTimeout(); + QCOMPARE(rejected.count(), 1); + QVERIFY(runtime.operationBusy()); + QVERIFY(QFileInfo::exists(sourcePath)); + QVERIFY(QFileInfo::exists(claimedPath)); + + QVERIFY(QFile::remove(sourcePath)); + QVERIFY(QFileInfo::exists(claimedPath)); + runtime.onLegacyMediaUploaded( + QStringLiteral("uploaded.mp4"), 2); + QVERIFY(!runtime.operationBusy()); + QVERIFY(!QFileInfo::exists(claimedPath)); + QCOMPARE(accepted.count(), 0); +} + +void QuickClientTests::operationsExposeStableRoles() { + OperationListModel model; + const QHash roles = model.roleNames(); + QCOMPARE(roles.value(OperationListModel::StateRole), + QByteArray("operationState")); + QCOMPARE(roles.value(OperationListModel::ProgressRole), + QByteArray("progress")); + + TryxRuntimeOperationsSnapshot snapshot; + snapshot.revision = 1; + TryxRuntimeOperationInfo info; + info.id = QStringLiteral("operation"); + info.state = QStringLiteral("Uploading"); + info.completed = 25; + info.total = 100; + snapshot.operations.append(info); + model.applySnapshot(snapshot); + + QCOMPARE( + model.data(model.index(0), OperationListModel::ProgressRole) + .toDouble(), + 0.25); + QVERIFY(!model.data(model.index(0), + OperationListModel::TerminalRole) + .toBool()); +} + +void QuickClientTests::operationsRejectStaleEvents() { + OperationListModel model; + TryxRuntimeOperationInfo current; + current.id = QStringLiteral("current"); + current.state = QStringLiteral("Uploading"); + QVERIFY(model.upsert(current, 10)); + + TryxRuntimeOperationInfo stale = current; + stale.state = QStringLiteral("Failed"); + QVERIFY(!model.upsert(stale, 9)); + QCOMPARE( + model.data(model.index(0), OperationListModel::StateRole) + .toString(), + QStringLiteral("Uploading")); + + QVERIFY(!model.remove(current.id, 9)); + QCOMPARE(model.rowCount(), 1); + + TryxRuntimeOperationsSnapshot staleSnapshot; + staleSnapshot.revision = 8; + QVERIFY(!model.applySnapshot(staleSnapshot)); + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.revision(), 10U); +} + +void QuickClientTests:: + operationAcknowledgementRequiresExactIdentity() { + const QString expected = + QStringLiteral( + "11111111-1111-4111-8111-111111111111"); + TryxRuntimeOperationInfo observed; + + QVERIFY(RuntimeClient::operationAcknowledgementMatches( + expected, expected, observed)); + QVERIFY(!RuntimeClient::operationAcknowledgementMatches( + expected, + QStringLiteral( + "22222222-2222-4222-8222-222222222222"), + observed)); + QVERIFY(!RuntimeClient::operationAcknowledgementMatches( + expected, QString(), observed)); + + observed.id = expected; + QVERIFY(RuntimeClient::operationAcknowledgementMatches( + expected, QString(), observed)); + QVERIFY(RuntimeClient::operationAcknowledgementMatches( + expected, + QStringLiteral( + "22222222-2222-4222-8222-222222222222"), + observed)); + QVERIFY(!RuntimeClient::operationAcknowledgementMatches( + QString(), QString(), observed)); +} + +void QuickClientTests::succeededOperationClearsBusyState() { + RuntimeClient runtime(true); + runtime.compatible_ = true; + TryxRuntimeOperationInfo operation; + operation.id = QStringLiteral("operation"); + operation.state = QStringLiteral("Uploading"); + + QVERIFY(QMetaObject::invokeMethod( + &runtime, "onOperationChanged", Qt::DirectConnection, + Q_ARG(TryxRuntimeOperationInfo, operation), + Q_ARG(quint64, 1))); + QVERIFY(runtime.operationBusy()); + + operation.state = QStringLiteral("Succeeded"); + QVERIFY(QMetaObject::invokeMethod( + &runtime, "onOperationChanged", Qt::DirectConnection, + Q_ARG(TryxRuntimeOperationInfo, operation), + Q_ARG(quint64, 2))); + QVERIFY(!runtime.operationBusy()); + QVERIFY(OperationListModel::isTerminal(operation)); +} + +void QuickClientTests:: + deviceMediaWorkflowRejectsMismatchedClaimIdentity() { + const QString mediaId = QStringLiteral("media-1"); + const QString mediaName = + QStringLiteral("source.mp4.h264_2240x1080"); + const QString deviceIdentity = + QStringLiteral("device-1"); + const QString artifactId = + QStringLiteral("artifact-1"); + + RuntimeClient runtime(true); + preparePaseDeviceMedia( + &runtime, mediaId, mediaName, deviceIdentity); + MediaEditorController editor(&runtime); + DeviceMediaWorkflowController workflow( + &runtime, &editor); + + workflow.beginEdit(mediaId, mediaName); + QCOMPARE(runtime.offlineRequests_.size(), 1); + const QString stageOperationId = + runtime.offlineRequests_.constFirst().operationId; + QVERIFY(!stageOperationId.isEmpty()); + + TryxRuntimeOperationInfo staged; + staged.id = stageOperationId; + staged.kind = QStringLiteral("StageDeviceMedia"); + staged.state = QStringLiteral("Succeeded"); + staged.resultName = artifactId; + emit runtime.operationUpdated(staged); + + QCOMPARE(runtime.offlineRequests_.size(), 2); + QCOMPARE( + runtime.offlineRequests_.constLast().method, + QStringLiteral("ClaimDeviceMediaArtifact")); + + TryxRuntimeDeviceMediaArtifact stale = + deviceMediaArtifact( + QStringLiteral("stale-operation"), artifactId, + mediaId, mediaName, deviceIdentity); + emit runtime.artifactClaimed( + stageOperationId, stale); + + QCOMPARE(runtime.offlineRequests_.size(), 3); + const RuntimeClient::OfflineRequest release = + runtime.offlineRequests_.constLast(); + QCOMPARE( + release.method, + QStringLiteral("ReleaseDeviceMediaArtifact")); + QCOMPARE( + release.arguments, + QVariantList({artifactId, stale.leaseId})); + QVERIFY(!workflow.busy()); + QVERIFY(!workflow.error().isEmpty()); + QVERIFY(!editor.recoveredDeviceCopy()); + + const int requestCount = + runtime.offlineRequests_.size(); + emit runtime.artifactClaimed( + stageOperationId, + deviceMediaArtifact( + stageOperationId, artifactId, mediaId, + mediaName, deviceIdentity)); + QCOMPARE( + runtime.offlineRequests_.size(), requestCount); +} + +void QuickClientTests:: + deviceMediaWorkflowSaveAsNewCompletesAndReleasesLease() { + const QString mediaId = QStringLiteral("media-1"); + const QString mediaName = + QStringLiteral("source.mp4.h264_2240x1080"); + const QString deviceIdentity = + QStringLiteral("device-1"); + const QString artifactId = + QStringLiteral("artifact-1"); + + RuntimeClient runtime(true); + preparePaseDeviceMedia( + &runtime, mediaId, mediaName, deviceIdentity); + MediaEditorController editor(&runtime); + DeviceMediaWorkflowController workflow( + &runtime, &editor); + + workflow.beginEdit(mediaId, mediaName); + QCOMPARE(runtime.offlineRequests_.size(), 1); + const RuntimeClient::OfflineRequest stageRequest = + runtime.offlineRequests_.constFirst(); + QCOMPARE( + stageRequest.method, + QStringLiteral("QueueStageDeviceMedia")); + QCOMPARE( + stageRequest.arguments.at(1).toString(), mediaId); + QVERIFY(workflow.busy()); + + TryxRuntimeOperationInfo staleStage; + staleStage.id = QStringLiteral("stale-stage"); + staleStage.kind = QStringLiteral("StageDeviceMedia"); + staleStage.state = QStringLiteral("Succeeded"); + staleStage.resultName = artifactId; + emit runtime.operationUpdated(staleStage); + QCOMPARE(runtime.offlineRequests_.size(), 1); + + TryxRuntimeOperationInfo staged = staleStage; + staged.id = stageRequest.operationId; + emit runtime.operationUpdated(staged); + QCOMPARE(runtime.offlineRequests_.size(), 2); + const RuntimeClient::OfflineRequest claimRequest = + runtime.offlineRequests_.constLast(); + QCOMPARE( + claimRequest.method, + QStringLiteral("ClaimDeviceMediaArtifact")); + QCOMPARE( + claimRequest.arguments, + QVariantList( + {stageRequest.operationId, artifactId})); + + const TryxRuntimeDeviceMediaArtifact artifact = + deviceMediaArtifact( + stageRequest.operationId, artifactId, mediaId, + mediaName, deviceIdentity); + emit runtime.artifactClaimed( + QStringLiteral("stale-stage"), artifact); + QCOMPARE(runtime.offlineRequests_.size(), 2); + QVERIFY(workflow.claimPending_); + + emit runtime.artifactClaimed( + stageRequest.operationId, artifact); + QVERIFY(editor.recoveredDeviceCopy()); + QVERIFY(!workflow.claimPending_); + + QVERIFY(QMetaObject::invokeMethod( + &workflow.renewTimer_, "timeout", + Qt::DirectConnection)); + QCOMPARE(runtime.offlineRequests_.size(), 3); + QCOMPARE( + runtime.offlineRequests_.constLast().method, + QStringLiteral("RenewDeviceMediaArtifactLease")); + QCOMPARE( + runtime.offlineRequests_.constLast().arguments, + QVariantList({artifactId, artifact.leaseId})); + + emit editor.recoveredSaveAsNewRequested( + editor.transform()); + QCOMPARE(runtime.offlineRequests_.size(), 4); + const RuntimeClient::OfflineRequest mutationRequest = + runtime.offlineRequests_.constLast(); + QCOMPARE( + mutationRequest.method, + QStringLiteral( + "QueueRecoveredMediaUploadWithTransform")); + QCOMPARE( + mutationRequest.arguments.at(1).toString(), + artifactId); + QCOMPARE( + mutationRequest.arguments.at(2).toString(), + artifact.leaseId); + QVERIFY(editor.submissionPending()); + + TryxRuntimeOperationInfo staleMutation; + staleMutation.id = QStringLiteral("stale-mutation"); + staleMutation.kind = + QStringLiteral("RecoveredMediaUpload"); + staleMutation.state = QStringLiteral("Succeeded"); + emit runtime.operationUpdated(staleMutation); + QVERIFY(editor.submissionPending()); + QCOMPARE(runtime.offlineRequests_.size(), 4); + + TryxRuntimeOperationInfo completed = staleMutation; + completed.id = mutationRequest.operationId; + emit runtime.operationUpdated(completed); + + QCOMPARE(runtime.offlineRequests_.size(), 5); + QCOMPARE( + runtime.offlineRequests_.constLast().method, + QStringLiteral("ReleaseDeviceMediaArtifact")); + QCOMPARE( + runtime.offlineRequests_.constLast().arguments, + QVariantList({artifactId, artifact.leaseId})); + QVERIFY(!workflow.busy()); + QVERIFY(!editor.submissionPending()); + QVERIFY(!editor.recoveredDeviceCopy()); + + const int requestCount = + runtime.offlineRequests_.size(); + emit runtime.operationUpdated(completed); + emit editor.recoveredSaveAsNewRequested( + editor.transform()); + QCOMPARE( + runtime.offlineRequests_.size(), requestCount); +} + +void QuickClientTests:: + deviceMediaWorkflowInvalidationStopsReplaceMutations() { + const QString mediaId = QStringLiteral("media-1"); + const QString mediaName = + QStringLiteral("source.mp4.h264_2240x1080"); + const QString deviceIdentity = + QStringLiteral("device-1"); + const QString artifactId = + QStringLiteral("artifact-1"); + + RuntimeClient runtime(true); + preparePaseDeviceMedia( + &runtime, mediaId, mediaName, deviceIdentity); + MediaEditorController editor(&runtime); + DeviceMediaWorkflowController workflow( + &runtime, &editor); + + workflow.beginEdit(mediaId, mediaName); + const QString stageOperationId = + runtime.offlineRequests_.constFirst().operationId; + + TryxRuntimeOperationInfo staged; + staged.id = stageOperationId; + staged.kind = QStringLiteral("StageDeviceMedia"); + staged.state = QStringLiteral("Succeeded"); + staged.resultName = artifactId; + emit runtime.operationUpdated(staged); + + const TryxRuntimeDeviceMediaArtifact artifact = + deviceMediaArtifact( + stageOperationId, artifactId, mediaId, + mediaName, deviceIdentity); + emit runtime.artifactClaimed( + stageOperationId, artifact); + QVERIFY(editor.recoveredDeviceCopy()); + + emit editor.recoveredReplaceRequested( + editor.transform()); + QCOMPARE(runtime.offlineRequests_.size(), 3); + const RuntimeClient::OfflineRequest replaceRequest = + runtime.offlineRequests_.constLast(); + QCOMPARE( + replaceRequest.method, + QStringLiteral("QueueReplaceDeviceMedia")); + QVERIFY(editor.submissionPending()); + + const quint64 serviceEpoch = runtime.serviceEpoch_; + const int requestCount = + runtime.offlineRequests_.size(); + runtime.onServiceUnregistered( + tryxRuntimeServiceName()); + + QCOMPARE(runtime.serviceEpoch_, serviceEpoch + 1); + QCOMPARE( + runtime.offlineRequests_.size(), requestCount); + QVERIFY(!workflow.busy()); + QVERIFY(workflow.error().contains( + QStringLiteral("runtime"), + Qt::CaseInsensitive)); + QVERIFY(!editor.submissionPending()); + QVERIFY(!editor.recoveredDeviceCopy()); + + TryxRuntimeOperationInfo completed; + completed.id = replaceRequest.operationId; + completed.kind = QStringLiteral("ReplaceDeviceMedia"); + completed.state = QStringLiteral("Succeeded"); + completed.terminalOutcome = QStringLiteral("Replaced"); + emit runtime.operationUpdated(completed); + emit runtime.artifactClaimed( + stageOperationId, artifact); + emit editor.recoveredReplaceRequested( + editor.transform()); + QVERIFY(QMetaObject::invokeMethod( + &workflow.renewTimer_, "timeout", + Qt::DirectConnection)); + workflow.beginEdit(mediaId, mediaName); + + QCOMPARE( + runtime.offlineRequests_.size(), requestCount); +} + +void QuickClientTests:: + applyRequestPreservesConfirmedOverlaySettings() { + RuntimeClient runtime(true); + TryxRuntimeDisplayState state; + state.revision = 1; + state.valid = true; + state.settingsPosition = QStringLiteral("Bottom"); + state.settingsColor = QStringLiteral("#123456"); + state.settingsAlign = QStringLiteral("Center"); + state.settingsBadges = { + QStringLiteral("CPU Badge")}; + state.settingsPosition2 = QStringLiteral("Top"); + state.settingsColor2 = QStringLiteral("#654321"); + state.settingsAlign2 = QStringLiteral("Right"); + state.settingsBadges2 = { + QStringLiteral("GPU Badge")}; + runtime.applyDisplayState(state); + + const TryxRuntimeApplyRequest full = + runtime.fullScreenApplyRequest( + {QStringLiteral("full.mp4.h264_2240x1080")}, + QStringLiteral("Loop"), + {QStringLiteral("CPU Temperature")}, + {QStringLiteral("GPU Badge")}); + QCOMPARE( + full.settingsBadges, + QStringList{QStringLiteral("GPU Badge")}); + QCOMPARE(full.settingsPosition, state.settingsPosition); + QCOMPARE(full.settingsColor, state.settingsColor); + QCOMPARE(full.settingsAlign, state.settingsAlign); + + const TryxRuntimeApplyRequest split = + runtime.splitScreenApplyRequest( + QStringLiteral("left.mp4.h264_2240x1080"), + QStringLiteral("right.mp4.h264_2240x1080"), + {QStringLiteral("CPU Temperature")}, + {QStringLiteral("GPU Temperature")}, + {QStringLiteral("GPU Badge")}, + {QStringLiteral("CPU Badge")}); + QCOMPARE( + split.settingsBadges, + QStringList{QStringLiteral("GPU Badge")}); + QCOMPARE( + split.settingsBadges2, + QStringList{QStringLiteral("CPU Badge")}); + QCOMPARE(split.settingsPosition2, state.settingsPosition2); + QCOMPARE(split.settingsColor2, state.settingsColor2); + QCOMPARE(split.settingsAlign2, state.settingsAlign2); + + QString badgeError; + QVERIFY(runtime.badgesSelectionValid( + {QStringLiteral("CPU Badge"), + QStringLiteral("GPU Badge")}, + &badgeError)); + QVERIFY(!runtime.badgesSelectionValid( + {QStringLiteral("Unsupported Badge")}, + &badgeError)); + QVERIFY(!badgeError.isEmpty()); +} + +void QuickClientTests:: + metricsRequestUsesExplicitEnableDisableContract() { + RuntimeClient runtime(true); + TryxRuntimeMetricsState state; + state.revision = 1; + state.availableMetrics = { + QStringLiteral("CPU Temperature"), + QStringLiteral("GPU Temperature")}; + state.alignment = QStringLiteral("Right"); + state.textColor = 0x00123456; + runtime.applyMetricsState(state); + + TryxRuntimeMetricsConfigRequest request; + QString error; + QVERIFY(runtime.metricsConfigRequest( + false, + {QStringLiteral("CPU Temperature")}, + QStringLiteral("invalid"), + QStringLiteral("not-a-color"), + &request, &error)); + QVERIFY(request.metrics.isEmpty()); + QVERIFY(!request.enabled); + QCOMPARE(request.alignment, state.alignment); + QCOMPARE(request.textColor, state.textColor); + + QVERIFY(!runtime.metricsConfigRequest( + true, {}, QStringLiteral("Left"), + QStringLiteral("#dcdcdc"), &request, &error)); + QVERIFY(!error.isEmpty()); + + error.clear(); + QVERIFY(runtime.metricsConfigRequest( + true, + {QStringLiteral("CPU Temperature")}, + QStringLiteral("Center"), + QStringLiteral("#abcdef"), + &request, &error)); + QVERIFY(request.enabled); + QCOMPARE( + request.metrics, + QStringList{QStringLiteral("CPU Temperature")}); + QCOMPARE(request.alignment, QStringLiteral("Center")); + QCOMPARE(request.textColor, 0x00abcdefU); +} + +void QuickClientTests::systemMetricsModelMapsAvailability() { + SystemMetricsModel model(false, nullptr); + QSignalSpy changed( + &model, &SystemMetricsModel::metricsChanged); + + SystemMetrics sample; + sample.cpu.usagePercent = 17.5; + sample.cpu.usageAvailable = true; + sample.cpu.temperature = 54.0; + sample.cpu.temperatureAvailable = true; + sample.cpu.frequencyMHz = 4250.0; + sample.cpu.frequencyAvailable = true; + + GpuMetrics gpu; + gpu.name = QStringLiteral("Primary GPU"); + gpu.usagePercent = 42.0; + gpu.usageAvailable = true; + gpu.temperature = 61.0; + gpu.temperatureAvailable = true; + gpu.frequencyMHz = 2300.0; + gpu.frequencyAvailable = true; + gpu.vramUsedMB = 4096; + gpu.vramTotalMB = 8192; + sample.gpus.append(gpu); + + sample.ram.usagePercent = 33.0; + sample.ram.usageAvailable = true; + sample.ram.usedMB = 10240; + sample.ram.totalMB = 32768; + sample.disk.usagePercent = 58.0; + sample.disk.usageAvailable = true; + sample.disk.usedGB = 580; + sample.disk.totalGB = 1000; + sample.net.available = true; + sample.net.rxSpeedKBs = 125.5; + sample.net.txSpeedKBs = 12.25; + + model.applyMetrics(sample); + + QCOMPARE(changed.count(), 1); + QVERIFY(model.sampled()); + QVERIFY(model.cpuUsageAvailable()); + QCOMPARE(model.cpuUsage(), 17.5); + QCOMPARE(model.cpuTemperature(), 54.0); + QVERIFY(model.gpuPresent()); + QCOMPARE(model.gpuName(), QStringLiteral("Primary GPU")); + QCOMPARE(model.gpuVramUsedMB(), 4096); + QVERIFY(model.gpuVramAvailable()); + QCOMPARE(model.ramTotalMB(), 32768); + QVERIFY(model.diskUsageAvailable()); + QCOMPARE(model.diskTotalGB(), 1000); + QVERIFY(model.networkAvailable()); + QCOMPARE(model.rxSpeedKBs(), 125.5); + + sample.gpus.clear(); + model.applyMetrics(sample); + QVERIFY(!model.gpuPresent()); + QVERIFY(!model.gpuUsageAvailable()); + QCOMPARE(model.gpuName(), QString()); +} + +void QuickClientTests:: + appSettingsDefaultToEnglishAndPreserveConfig() { + QTemporaryDir configRoot; + QVERIFY(configRoot.isValid()); + + const bool hadConfigHome = + qEnvironmentVariableIsSet("XDG_CONFIG_HOME"); + const QByteArray previousConfigHome = + qgetenv("XDG_CONFIG_HOME"); + qputenv("XDG_CONFIG_HOME", + QFile::encodeName(configRoot.path())); + + { + AppSettingsController settings(true); + QCOMPARE(settings.language(), QStringLiteral("en")); + QVERIFY(settings.errorMessage().isEmpty()); + } + + panorama::Config config; + config.port = "ttyACM-test"; + config.brightness = 61; + config.keepalive_interval = 23; + config.language = "ru"; + config.pase_overlay_lease_mode = + "ping-and-overlay-lease"; + QVERIFY(panorama::ConfigManager::save_config(config)); + + { + AppSettingsController settings(true); + QCOMPARE(settings.language(), QStringLiteral("ru")); + QCOMPARE( + settings.devicePort(), + QString::fromStdString(config.port)); + QCOMPARE( + settings.keepaliveInterval(), + config.keepalive_interval); + QSignalSpy languageChanged( + &settings, + &AppSettingsController::languageChanged); + QSignalSpy deviceSettingsChanged( + &settings, + &AppSettingsController::deviceSettingsChanged); + settings.setLanguage(QStringLiteral("en")); + settings.setDevicePort( + QStringLiteral("/dev/ttyACM9")); + settings.setKeepaliveInterval(41); + QCOMPARE(settings.language(), QStringLiteral("en")); + QCOMPARE( + settings.devicePort(), + QStringLiteral("/dev/ttyACM9")); + QCOMPARE(settings.keepaliveInterval(), 41); + QCOMPARE(languageChanged.count(), 1); + QCOMPARE(deviceSettingsChanged.count(), 2); + QVERIFY(settings.errorMessage().isEmpty()); + } + + const auto saved = + panorama::ConfigManager::load_config(); + QVERIFY(saved.has_value()); + QCOMPARE(saved->language, std::string("en")); + QCOMPARE(saved->port, std::string("/dev/ttyACM9")); + QCOMPARE(saved->brightness, config.brightness); + QCOMPARE(saved->keepalive_interval, 41); + QCOMPARE(saved->pase_overlay_lease_mode, + config.pase_overlay_lease_mode); + + if (hadConfigHome) { + qputenv("XDG_CONFIG_HOME", previousConfigHome); + } else { + qunsetenv("XDG_CONFIG_HOME"); + } +} + +void QuickClientTests:: + windowChromeRejectsOperationsWithoutWindow() { + WindowChromeController chrome; + QVERIFY(!chrome.ready()); + QVERIFY(!chrome.maximized()); + QVERIFY(!chrome.trayAvailable()); + QVERIFY(!chrome.hiddenToTray()); + QVERIFY(!chrome.startMove()); + QVERIFY(!chrome.startResize(Qt::LeftEdge)); + QVERIFY(!chrome.startResize( + Qt::LeftEdge | Qt::RightEdge)); + QVERIFY(!chrome.handleCloseRequest()); + chrome.setTrayAvailable(true); + QVERIFY(chrome.trayAvailable()); + QVERIFY(!chrome.handleCloseRequest()); + QVERIFY(!chrome.hiddenToTray()); + chrome.showWindow(); + chrome.setTrayAvailable(false); + QVERIFY(!chrome.trayAvailable()); + chrome.minimize(); + chrome.toggleMaximized(); + chrome.closeWindow(); +} + +void QuickClientTests:: + windowChromeHidesAndRestoresOnlyWithTray() { + QWindow window; + window.resize(640, 480); + window.show(); + QTRY_VERIFY(window.isVisible()); + + WindowChromeController chrome; + chrome.setWindow(&window); + QVERIFY(chrome.ready()); + QVERIFY(!chrome.handleCloseRequest()); + QVERIFY(window.isVisible()); + + chrome.setTrayAvailable(true); + QVERIFY(chrome.handleCloseRequest()); + QVERIFY(chrome.hiddenToTray()); + QVERIFY(!window.isVisible()); + + chrome.showWindow(); + QTRY_VERIFY(window.isVisible()); + QVERIFY(!chrome.hiddenToTray()); + + QVERIFY(chrome.handleCloseRequest()); + QVERIFY(chrome.hiddenToTray()); + chrome.setTrayAvailable(false); + QTRY_VERIFY(window.isVisible()); + QVERIFY(!chrome.hiddenToTray()); +} + +int main(int argc, char **argv) { + if (argc > 1 && + qstrcmp(argv[1], "--internal-stage-copy") == 0) { + QStringList helperArguments; + for (int index = 2; index < argc; ++index) { + helperArguments.append( + QString::fromLocal8Bit(argv[index])); + } + return MediaPreviewController::runStageCopyHelper( + helperArguments); + } + + QTemporaryDir isolatedRuntime( + QDir(QDir::tempPath()).filePath( + QStringLiteral( + "tryx-quick-tests-runtime-XXXXXX"))); + if (!isolatedRuntime.isValid() || + !QFile::setPermissions( + isolatedRuntime.path(), + QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)) { + return 2; + } + qputenv( + "XDG_RUNTIME_DIR", + QFile::encodeName(isolatedRuntime.path())); + + qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); + QGuiApplication application(argc, argv); + QuickClientTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "tst_quickmodels.moc" diff --git a/tests/replacejournal_tests.cpp b/tests/replacejournal_tests.cpp new file mode 100644 index 0000000..f850105 --- /dev/null +++ b/tests/replacejournal_tests.cpp @@ -0,0 +1,358 @@ +#include "replacejournal.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +TryxReplaceJournalRecord validRecord() { + TryxReplaceJournalRecord record; + record.operationId = + QStringLiteral("11111111-1111-4111-8111-111111111111"); + record.deviceIdentity = QStringLiteral("PASE-TEST-001"); + record.deviceGeneration = 7; + record.originalMediaId = QString(64, QLatin1Char('a')); + record.originalRemoteName = + QStringLiteral("original.mp4.h264_2240x1080"); + record.originalSize = 123456; + record.artifactId = + QStringLiteral("22222222-2222-4222-8222-222222222222"); + record.decodedSha256 = QString(64, QLatin1Char('b')); + record.transformFingerprint = QString(64, QLatin1Char('c')); + record.applyFingerprint = QString(64, QLatin1Char('d')); + record.referenceNames = { + QStringLiteral("original.mp4.h264_2240x1080")}; + return record; +} + +bool writeRawFile(const QString &path, const QByteArray &payload, + QFileDevice::Permissions permissions) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || + file.write(payload) != payload.size()) { + return false; + } + file.close(); + return QFile::setPermissions(path, permissions); +} + +QByteArray readFile(const QString &path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +} // namespace + +class ReplaceJournalTests final : public QObject { + Q_OBJECT + +private slots: + void missingJournalIsNotAnError(); + void ownerOnlyRoundTripAndClear(); + void malformedAndUnexpectedFieldsFailClosed(); + void unsafeFilesystemEntriesFailClosed(); + void invalidWriteDoesNotReplaceValidJournal(); + void transitionsAreMonotonic(); + void terminalOriginalRetainedIsValidAndImmutable(); + void oversizedJournalIsRejected(); +}; + +void ReplaceJournalTests::missingJournalIsNotAnError() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + TryxReplaceJournal journal( + directory.filePath(QStringLiteral("replace-intent.json"))); + + const TryxReplaceJournalLoadResult loaded = journal.load(); + QCOMPARE(loaded.status, TryxReplaceJournalLoadStatus::Missing); + QVERIFY(loaded.error.isEmpty()); + QString error; + QVERIFY2(journal.clear(&error), qPrintable(error)); +} + +void ReplaceJournalTests::ownerOnlyRoundTripAndClear() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = + directory.filePath(QStringLiteral("replace-intent.json")); + TryxReplaceJournal journal(path); + const TryxReplaceJournalRecord expected = validRecord(); + + QString error; + QVERIFY2(journal.write(expected, &error), qPrintable(error)); + + struct stat status {}; + QVERIFY(::lstat(QFile::encodeName(path).constData(), &status) == 0); + QVERIFY(S_ISREG(status.st_mode)); + QCOMPARE(status.st_uid, ::geteuid()); + QCOMPARE(status.st_mode & 07777, + static_cast(S_IRUSR | S_IWUSR)); + QCOMPARE(status.st_nlink, static_cast(1)); + QVERIFY(status.st_size > 0); + QVERIFY(status.st_size <= TryxReplaceJournal::MaximumBytes); + + const TryxReplaceJournalLoadResult loaded = journal.load(); + QCOMPARE(loaded.status, TryxReplaceJournalLoadStatus::Loaded); + QVERIFY(loaded.error.isEmpty()); + QCOMPARE(loaded.record.operationId, expected.operationId); + QCOMPARE(loaded.record.deviceIdentity, expected.deviceIdentity); + QCOMPARE(loaded.record.deviceGeneration, + expected.deviceGeneration); + QCOMPARE(loaded.record.originalMediaId, expected.originalMediaId); + QCOMPARE(loaded.record.originalRemoteName, + expected.originalRemoteName); + QCOMPARE(loaded.record.originalSize, expected.originalSize); + QCOMPARE(loaded.record.artifactId, expected.artifactId); + QCOMPARE(loaded.record.decodedSha256, expected.decodedSha256); + QCOMPARE(loaded.record.transformFingerprint, + expected.transformFingerprint); + QCOMPARE(loaded.record.applyFingerprint, + expected.applyFingerprint); + QCOMPARE(loaded.record.referenceNames, expected.referenceNames); + QCOMPARE(loaded.record.stage, QStringLiteral("Preflight")); + QCOMPARE(loaded.record.disposition, + QStringLiteral("OriginalRetained")); + + QVERIFY2(journal.clear(&error), qPrintable(error)); + QVERIFY(!QFileInfo::exists(path)); + QCOMPARE(journal.load().status, + TryxReplaceJournalLoadStatus::Missing); +} + +void ReplaceJournalTests:: + malformedAndUnexpectedFieldsFailClosed() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = + directory.filePath(QStringLiteral("replace-intent.json")); + TryxReplaceJournal journal(path); + const auto ownerOnly = + QFileDevice::ReadOwner | QFileDevice::WriteOwner; + + QVERIFY(writeRawFile(path, QByteArray("{"), + ownerOnly)); + TryxReplaceJournalLoadResult loaded = journal.load(); + QCOMPARE(loaded.status, TryxReplaceJournalLoadStatus::Invalid); + QVERIFY(!loaded.error.isEmpty()); + QString error; + QVERIFY(!journal.clear(&error)); + QVERIFY(QFileInfo::exists(path)); + QVERIFY(!journal.write(validRecord(), &error)); + QVERIFY(!error.isEmpty()); + + QVERIFY(QFile::remove(path)); + QVERIFY2(journal.write(validRecord(), &error), qPrintable(error)); + QJsonDocument document = + QJsonDocument::fromJson(readFile(path)); + QVERIFY(document.isObject()); + QJsonObject object = document.object(); + object.insert(QStringLiteral("unexpected"), true); + QVERIFY(writeRawFile( + path, QJsonDocument(object).toJson(QJsonDocument::Compact), + ownerOnly)); + loaded = journal.load(); + QCOMPARE(loaded.status, TryxReplaceJournalLoadStatus::Invalid); + + object.remove(QStringLiteral("unexpected")); + object.insert(QStringLiteral("version"), 1.5); + QVERIFY(writeRawFile( + path, QJsonDocument(object).toJson(QJsonDocument::Compact), + ownerOnly)); + loaded = journal.load(); + QCOMPARE(loaded.status, TryxReplaceJournalLoadStatus::Invalid); + + object.insert(QStringLiteral("version"), + TryxReplaceJournal::FormatVersion); + object.insert(QStringLiteral("uploadVerified"), + QStringLiteral("true")); + QVERIFY(writeRawFile( + path, QJsonDocument(object).toJson(QJsonDocument::Compact), + ownerOnly)); + loaded = journal.load(); + QCOMPARE(loaded.status, TryxReplaceJournalLoadStatus::Invalid); +} + +void ReplaceJournalTests::unsafeFilesystemEntriesFailClosed() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = + directory.filePath(QStringLiteral("replace-intent.json")); + const QString target = + directory.filePath(QStringLiteral("target.json")); + const auto ownerOnly = + QFileDevice::ReadOwner | QFileDevice::WriteOwner; + QVERIFY(writeRawFile(target, QByteArray("{}"), ownerOnly)); + QVERIFY(::symlink( + QFile::encodeName(target).constData(), + QFile::encodeName(path).constData()) == 0); + + TryxReplaceJournal symlinkJournal(path); + QCOMPARE(symlinkJournal.load().status, + TryxReplaceJournalLoadStatus::Invalid); + QString error; + QVERIFY(!symlinkJournal.clear(&error)); + QVERIFY(QFileInfo(path).isSymLink()); + QVERIFY(!symlinkJournal.write(validRecord(), &error)); + + QVERIFY(QFile::remove(path)); + TryxReplaceJournal modeJournal(path); + QVERIFY2(modeJournal.write(validRecord(), &error), + qPrintable(error)); + QVERIFY(QFile::setPermissions( + path, QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ReadGroup)); + QCOMPARE(modeJournal.load().status, + TryxReplaceJournalLoadStatus::Invalid); + QVERIFY(!modeJournal.clear(&error)); + + QVERIFY(QFile::remove(path)); + QVERIFY2(modeJournal.write(validRecord(), &error), + qPrintable(error)); + const QString alias = + directory.filePath(QStringLiteral("replace-hardlink.json")); + QVERIFY(::link(QFile::encodeName(path).constData(), + QFile::encodeName(alias).constData()) == 0); + QCOMPARE(modeJournal.load().status, + TryxReplaceJournalLoadStatus::Invalid); + QVERIFY(!modeJournal.clear(&error)); + QVERIFY(QFileInfo::exists(path)); + QVERIFY(QFileInfo::exists(alias)); +} + +void ReplaceJournalTests:: + invalidWriteDoesNotReplaceValidJournal() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = + directory.filePath(QStringLiteral("replace-intent.json")); + TryxReplaceJournal journal(path); + QString error; + QVERIFY2(journal.write(validRecord(), &error), qPrintable(error)); + const QByteArray before = readFile(path); + QVERIFY(!before.isEmpty()); + + TryxReplaceJournalRecord invalid = validRecord(); + invalid.decodedSha256 = QStringLiteral("not-a-sha256"); + QVERIFY(!journal.write(invalid, &error)); + QCOMPARE(readFile(path), before); + + invalid = validRecord(); + invalid.newRemoteName = + QStringLiteral("new.mp4.h264_2240x1080"); + QVERIFY(!journal.write(invalid, &error)); + QCOMPARE(readFile(path), before); + QCOMPARE(journal.load().status, + TryxReplaceJournalLoadStatus::Loaded); +} + +void ReplaceJournalTests::transitionsAreMonotonic() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = + directory.filePath(QStringLiteral("replace-intent.json")); + TryxReplaceJournal journal(path); + QString error; + const TryxReplaceJournalRecord initial = validRecord(); + QVERIFY2(journal.write(initial, &error), qPrintable(error)); + + TryxReplaceJournalRecord uploaded = initial; + uploaded.newRemoteName = + QStringLiteral("new.mp4.h264_2240x1080"); + uploaded.newSize = 654321; + uploaded.uploadVerified = true; + uploaded.stage = QStringLiteral("UploadVerified"); + uploaded.disposition = QStringLiteral("NewCopyReady"); + QVERIFY2(journal.write(uploaded, &error), qPrintable(error)); + + TryxReplaceJournalRecord regressed = uploaded; + regressed.stage = QStringLiteral("Uploading"); + QVERIFY(!journal.write(regressed, &error)); + + TryxReplaceJournalRecord changedIdentity = uploaded; + changedIdentity.artifactId = + QStringLiteral("33333333-3333-4333-8333-333333333333"); + QVERIFY(!journal.write(changedIdentity, &error)); + + TryxReplaceJournalRecord unverifiedDelete = uploaded; + unverifiedDelete.stage = + QStringLiteral("DeleteIntentLinked"); + unverifiedDelete.deleteIntentLinked = true; + QVERIFY(!journal.write(unverifiedDelete, &error)); + + TryxReplaceJournalRecord deleting = uploaded; + deleting.stage = QStringLiteral("Deleting"); + deleting.applyMayHaveStarted = true; + deleting.applyVerified = true; + deleting.deleteIntentLinked = true; + deleting.fileRemoveMayHaveStarted = true; + QVERIFY2(journal.write(deleting, &error), qPrintable(error)); + + TryxReplaceJournalRecord replaced = deleting; + replaced.stage = QStringLiteral("Terminal"); + replaced.disposition = QStringLiteral("Replaced"); + QVERIFY2(journal.write(replaced, &error), qPrintable(error)); + + TryxReplaceJournalRecord afterTerminal = replaced; + afterTerminal.disposition = QStringLiteral("PartialOrUnknown"); + QVERIFY(!journal.write(afterTerminal, &error)); +} + +void ReplaceJournalTests:: + terminalOriginalRetainedIsValidAndImmutable() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + TryxReplaceJournal journal( + directory.filePath(QStringLiteral("replace-intent.json"))); + QString error; + const TryxReplaceJournalRecord initial = validRecord(); + QVERIFY2(journal.write(initial, &error), qPrintable(error)); + + TryxReplaceJournalRecord uncertainTerminal = initial; + uncertainTerminal.stage = QStringLiteral("Terminal"); + uncertainTerminal.disposition = + QStringLiteral("PartialOrUnknown"); + QVERIFY(!journal.write(uncertainTerminal, &error)); + + TryxReplaceJournalRecord terminal = initial; + terminal.stage = QStringLiteral("Terminal"); + QVERIFY2(journal.write(terminal, &error), qPrintable(error)); + QCOMPARE(journal.load().record.disposition, + QStringLiteral("OriginalRetained")); + + TryxReplaceJournalRecord changed = terminal; + changed.disposition = QStringLiteral("PartialOrUnknown"); + QVERIFY(!journal.write(changed, &error)); + QVERIFY2(journal.write(terminal, &error), qPrintable(error)); +} + +void ReplaceJournalTests::oversizedJournalIsRejected() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString path = + directory.filePath(QStringLiteral("replace-intent.json")); + const QByteArray oversized( + TryxReplaceJournal::MaximumBytes + 1, 'x'); + QVERIFY(writeRawFile( + path, oversized, + QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + + TryxReplaceJournal journal(path); + const TryxReplaceJournalLoadResult loaded = journal.load(); + QCOMPARE(loaded.status, TryxReplaceJournalLoadStatus::Invalid); + QVERIFY(!loaded.error.isEmpty()); +} + +QTEST_APPLESS_MAIN(ReplaceJournalTests) + +#include "replacejournal_tests.moc" diff --git a/tests/replacejournal_tests.pro b/tests/replacejournal_tests.pro new file mode 100644 index 0000000..fd457d4 --- /dev/null +++ b/tests/replacejournal_tests.pro @@ -0,0 +1,19 @@ +QT += core testlib + +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = replacejournal-tests + +INCLUDEPATH += $$PWD/../src + +DESTDIR = $$PWD/../build/replacejournal-tests +OBJECTS_DIR = $$PWD/../build/replacejournal-tests/obj +MOC_DIR = $$PWD/../build/replacejournal-tests/moc + +HEADERS += \ + $$PWD/../src/replacejournal.h + +SOURCES += \ + replacejournal_tests.cpp \ + $$PWD/../src/replacejournal.cpp diff --git a/translations/tryx-panorama_ru.ts b/translations/tryx-panorama_ru.ts index 001ff42..83e8562 100644 --- a/translations/tryx-panorama_ru.ts +++ b/translations/tryx-panorama_ru.ts @@ -1,1266 +1,1931 @@ + + AppSettingsController + + + systemctl failed with exit code %1 + systemctl завершился с кодом %1 + + + + + The application settings file is unreadable or invalid + Файл настроек приложения недоступен для чтения или содержит ошибки + + + + Unsupported application language setting: %1 + В настройках указан неподдерживаемый язык приложения: %1 + + + + Failed to read application settings: %1 + Не удалось прочитать настройки приложения: %1 + + + + Unsupported application language: %1 + Неподдерживаемый язык приложения: %1 + + + + Failed to save the application language + Не удалось сохранить язык приложения + + + + Failed to save application settings: %1 + Не удалось сохранить настройки приложения: %1 + + + + Autostart management is unavailable in offline mode + Управление автозапуском недоступно в автономном режиме + + + + + Another autostart operation is still in progress + Другая операция с автозапуском ещё выполняется + + + + Timed out while checking autostart + Истекло время ожидания проверки автозапуска + + + + Timed out while changing autostart + Истекло время ожидания изменения автозапуска + + + + Failed to start systemctl: %1 + Не удалось запустить systemctl: %1 + + DeviceManager - + USB printer-class USB printer-class - + Rotation is not supported on printer-class firmware yet. Поворот пока не поддерживается на printer-class прошивке. - + Reboot is not supported on printer-class firmware yet. Перезагрузка пока не поддерживается на printer-class прошивке. - - + + Waiting for TRYX device. Reconnect USB or keep Auto connection selected. Ожидание устройства TRYX. Переподключите USB или оставьте выбранным Auto connection. - + The restarted TRYX runtime API could not be verified: %1 Не удалось проверить API перезапущенной фоновой службы TRYX: %1 - + The restarted TRYX runtime uses API %1, but this GUI requires API %2 Перезапущенная фоновая служба TRYX использует API %1, а графическому интерфейсу требуется API %2 - + Failed to read the media catalog: %1 Не удалось прочитать каталог медиафайлов: %1 - + Failed to read the TRYX background runtime state: %1 Не удалось прочитать состояние фоновой службы TRYX: %1 - + Failed to read background operation state: %1 Не удалось прочитать состояние фоновой операции: %1 - + Failed to read PASE metrics state: %1 Не удалось прочитать состояние метрик PASE: %1 - + Background runtime call %1 was blocked until its API is verified Вызов фоновой службы %1 заблокирован до проверки её API - + Background runtime call %1 failed: %2 Вызов %1 фоновой службы завершился ошибкой: %2 - + Background operation %1 was blocked until the runtime API is verified Фоновая операция %1 заблокирована до проверки API службы - + Background operation call %1 failed: %2 Вызов фоновой операции %1 завершился ошибкой: %2 - + Submitting operation to the background runtime... Операция передаётся фоновой службе... - + TRYX background runtime stopped Фоновая служба TRYX остановлена - + PASE display session was lost Сеанс дисплея PASE потерян - + PASE display session is lost; waiting for a new USB endpoint generation Сеанс дисплея PASE потерян; ожидается новое поколение USB-подключения - + The PASE transfer ended in an unknown partial state. Power-cycle the device before Retry or Save; the prepared media has been preserved. Передача на PASE завершилась в неопределённом частичном состоянии. Выполните полное выключение и включение устройства перед повтором или сохранением. Подготовленный файл сохранён. - + USB changed before uploaded media could be verified USB-подключение изменилось до проверки загруженного медиафайла - + Upload acknowledged; verifying the device file list... Загрузка подтверждена; проверяется список файлов устройства... - + USB changed before FileList could be reconciled USB-подключение изменилось до завершения сверки FileList - + The media is present on the device, but its preview could not be committed. Retry will publish the preview without retransmitting the media Медиафайл есть на устройстве, но не удалось сохранить его превью. Повтор опубликует превью без повторной передачи медиафайла - - - - - - + + + + + + Operation cancelled by the user Операция отменена пользователем - + The media is already on the device; applying it without conversion or upload... Медиафайл уже находится на устройстве. Применение без конвертации и загрузки... - + No confirmed existing copy was found; preparing media for upload... Подтверждённая копия не найдена. Подготовка медиафайла к загрузке... - + An existing copy was found, but the retry manifest could not be removed Существующая копия найдена, но удалить манифест повтора не удалось - + A confirmed copy already exists; applying it without retransmission... Подтверждённая копия уже существует. Применение без повторной передачи... - + A confirmed copy already exists; media data was not retransmitted Подтверждённая копия уже существует. Данные медиафайла повторно не передавались - + The previous upload is present, but its content identity could not be persisted: %1 Предыдущая загрузка присутствует, но не удалось сохранить идентификатор её содержимого: %1 - + The media and preview were verified, but the retry manifest could not be removed Медиафайл и превью проверены, но не удалось удалить манифест повтора - + Could not allocate a unique media name for retry Не удалось подобрать уникальное имя медиафайла для повтора - - + + Cannot persist the new retry target before upload: %1 Не удалось сохранить новую цель повтора перед загрузкой: %1 - - + + Failed to read PASE display state: %1 Не удалось прочитать состояние дисплея PASE: %1 - + PASE reconnected with an unverified device identity after the final upload acknowledgement was lost. Power-cycle the device before a manual retry. После потери финального подтверждения загрузки PASE подключена повторно, но её идентификатор не подтверждён. Полностью выключите и включите устройство перед ручным повтором. - + The completed upload could not be reconciled safely because the USB device identity changed Не удалось безопасно сверить завершённую загрузку, поскольку идентификатор USB-устройства изменился - + The final upload acknowledgement was lost; verifying FileList without retransmitting media... Финальное подтверждение загрузки потеряно. FileList проверяется без повторной передачи медиафайла... - + PASE did not recover far enough to verify the committed upload. Power-cycle the device before a manual retry. PASE не восстановилась до состояния, позволяющего проверить зафиксированную загрузку. Полностью выключите и включите устройство перед ручным повтором. - + The final upload acknowledgement was lost and the read-only FileList reconciliation could not start Финальное подтверждение загрузки потеряно, а сверку FileList только для чтения запустить не удалось - + + The device media copy could not be staged safely + Не удалось безопасно подготовить копию медиафайла с устройства + + + + The staged device media artifact failed its final identity check + Подготовленный артефакт медиафайла с устройства не прошёл окончательную проверку подлинности + + + + Device media copy was staged and validated + Копия медиафайла с устройства подготовлена и проверена + + + + The replace preflight returned a different media identity + Предварительная проверка замены вернула другой идентификатор медиафайла + + + + The new copy is active, but the original was retained because its references could not be re-read + Новая копия активна, но оригинал сохранён, поскольку не удалось повторно прочитать ссылки на него + + + + The new copy is active, but the original was retained: %1 + Новая копия активна, но оригинал сохранён: %1 + + + + The previous Apply outcome is still unknown; no mutation was repeated + Результат предыдущего применения по-прежнему неизвестен. Изменения не выполнялись повторно + + + + The previous Apply outcome is still unknown: %1 + Результат предыдущего применения по-прежнему неизвестен: %1 + + + + The original media references could not be verified + Не удалось проверить ссылки на исходный медиафайл + + + + The device returned an incomplete media reference set + Устройство вернуло неполный набор ссылок на медиафайл + + + + Apply was not repeated, but the read-only reconciliation could not be persisted: %1 + Применение не выполнялось повторно, но не удалось сохранить результат сверки только для чтения: %1 + + + The previous Apply was reconciled without repeating it. The new copy is ready and the original was retained. + Результат предыдущего применения сверен без его повторного выполнения. Новая копия готова, оригинал сохранён. + + + The previous Apply was reconciled without repeating it. The original is still referenced by: %1 + Результат предыдущего применения сверен без его повторного выполнения. На оригинал по-прежнему ссылаются: %1 + + + + The new copy is active, but the original media is still referenced by: %1 + Новая копия активна, но на исходный медиафайл по-прежнему ссылаются: %1 + + + + The new copy is active, but the original was retained because replace state could not be persisted: %1 + Новая копия активна, но оригинал сохранён, поскольку не удалось сохранить состояние замены: %1 + + + + The new copy is active, but the original was retained because delete intent could not be persisted: %1 + Новая копия активна, но оригинал сохранён, поскольку не удалось сохранить намерение удаления: %1 + + + + The new copy is active, but the original was retained because the replace/delete link could not be persisted: %1 + Новая копия активна, но оригинал сохранён, поскольку не удалось сохранить связь между заменой и удалением: %1 + + + + The new copy is active, but the original was retained because the delete boundary could not be persisted: %1 + Новая копия активна, но оригинал сохранён, поскольку не удалось сохранить границу удаления: %1 + + + + No references to the original remain; deleting it once... + Ссылок на оригинал больше нет. Выполняется однократное удаление... + + + + The original media is not referenced by the active layout; use Save as new instead + Активная компоновка не ссылается на исходный медиафайл. Используйте «Сохранить как новое» + + + + Replace is blocked because the original media is also referenced by: %1 + Замена заблокирована, поскольку на исходный медиафайл также ссылаются: %1 + + + + + Replacement was stopped before upload because its journal could not be persisted: %1 + Замена остановлена до загрузки, поскольку не удалось сохранить её журнал: %1 + + + + Reference preflight passed; preparing the replacement media... + Предварительная проверка ссылок пройдена. Подготавливается замена медиафайла... + + + All media data was acknowledged, but the final status was lost. Recovering the session to verify FileList without retransmission... Все данные медиафайла подтверждены, но финальный статус потерян. Сеанс восстанавливается для проверки FileList без повторной передачи... - + Cannot persist pending upload finalization reconciliation: %1 Не удалось сохранить состояние ожидающей сверки завершения загрузки: %1 - + Could not allocate a unique media name for the recovered transfer Не удалось подобрать уникальное имя медиафайла для восстановленной передачи - + Retry preflight completed; the preserved media will be transferred under a new device filename Предварительная проверка повтора завершена. Сохранённый медиафайл будет передан под новым именем на устройстве - + Retry preflight completed Предварительная проверка повтора завершена - + The final upload status was lost and FileList did not confirm the exact file. Power-cycle PASE before a manual retry. Финальный статус загрузки потерян, а FileList не подтвердил точный файл. Полностью выключите и включите PASE перед ручным повтором. - + The device acknowledged upload completion, but %1 does not match the prepared file size or source in FileList Устройство подтвердило завершение загрузки, но %1 не совпадает с размером или источником подготовленного файла в FileList - + The device acknowledged upload completion, but %1 is absent from FileList Устройство подтвердило завершение загрузки, но %1 отсутствует в FileList - + + The new copy is verified, but Apply was not started because replace state could not be persisted: %1 + Новая копия проверена, но применение не запущено, поскольку не удалось сохранить состояние замены: %1 + + + The media is present on the device, but its content identity could not be persisted: %1 Медиафайл присутствует на устройстве, но не удалось сохранить идентификатор его содержимого: %1 - + The upload was verified, but the retry manifest could not be removed Загрузка проверена, но не удалось удалить манифест повтора - + Upload completed before cancellation; apply was skipped Загрузка завершилась до отмены; применение пропущено - + Upload completed before cancellation Загрузка завершилась до отмены - + + The new copy is ready, but the explicit layout no longer references the original media + Новая копия готова, но явно заданная компоновка больше не ссылается на исходный медиафайл + + + + The new copy is ready, but Apply was not started because replace state could not be persisted: %1 + Новая копия готова, но применение не запущено, поскольку не удалось сохранить состояние замены: %1 + + + Applying the verified media... Применяется проверенный медиафайл... - + Media uploaded and verified Медиафайл загружен и проверен - + The existing-media check failed; upload was not started: %1 Проверка существующего медиафайла завершилась ошибкой. Загрузка не запускалась: %1 - + The final upload status was lost and FileList could not be read. Power-cycle PASE before a manual retry. Финальный статус загрузки потерян, а FileList прочитать не удалось. Полностью выключите и включите PASE перед ручным повтором. - + + The PASE connection changed before the replacement delete result could be associated with the original device + Подключение PASE изменилось до того, как результат удаления при замене удалось связать с исходным устройством + + + + The fresh FileList does not contain the exact verified replacement copy. Replace remains unresolved and no mutation was repeated. + В свежем FileList нет точной подтверждённой новой копии. Состояние замены остаётся неопределённым, повторных изменений не выполнялось. + + + Deletion is confirmed, but its intent journal could not be removed: %1 Удаление подтверждено, но не удалось удалить журнал намерения: %1 - + + Replacement uploaded, applied and the original media was deleted + Замена загружена и применена, исходный медиафайл удалён + + + Media file deleted and verified through FileList Медиафайл удалён, результат подтверждён через FileList - + %1 media files deleted and verified through FileList Удалено медиафайлов: %1. Результат подтверждён через FileList - + + The original media is still present, but stale delete state could not be cleared: %1 + Исходный медиафайл всё ещё присутствует, но устаревшее состояние удаления не удалось очистить: %1 + + + + Read-only FileList confirmed that the original media is still present. FileRemove was not repeated. + Чтение FileList подтвердило, что исходный медиафайл всё ещё присутствует. FileRemove не выполнялся повторно. + + + + The previous FileRemove outcome is still unknown; only FileList reconciliation may be retried + Результат предыдущего FileRemove всё ещё неизвестен. Повторно можно выполнить только сверку через FileList + + + + The previous FileRemove outcome is still unknown: %1 + Результат предыдущего FileRemove всё ещё неизвестен: %1 + + + Delete outcome is unknown. FileRemove will not be repeated; only FileList reconciliation is allowed: %1 Результат удаления неизвестен. FileRemove не будет отправлен повторно, разрешена только сверка через FileList: %1 - + Delete did not complete, but its intent journal could not be cleared: %1 Удаление не завершено, но очистить журнал намерения не удалось: %1 - + + The replacement is active, but the original media was retained: %1 + Замена активна, но исходный медиафайл сохранён: %1 + + + USB changed before the applied PASE configuration could be associated with its original device USB-подключение изменилось до того, как применённую конфигурацию PASE удалось связать с исходным устройством - - + + The PASE metrics layout was applied but could not be persisted: %1 Разметка метрик PASE применена, но сохранить её не удалось: %1 - + + The new copy is active, but the original was retained because Apply verification could not be persisted: %1 + Новая копия активна, но оригинал сохранён, поскольку не удалось сохранить результат проверки применения: %1 + + + + The new copy is active, but the original was retained because reference reconciliation could not be persisted: %1 + Новая копия активна, но оригинал сохранён, поскольку не удалось сохранить результат сверки ссылок: %1 + + + + The replacement is active; re-reading every device reference before deletion... + Замена активна. Перед удалением повторно читаются все ссылки на устройстве... + + + Media was applied before cancellation completed Медиафайл применён до завершения отмены - + Media applied successfully Медиафайл успешно применён - + + The new copy is present, but the Apply outcome is uncertain. The original was not deleted: %1 + Новая копия присутствует, но результат применения неизвестен. Оригинал не удалён: %1 + + + + The new copy is ready, but Apply did not complete. The original was retained: %1 + Новая копия готова, но применение не завершилось. Оригинал сохранён: %1 + + + USB changed before the PASE metrics layout could be associated with its original device USB-подключение изменилось до того, как разметку метрик PASE удалось связать с исходным устройством - + PASE metrics configured successfully Метрики PASE успешно настроены - + PASE metrics disabled successfully Метрики PASE успешно отключены - + Failed to update PASE metrics: %1 Не удалось обновить метрики PASE: %1 - + Source media identity could not be associated with the active operation Не удалось связать идентификатор исходного медиафайла с активной операцией - + Source media changed while its identity was being calculated Исходный медиафайл изменился во время расчёта его идентификатора - + Checking whether this media is already on the device... Проверяется, находится ли этот медиафайл уже на устройстве... - + Source media changed while it was being converted Исходный медиафайл изменился во время конвертации - + Prepared media is ready for upload Подготовленный медиафайл готов к загрузке - + Cannot preserve prepared media while stopping the runtime: %1 Не удалось сохранить подготовленный медиафайл при остановке службы: %1 - + Prepared media was preserved for a manual retry after runtime restart Подготовленный медиафайл сохранён для ручного повтора после перезапуска службы - + TRYX runtime is stopping Служба TRYX останавливается - + Printer-class operation stopped because the USB connection changed Операция класса принтера остановлена из-за изменения USB-подключения - + Restoring the active PASE display session after USB re-enumeration... Восстановление активной сессии дисплея PASE после повторного подключения USB... - - + + Stored retry media is still being validated; the PASE display session will start only after validation finishes Сохранённый файл для повтора ещё проверяется; сессия дисплея PASE запустится только после завершения проверки - + A TRYX printer-class or Rockchip gadget device is present; use Auto connection. Обнаружено устройство TRYX printer-class или Rockchip gadget. Используйте автоматическое подключение. - + Printer-class connection was restarted Подключение класса принтера перезапущено - + Printer-class operation stopped because the device was disconnected Операция класса принтера остановлена из-за отключения устройства - + Legacy device information is available from its connection handshake. Информация об устройстве старого типа доступна после установления соединения. - + TRYX device is not connected Устройство TRYX не подключено - + TRYX endpoint is present, but the display session stopped. Reconnect USB or select Auto connection again. Endpoint TRYX доступен, но сессия дисплея остановлена. Переподключите USB или повторно выберите автоматическое подключение. - + Stored retry media is still being validated; wait for validation to finish before using the PASE display session. Сохранённый файл для повтора ещё проверяется. Дождитесь завершения проверки перед использованием сессии дисплея PASE. - + PASE must be power-cycled before another upload or display change. Disconnect its USB/power while the TRYX runtime is running, reconnect it, and wait for the display session to become active. Перед следующей загрузкой или изменением экрана необходимо полностью выключить и включить PASE. Отключите USB или питание устройства при работающей службе TRYX, подключите его снова и дождитесь активации сеанса дисплея. - + The PASE display session is lost. Reconnect the device and wait for a new display session before trying again. Сеанс дисплея PASE потерян. Переподключите устройство и дождитесь нового сеанса перед повторной попыткой. - + The PASE display session is not ready yet. Wait until the device finishes connecting before trying again. Сеанс дисплея PASE ещё не готов. Дождитесь завершения подключения устройства и повторите попытку. - + PASE reconnected, but the recovery state could not be saved: %1 PASE переподключён, но состояние восстановления не удалось сохранить: %1 - + PASE power-cycle was observed; starting a clean display session Полное выключение и включение PASE подтверждено. Запускается чистый сеанс дисплея. - + + The shared media staging directories are unavailable + Общие каталоги временного хранения медиа недоступны + + + + The staged source ownership destination is unavailable + Место назначения для передачи владения временным исходником недоступно + + + + The staged media identity changed while daemon ownership was acquired + Идентификатор временного медиа изменился при передаче владения фоновому сервису + + + + Could not remove daemon-owned staged source %1: %2 + Не удалось удалить временный исходник фонового сервиса %1: %2 + + + + Refusing to remove an owned source outside the daemon spool: %1 + Удаление принадлежащего исходника вне буфера фонового сервиса отклонено: %1 + + + + Could not initialize media staging: %1 + Не удалось инициализировать временное хранение медиа: %1 + + + + Could not initialize the device media outbox: %1 + Не удалось инициализировать приватный исходящий каталог медиафайлов устройства: %1 + + + + Could not remove stale device media artifact %1: %2 + Не удалось удалить устаревший артефакт медиафайла устройства %1: %2 + + + + The device media artifact does not exist + Артефакт медиафайла устройства не существует + + + + The device media artifact belongs to another caller + Артефакт медиафайла устройства принадлежит другому вызывающему клиенту + + + + The device media artifact lease is invalid + Аренда артефакта медиафайла устройства недействительна + + + + The unclaimed device media artifact has no lease + У ещё не полученного артефакта медиафайла устройства нет аренды + + + + The device media artifact lease has expired + Срок аренды артефакта медиафайла устройства истёк + + + + The device media artifact escaped its private outbox + Артефакт медиафайла устройства оказался за пределами приватного исходящего каталога + + + + The device media artifact identity changed + Идентификатор артефакта медиафайла устройства изменился + + + + The device media artifact hash changed + Хеш артефакта медиафайла устройства изменился + + + Media catalog writes are disabled because the v1 rollback copy could not be created Запись каталога медиа отключена, поскольку не удалось создать резервную копию версии 1 - + Cannot create the media catalog directory Не удалось создать каталог медиатеки - + Cannot persist PASE metrics without a device serial Нельзя сохранить метрики PASE без серийного номера устройства - + Cannot remove the previous PASE metrics configuration Не удалось удалить предыдущую конфигурацию метрик PASE - + Cannot create the PASE metrics configuration directory Не удалось создать каталог конфигурации метрик PASE - + Confirmed media does not have a complete origin identity У подтверждённого медиафайла отсутствует полный идентификатор источника - + Confirmed media identity is invalid Идентификатор подтверждённого медиафайла недействителен - - - + + Replace reconciliation state could not be persisted: %1 + Не удалось сохранить состояние сверки замены: %1 + + + + Terminal replace state could not be persisted: %1 + Не удалось сохранить конечное состояние замены: %1 + + + + Terminal replace journal could not be removed: %1 + Не удалось удалить журнал завершённой замены: %1 + + + + The selected device media identity is invalid + Идентификатор выбранного медиафайла устройства недействителен + + + + The current PASE media catalog is not associated with the active device + Текущий каталог медиафайлов PASE не связан с активным устройством + + + + Only an exact writable user media entry can be exported or edited + Экспортировать или редактировать можно только точно определённую пользовательскую запись, доступную для записи + + + + The private device media outbox is unavailable: %1 + Приватный исходящий каталог медиафайлов устройства недоступен: %1 + + + + Could not allocate a unique device media artifact + Не удалось создать уникальный артефакт медиафайла устройства + + + + Validating the current device media entry... + Проверяется текущая запись медиафайла устройства... + + + + The requested stage operation has no unclaimed artifact + У запрошенной операции подготовки нет доступного неполученного артефакта + + + + + The device media artifact has not been claimed + Артефакт медиафайла устройства не был получен клиентом + + + + The device media artifact is held by an active operation + Артефакт медиафайла устройства удерживается активной операцией + + + + + Media transform is invalid: %1 + Недопустимые параметры преобразования медиафайла: %1 + + + + + + + Stored retry media is still being validated; retry this operation when startup validation finishes Сохранённый медиафайл для повтора всё ещё проверяется. Повторите операцию после завершения стартовой проверки - - - - - + + The PASE connection changed before replacement preflight could be associated with the original device + Подключение PASE изменилось до того, как предварительную проверку замены удалось связать с исходным устройством + + + + The fresh FileList did not prove the exact original and replacement identities. Replace remains unresolved and no mutation was repeated. + Свежий FileList не подтвердил точные идентификаторы исходного файла и новой копии. Replace остаётся незавершённым, повторных изменений устройства не выполнялось. + + + + The replace preflight succeeded without proving the original media identity + Предварительная проверка Replace завершилась успешно без подтверждения идентификатора исходного медиафайла + + + + The previous Apply was not repeated and its outcome remains unknown. The new copy is ready and the original was retained. + Предыдущий Apply не выполнялся повторно, его результат остаётся неизвестным. Новая копия готова, исходный медиафайл сохранён. + + + + The previous Apply was not repeated and its outcome remains unknown. The original is still referenced by: %1 + Предыдущий Apply не выполнялся повторно, его результат остаётся неизвестным. Исходный медиафайл всё ещё используется в: %1 + + + + + + + + + Another operation is active: %1 Уже выполняется другая операция: %1 - + + A previous replacement still requires read-only reconciliation + Для предыдущей замены всё ещё требуется сверка только для чтения + + + + The recovered copy belongs to a different PASE device + Полученная копия принадлежит другому устройству PASE + + + + The original media identity changed after the device copy was staged + Идентификатор исходного медиафайла изменился после подготовки копии с устройства + + + + Replace requires an explicit current full-screen or split-screen layout that references the original media + Для замены требуется явно заданная текущая полноэкранная или разделённая компоновка со ссылкой на исходный медиафайл + + + + Checking every device reference before replacement... + Перед заменой проверяются все ссылки на устройстве... + + + + Preparing the recovered device copy as new media... + Полученная с устройства копия подготавливается как новый медиафайл... + + + Media file does not exist Медиафайл не существует - + + The staged media source could not be claimed safely + Не удалось безопасно принять временный исходник медиа + + + Calculating the source media content identity... Рассчитывается идентификатор содержимого исходного медиафайла... - + Preparing media for printer-class upload... Медиафайл подготавливается для загрузки через класс принтера... - + Display settings Настройки дисплея - + PASE configuration requires a display change or valid full/split media, supported play mode, up to three metrics per side and CPU/GPU badges Конфигурация PASE должна содержать изменение дисплея либо корректный полноэкранный или разделённый набор медиа, поддерживаемый режим воспроизведения, до трёх метрик на сторону и баджи CPU/GPU - + Preparing to apply printer-class media... Подготовка к применению медиафайла через класс принтера... - + Preparing to apply printer-class display settings... Подготовка применения настроек дисплея PASE... - + Disabled Отключено - + PASE metrics configuration requires one to three unique supported metrics, or an explicit disabled state Для настройки метрик PASE требуется от одной до трёх уникальных поддерживаемых метрик либо явное отключение - + Preparing to configure PASE metrics... Подготовка настройки метрик PASE... - + Preparing to disable PASE metrics... Подготовка отключения метрик PASE... - + This operation has no safe prepared-media retry Для этой операции нет безопасного повтора с подготовленным медиафайлом - + Prepared media belongs to a different or unverified PASE connection. Reconnect the original device before Retry. Подготовленный медиафайл относится к другому или неподтверждённому подключению PASE. Перед повтором подключите исходное устройство. - + This operation is not the active retry candidate Эта операция не является активным кандидатом для повтора - - + + The prepared retry cache is no longer valid Кэш подготовленного повтора больше недействителен - + The invalid retry cache could not be removed Не удалось удалить недействительный кэш повтора - + Validating prepared media before manual retry... Проверка подготовленного медиафайла перед ручным повтором... - + Delete reconciliation cannot be cancelled because FileRemove may already have been sent Сверку удаления нельзя отменить, поскольку FileRemove уже мог быть отправлен - + The retry cache could not be removed; it remains available Не удалось удалить кэш повтора, поэтому он остаётся доступным - + Cancelling prepared-media validation... Отмена проверки подготовленного медиафайла... - + %1 Power-cycle PASE before Retry or Save; the current firmware transfer session cannot be reused safely. %1 Полностью выключите и включите PASE перед повтором или сохранением. Текущий сеанс передачи прошивки нельзя безопасно использовать повторно. - + Cancellation was acknowledged, but the retry cache could not be removed Отмена подтверждена, но не удалось удалить кэш повтора - + + Replace journal metadata is unavailable + Метаданные журнала замены недоступны + + + + + A replacement upload was verified before restart. The new copy is ready; Apply and Delete were not resumed. + Загрузка замены была проверена до перезапуска. Новая копия готова, применение и удаление не возобновлялись. + + + + + A replacement stopped before upload was verified. The original media was retained. + Замена остановилась до проверки загрузки. Исходный медиафайл сохранён. + + + + A previous replacement stopped after a mutation may have started. Apply and Delete will not be repeated automatically. + Предыдущая замена остановилась после возможного начала изменения. Применение и удаление не будут повторены автоматически. + + + Re-reading display state and media references without repeating Apply... + Состояние дисплея и ссылки на медиафайлы читаются повторно без повторения применения... + + + Delete intent metadata is incomplete Метаданные намерения удаления неполны - + Delete target is absent from the typed media catalog Цель удаления отсутствует в типизированном каталоге медиа - + Cannot remove delete intent: %1 Не удалось удалить намерение удаления: %1 - + A previous delete command requires read-only FileList reconciliation Предыдущая команда удаления требует сверки FileList только для чтения - + Reconciling the previous delete command without repeating it... Сверка предыдущей команды удаления без её повторной отправки... - + Prepared media has an unsupported terminal outcome Подготовленное медиа имеет неподдерживаемый итоговый статус - + Prepared media is not bound to a verified PASE identity Подготовленное медиа не привязано к подтверждённому устройству PASE - + Finalization recovery is not marked as reconciliation-only Восстановление финализации не помечено как операция только для сверки - + Pre-mutation retry state contains confirmed upload progress Состояние повтора до изменения содержит подтверждённый прогресс загрузки - + Prepared media has inconsistent confirmed progress Подготовленное медиа содержит несогласованный подтверждённый прогресс - + Cannot remove the superseded retry-cache file: %1 Не удалось удалить заменённый файл кэша повтора: %1 - + Cannot clear the retry cache manifest: %1 Не удалось очистить манифест кэша повтора: %1 - + The retry manifest was cleared, but cached artifacts could not be removed: %1 Манифест повтора очищен, но не удалось удалить файлы кэша: %1 - + Prepared media was preserved, but the legacy PASE standby action was removed. Retry will upload the file without applying display settings. Подготовленный файл сохранён, но устаревшее действие режима ожидания PASE удалено. Retry загрузит файл без применения настроек дисплея. - + Stored retry-cache validation was cancelled; the cache remains available for the next runtime start Проверка сохранённого кэша повтора отменена. Кэш останется доступен после следующего запуска службы - + Stored retry cache failed validation Сохранённый кэш повтора не прошёл проверку - + Stored retry operation ID conflicts with an existing operation; assigning a new ID Идентификатор сохранённой операции повтора конфликтует с существующей операцией. Назначается новый идентификатор - + The final upload acknowledgement was lost before the file could be verified Финальное подтверждение загрузки потеряно до проверки файла - + The previous PASE upload ended in an unknown partial state Предыдущая загрузка на PASE завершилась в неопределённом частичном состоянии - + The previous prepared upload did not complete Предыдущая подготовленная загрузка не завершилась - + The completed upload still requires read-only FileList reconciliation. Physically reconnect the same PASE before verification can continue. Завершённая загрузка всё ещё требует сверки FileList только для чтения. Физически переподключите ту же PASE, чтобы продолжить проверку. - + The completed upload is being recovered through read-only FileList verification; media data will not be retransmitted. Завершённая загрузка восстанавливается через проверку FileList только для чтения. Данные медиафайла повторно передаваться не будут. - + The previous PASE upload ended after transmission began. Power-cycle the same device while this runtime is running before Retry. Предыдущая загрузка на PASE завершилась после начала передачи. Перед повтором полностью выключите и включите то же устройство при запущенной фоновой службе. - + The same PASE was reconnected. Prepared media is available for a new transfer under a new device filename. Та же PASE подключена повторно. Подготовленный медиафайл доступен для новой передачи под новым именем на устройстве. - + Stored retry manifest could not be upgraded safely: %1 Не удалось безопасно обновить сохранённый манифест повтора: %1 - + A completed upload needs FileList reconciliation on the same PASE. Physically reconnect it while this runtime is running. Завершённая загрузка требует сверки FileList на той же PASE. Физически переподключите устройство при запущенной фоновой службе. - + A prepared upload was restored after an incomplete PASE transfer. Power-cycle the same device while this runtime is running before Retry. Подготовленная загрузка восстановлена после незавершённой передачи на PASE. Перед повтором полностью выключите и включите то же устройство при запущенной фоновой службе. - + Prepared-media validation was interrupted Проверка подготовленного медиафайла была прервана - + Checking FileList before manual retry... FileList проверяется перед ручным повтором... - + Cannot transfer retry-cache ownership: %1 Не удалось передать владение кэшем повтора: %1 - + The previous upload is present in FileList; applying it without retransmission... Предыдущая загрузка найдена в FileList; применение без повторной передачи... - + The previous upload was verified in FileList; media data was not retransmitted Предыдущая загрузка подтверждена через FileList; данные медиа не передавались повторно - + The original PASE identity is unavailable. Prepared media cannot be retried automatically. Идентификатор исходного устройства PASE недоступен. Подготовленное медиа нельзя повторно загрузить автоматически. - + PASE was reconnected, but its device identity is unavailable. Retry remains blocked. PASE подключена повторно, но идентификатор устройства недоступен. Повтор остаётся заблокированным. - + A different PASE was connected after the incomplete transfer. Reconnect the original device before Retry. После незавершённой передачи подключена другая PASE. Перед повтором подключите исходное устройство. - + The same PASE was physically reconnected after the incomplete transfer. Prepared media can now be transferred again under a new device filename. После незавершённой передачи та же PASE была физически переподключена. Теперь подготовленный медиафайл можно передать снова под новым именем на устройстве. - + + Staged media must be one validated direct child of the shared inbox + Подготовленный медиафайл должен быть проверенным файлом непосредственно в общем входящем каталоге + + + + Staged media must be a non-linked regular file owned by this user with mode 0600 and a supported size + Подготовленный медиафайл должен быть обычным файлом без ссылок, принадлежать текущему пользователю, иметь права 0600 и допустимый размер + + + Cannot persist an enabled PASE overlay without metrics or badges Нельзя сохранить включённый оверлей PASE без метрик или баджей - + Cannot persist an invalid PASE overlay configuration Нельзя сохранить некорректную конфигурацию оверлея PASE - + PASE identity is unavailable; upload cannot start safely Идентификатор PASE недоступен, безопасный запуск загрузки невозможен - + PASE upload-and-apply requires one full-screen media file, a supported play mode, up to three metrics and CPU/GPU badges Загрузка с применением на PASE требует одного полноэкранного медиафайла, поддерживаемого режима воспроизведения, до трёх метрик и баджей CPU/GPU - + A previous delete command still requires read-only reconciliation Предыдущая команда удаления всё ещё требует сверки только для чтения - + Select exactly one media file to delete safely Для безопасного удаления выберите ровно один медиафайл - + Media file is not eligible for deletion: %1 Медиафайл нельзя удалить: %1 - + Preparing a safe delete operation... Подготовка безопасной операции удаления... - + Cannot persist delete intent before preflight: %1 Не удалось сохранить намерение удаления до предварительной проверки: %1 - + Cancelling the active USB operation... Активная USB-операция отменяется... - + %1. Retry cache could not be saved: %2 %1. Не удалось сохранить кэш повтора: %2 - + + Re-reading FileList without repeating FileRemove... + Повторное чтение FileList без повторного FileRemove... + + + + Re-reading media references without repeating Apply... + Повторное чтение ссылок на медиафайлы без повторного Apply... + + + Operation metadata is no longer available Метаданные операции больше недоступны - + Prepared media failed retry-cache validation Подготовленный медиафайл не прошёл проверку кэша повтора - + Cannot create the retry-cache directory Не удалось создать каталог кэша повтора - + A verified prepared upload is available for manual retry Проверенный подготовленный файл доступен для ручного повтора загрузки - + %1. The retry cache could not be removed %1. Не удалось удалить кэш повтора - + FileList refresh is deferred while operation %1 is active Обновление FileList отложено до завершения операции %1 - + Media deletion is disabled because USB file_remove has no dedicated response. Удаление медиа отключено, поскольку у USB-команды file_remove нет отдельного ответа. - - - + + + Device not connected. Use Auto connection or reconnect USB. Устройство не подключено. Используйте Auto connection или переподключите USB. - DeviceWorker + DeviceMediaWorkflowController - - Device not found. Check the USB connection. - Устройство не найдено. Проверьте USB-подключение. + + The device media lease expired + Срок действия доступа к медиафайлу устройства истёк - - Failed to connect to %1 - Не удалось подключиться к %1 + + The device media export helper could not start + Не удалось запустить вспомогательный процесс экспорта медиафайла устройства - - - - - Device not connected - Устройство не подключено + + Exporting the device media copy timed out + Истекло время ожидания экспорта копии медиафайла с устройства - - Handshake failed - Handshake не удался + + + Finish the current device media action first + Сначала завершите текущее действие с медиафайлом устройства - - Failed to set brightness - Не удалось установить яркость + + Choose a local export folder + Выберите локальную папку для экспорта - - Failed to set display configuration - Не удалось применить конфигурацию дисплея + + The selected export folder is unavailable + Выбранная папка для экспорта недоступна - - Failed to delete media files - Не удалось удалить медиафайлы + + Enter a valid H.264 export file name + Введите допустимое имя экспортируемого файла H.264 - - - ADB device not found - ADB-устройство не найдено + + The claimed device media copy is unavailable + Зарезервированная копия медиафайла с устройства недоступна - - ffmpeg not found. Install it with your system package manager - ffmpeg не найден. Установите его через пакетный менеджер вашей системы + + The device media lease expired before export started + Срок действия доступа к медиафайлу устройства истёк до начала экспорта - - Converting to MP4... - Конвертация в MP4... + + The export destination already exists + Файл назначения уже существует - - Conversion to MP4 failed - Не удалось конвертировать в MP4 + + The device media copy could not be exported + Не удалось экспортировать копию медиафайла с устройства - - Uploading to device... - Загрузка на устройство... + + Exported device media copy as %1 + Копия медиафайла с устройства экспортирована как %1 - - Upload to device failed - Не удалось загрузить на устройство + + The device media action failed + Не удалось выполнить действие с медиафайлом устройства - - Printer-class upload was cancelled because the USB device changed - Загрузка через printer-class отменена из-за изменения USB-устройства + + The claimed device media lease is already expired + Срок действия доступа к зарезервированной копии медиафайла устройства уже истёк - - Prepared printer-class media is not available - Подготовленный медиафайл для printer-class недоступен + + The edited device media replaced the original + Отредактированный медиафайл устройства заменил оригинал - - Starting printer-class upload... - Запускается загрузка через класс принтера... + + The edited copy is ready, but the original media was retained + Отредактированная копия готова, но исходный медиафайл не удалён - - The PASE upload outcome is partial or unknown; physically reconnect the device before continuing + + The edited device media was saved as a new copy + Отредактированный медиафайл устройства сохранён как новая копия + + + + The edited copy was saved. Select it in the Media Library and apply it to the display. + Отредактированная копия сохранена. Выберите её в медиатеке и примените к дисплею. + + + + The runtime returned an unexpected device media artifact + Среда выполнения вернула неожиданный артефакт медиафайла устройства + + + + The device media action is no longer active + Действие с медиафайлом устройства больше не активно + + + + The runtime stopped during the media operation + Среда выполнения остановилась во время операции с медиафайлом + + + + The runtime stopped during the device media action + Среда выполнения остановилась во время действия с медиафайлом устройства + + + + DeviceWorker + + + Device not found. Check the USB connection. + Устройство не найдено. Проверьте USB-подключение. + + + + Failed to connect to %1 + Не удалось подключиться к %1 + + + + + + + Device not connected + Устройство не подключено + + + + Handshake failed + Handshake не удался + + + + Failed to set brightness + Не удалось установить яркость + + + + Failed to set display configuration + Не удалось применить конфигурацию дисплея + + + + Failed to delete media files + Не удалось удалить медиафайлы + + + + + ADB device not found + ADB-устройство не найдено + + + + ffmpeg not found. Install it with your system package manager + ffmpeg не найден. Установите его через пакетный менеджер вашей системы + + + + Converting to MP4... + Конвертация в MP4... + + + + Conversion to MP4 failed + Не удалось конвертировать в MP4 + + + + Uploading to device... + Загрузка на устройство... + + + + Upload to device failed + Не удалось загрузить на устройство + + + + Printer-class upload was cancelled because the USB device changed + Загрузка через printer-class отменена из-за изменения USB-устройства + + + + Prepared printer-class media is not available + Подготовленный медиафайл для printer-class недоступен + + + + Starting printer-class upload... + Запускается загрузка через класс принтера... + + + + The PASE upload outcome is partial or unknown; physically reconnect the device before continuing Результат загрузки PASE частичный или неизвестный; перед продолжением физически переподключите устройство - + The PASE upload outcome is partial or unknown: %1. Physically reconnect the device before continuing Результат загрузки PASE частичный или неизвестный: %1. Перед продолжением физически переподключите устройство - + + Recovered media output path is not an unused private H264 artifact + Путь для восстановленного медиафайла не указывает на неиспользуемый закрытый артефакт H.264 + + + + There is not enough free space to stage this device media copy + Недостаточно свободного места для подготовки копии медиафайла с устройства + + + + Cannot create the private recovered media artifact: %1 + Не удалось создать закрытый артефакт восстановленного медиафайла: %1 + + + + Reading and decoding the device media copy... + Копия медиафайла с устройства считывается и декодируется... + + + + Cannot commit recovered media bytes to local storage + Не удалось окончательно записать восстановленные данные медиафайла в локальное хранилище + + + + Cannot publish the recovered media artifact atomically: %1 + Не удалось атомарно опубликовать артефакт восстановленного медиафайла: %1 + + + + Published recovered media artifact failed its final filesystem validation + Опубликованный артефакт восстановленного медиафайла не прошёл окончательную проверку файловой системы + + + Deletion was cancelled before FileRemove dispatch Удаление отменено до отправки FileRemove - + Checking whether %1 can be deleted... Проверяется возможность удаления %1... - + Sending one delete request for %1... Отправляется один запрос на удаление %1... - + Verifying deletion of %1 through FileList... Удаление %1 проверяется через FileList... - + display settings настройки дисплея - - + + Applying printer-class configuration: %1 Применение конфигурации PASE: %1 - + Failed to apply printer-class configuration: %1 Не удалось применить конфигурацию PASE: %1 - + Printer-class configuration applied Конфигурация PASE применена - + Configuring PASE metrics layout... Настройка разметки метрик PASE... - + Failed to configure PASE metrics: %1 Не удалось настроить метрики PASE: %1 - + Automatic same-generation PASE bootstrap retry is disabled; physically reconnect the device before continuing Автоматический повтор инициализации PASE в том же поколении отключён; физически переподключите устройство перед продолжением - + PASE display session bootstrap failed; physically reconnect the device before continuing Не удалось инициализировать сеанс дисплея PASE; физически переподключите устройство перед продолжением - + PASE display session bootstrap failed: %1. Physically reconnect the device before continuing Не удалось инициализировать сеанс дисплея PASE: %1. Физически переподключите устройство перед продолжением - + PASE keepalive was cancelled before a safe write could start Проверка активности PASE отменена до начала безопасной записи - + PASE overlay recovery stopped because the mandatory post-bootstrap keepalive failed Восстановление оверлея PASE остановлено из-за сбоя обязательной проверки активности после инициализации - + PASE overlay recovery stopped because the mandatory post-bootstrap keepalive failed: %1 Восстановление оверлея PASE остановлено из-за сбоя обязательной проверки активности после инициализации: %1 - + PASE overlay lease refresh stopped after %1 retries: %2 Контрольное обновление оверлея PASE остановлено после %1 попыток: %2 - + PASE overlay lease refresh stopped: %1 Контрольное обновление оверлея PASE остановлено: %1 - + PASE overlay lease could not be refreshed safely: %1 Не удалось безопасно выполнить контрольное обновление оверлея PASE: %1 - + Automatic same-generation PASE bootstrap retry is disabled Автоматический повтор инициализации PASE в том же поколении отключён - + PASE bootstrap completed without an exact DeviceInfo readiness confirmation Инициализация PASE завершилась без точного подтверждения готовности DeviceInfo - + The mandatory post-bootstrap PASE keepalive failed Сбой обязательной проверки активности PASE после инициализации - + The mandatory post-bootstrap PASE keepalive did not activate the display session Обязательная проверка активности PASE после инициализации не активировала сеанс дисплея - + The PASE USB interface stopped responding after persistent input transport errors: %1. Software USB reset is disabled; fully power-cycle or physically reconnect PASE before continuing. USB-интерфейс PASE перестал отвечать после устойчивых ошибок входящего транспорта: %1. Программный сброс USB отключён; полностью обесточьте PASE или физически переподключите устройство перед продолжением. - + The PASE display session stopped after an operation failure; physically reconnect the device before continuing Сеанс дисплея PASE остановлен после сбоя операции; физически переподключите устройство перед продолжением - + The PASE display session stopped after an operation failure: %1. Physically reconnect the device before continuing Сеанс дисплея PASE остановлен после сбоя операции: %1. Физически переподключите устройство перед продолжением - + PASE overlay restoration was cancelled because the USB generation changed Восстановление оверлея PASE отменено из-за смены поколения USB - + PASE overlay restoration failed Не удалось восстановить оверлей PASE - + PASE overlay restoration failed: %1 Не удалось восстановить оверлей PASE: %1 - + PASE display session and overlay are active Сессия дисплея PASE и оверлей активны - + The PASE display session is lost until a new USB endpoint generation appears Сессия дисплея PASE потеряна до появления нового поколения конечной точки USB - + Printer-class keepalive write will be retried (%1/%2): %3 Запись keepalive printer-class будет повторена (%1/%2): %3 - + Printer-class keepalive stopped after %1 retries: %2 Keepalive printer-class остановлен после %1 повторных попыток: %2 - + PASE display keepalive recovered Keepalive дисплея PASE восстановлен - - + + Printer-class operation was cancelled by the user Операция класса принтера отменена пользователем - + The PASE protocol session is waiting for confirmed overlay restoration Протокольная сессия PASE ожидает подтверждённого восстановления оверлея - + Printer-class display session is already starting Сессия дисплея printer-class уже запускается - + Printer-class display session is lost until a new USB endpoint generation appears Сеанс дисплея класса принтера потерян до появления нового поколения USB-подключения - + Starting PASE display session... Запуск сессии дисплея PASE... - - + + The PASE USB interface stopped responding after persistent input transport errors. Software USB reset is disabled; fully power-cycle or physically reconnect PASE before continuing. USB-интерфейс PASE перестал отвечать после устойчивых ошибок входящего транспорта. Программный сброс USB отключён; полностью обесточьте PASE или физически переподключите устройство перед продолжением. - + Failed to start the printer-class display session Не удалось запустить сессию дисплея printer-class - + Failed to start the printer-class display session: %1 Не удалось запустить сессию дисплея printer-class: %1 - + Printer-class display session was cancelled because the USB device changed Сессия дисплея printer-class отменена из-за изменения USB-устройства - + PASE protocol session is ready; waiting for a confirmed keepalive before restoring the overlay Протокольная сессия PASE готова; перед восстановлением оверлея ожидается подтверждённый keepalive - + PASE display session is active Сессия дисплея PASE активна - + Failed to read printer-class media list: %1 Не удалось прочитать список медиа printer-class: %1 - + Printer-class keepalive stopped: %1 Keepalive printer-class остановлен: %1 - - + + Printer-class operation was cancelled because the USB device changed Операция printer-class отменена из-за изменения USB-устройства - - + + Uploading to printer-class firmware... %1% Загрузка в printer-class прошивку... %1% - + Printer-class upload failed: %1 Загрузка через printer-class не удалась: %1 - + Failed to retrieve file list Не удалось получить список файлов @@ -1805,6 +2470,206 @@ The operation cannot be undone. Запись completion marker Rockchip... + + HomePage + + Home + Главная + + + Runtime + Фоновая служба + + + Available + Доступна + + + Not running + Не запущена + + + API %1, required %2 + API %1, требуется %2 + + + Display + Дисплей + + + Current layout + Текущая компоновка + + + + Frequency unavailable + Частота недоступна + + + + %1 GHz + %1 ГГц + + + + %1 MHz + %1 МГц + + + + Memory data unavailable + Данные о памяти недоступны + + + + + %1 / %2 GiB + %1 / %2 ГиБ + + + + Storage data unavailable + Данные о хранилище недоступны + + + + %1 MiB/s + %1 МиБ/с + + + + %1 KiB/s + %1 КиБ/с + + + + PANORAMA SE + PANORAMA SE + + + + Layout: %1 · Playback: %2 + Компоновка: %1 · Воспроизведение: %2 + + + + + Unknown + Неизвестно + + + + No media is currently selected + Медиафайл не выбран + + + + %1% brightness + Яркость: %1% + + + + Display state unavailable + Состояние дисплея недоступно + + + + Manage display + Управление дисплеем + + + + This PC + Этот компьютер + + + + Updated automatically + Обновляется автоматически + + + + Reading sensors… + Чтение датчиков… + + + + CPU + CPU + + + + Processor + Процессор + + + + GPU + GPU + + + + Graphics processor + Графический процессор + + + + Memory + Память + + + + System RAM + Оперативная память + + + + Storage + Хранилище + + + + System disk + Системный диск + + + + Network + Сеть + + + + Current transfer rate + Текущая скорость передачи + + + + Waiting for the next sample + Ожидание следующего измерения + + + + Download + Загрузка + + + + Upload + Отдача + + + Metrics + Метрики + + + Enabled + Включены + + + Disabled + Отключены + + Homepage @@ -1884,1635 +2749,3593 @@ The operation cannot be undone. - MainWindow + HudPage - - TRYX Panorama Manager - TRYX Panorama Manager + Metrics on display (max 3) + Метрики на дисплее, не более 3 - - Homepage - Главная + CPU Temperature + Температура CPU - - Panorama - Панорама + CPU Frequency + Частота CPU - - Rota - Rota + CPU Usage + Использование CPU - - Settings - Настройки + CPU Voltage + Напряжение CPU - - ROTA - ROTA + GPU Temperature + Температура GPU - - Lighting & Fan Speed Control - Управление подсветкой и скоростью вентиляторов + GPU Frequency + Частота GPU - - In Development - В разработке + GPU Voltage + Напряжение GPU - - ROTA is the ARGB lighting and fan speed controller for TRYX coolers. - -Planned features: - - ARGB lighting effects (15+ presets) - - Fan speed control (Smart/Fixed modes) - - Per-fan speed curves - - Motherboard ARGB sync - ROTA это контроллер ARGB-подсветки и скорости вентиляторов для кулеров TRYX. - -Запланированные функции: - - эффекты ARGB-подсветки (15+ пресетов) - - управление скоростью вентиляторов (Smart/Fixed) - - кривые скорости для каждого вентилятора - - синхронизация ARGB с материнской платой + Motherboard Temperature + Температура материнской платы - - - - Disconnected - Отключено + Memory Frequency + Частота памяти - - Connected: %1 (%2) - Подключено: %1 (%2) + Memory Utilization + Использование памяти - - Connected: %1 (S/N: %2, FW: %3) - Подключено: %1 (S/N: %2, FW: %3) + Date & Time + Дата и время - - - Device connected - Устройство подключено + Selected: 0 / 3 + Выбрано: 0 / 3 - - Error: %1 - Ошибка: %1 + Display settings + Настройки дисплея - - - Connected - Подключено + Position: + Позиция: - - - PanoramaPage - - PANORAMA - PANORAMA + Top + Сверху - - Retry transfer - Повторить передачу + Center + По центру - - CPU Temperature - Температура CPU + Bottom + Снизу - - CPU Frequency - Частота CPU + Alignment: + Выравнивание: - - CPU Usage - Использование CPU + Left + Слева - - GPU Temperature - Температура GPU + Right + Справа - - GPU Frequency - Частота GPU + Text color + Цвет текста - - GPU Usage - Использование GPU + CPU Badge + Бейдж CPU - - Memory Frequency - Частота памяти + GPU Badge + Бейдж GPU - - Date&Time - Дата и время + Apply configuration + Применить конфигурацию - - Center - По центру + Send metrics + Отправлять метрики - - Align: - Выравнивание: + Interval (sec): + Интервал, сек.: - - Left - Слева + Start + Запустить - - Right - Справа + Metrics not being sent + Метрики не отправляются - - Color - Цвет + Selected: %1 / 3 + Выбрано: %1 / 3 - - CPU Badge - Бейдж CPU + Screen configuration is disabled on printer-class firmware until the new protocol is verified. + Настройка экрана отключена на прошивке класса принтера до завершения проверки нового протокола. - - GPU Badge - Бейдж GPU + Metrics configuration applied + Конфигурация метрик применена - - Save - Сохранить + Select at least one metric + Выберите хотя бы одну метрику - - - Full Screen - Полный экран + Stop + Остановить - - - Screen Splitting - Разделение экрана + Sending metrics... + Отправка метрик... - - Play Mode: - Режим воспроизведения: + Sent: %1 metrics + Отправлено метрик: %1 + + + Main - - Single - Один файл + TRYX Panorama + TRYX Panorama - - Shuffle - Случайно + TRYX PANORAMA + TRYX PANORAMA - - Loop - Повтор + + TRYX Panorama Manager + TRYX Panorama Manager - - Ratio: - Соотношение: + + + Dashboard + Панель управления - - System info: - Системная информация: + + + Display + Дисплей - - Upload a file -(MP4, WEBM, GIF, JPG, PNG) - Загрузите файл -(MP4, WEBM, GIF, JPG, PNG) + + Device status and live metrics from this PC + Состояние устройства и метрики этого компьютера - - Upload File... - Загрузить файл... + + Manage media, layout and screen controls + Управление медиафайлами, компоновкой и экраном - - Display Off - Выключить экран + + Application, startup and device preferences + Настройки приложения, автозапуска и устройства - - Disable only the PASE display backlight - Отключить только подсветку дисплея PASE + + PANORAMA + PANORAMA - - - Waterfall Mode - Режим водопада + + Minimize + Свернуть - - Rotate the user interface by 90 degrees - Повернуть пользовательский интерфейс на 90 градусов + + Restore + Восстановить - - Waterfall Mode rotates the PASE interface and media by 90 degrees. Continue? - Режим водопада поворачивает интерфейс и медиа PASE на 90 градусов. Продолжить? + + Maximize + Развернуть - - - - - - The PASE display session is not ready. Reconnect or power-cycle the device and wait for it to become active. - Сеанс дисплея PASE не готов. Переподключите или полностью выключите и включите устройство, затем дождитесь активации. + + Close + Закрыть - - %1: %2 - %1: %2 + + CONTROL CENTER + ЦЕНТР УПРАВЛЕНИЯ - - Initial transfer error: %1 - Исходная ошибка передачи: %1 + + DISPLAY SESSION + СЕАНС ДИСПЛЕЯ - - Confirmed in the previous attempt: %1 of %2 bytes - Подтверждено в предыдущей попытке: %1 из %2 байт + + Ready + Готов - - %1 of %2 bytes - %1 из %2 байт + + Waiting for device + Ожидание устройства - - Last confirmed chunk: %1 - Последний подтверждённый чанк: %1 + + Refresh + Обновить - - Multiple prepared uploads require reconciliation; restart the background runtime before retrying - Обнаружено несколько подготовленных загрузок, требующих сверки; перезапустите фоновую службу перед повтором + Home + Главная - - - Metrics active in background runtime - Метрики активны в фоновой службе + Panorama + Панорама - - Select %1 media file(s) for this screen mode - Выберите медиафайлы для этого режима экрана: %1 + + + Settings + Настройки + + + MainWindow - - This PASE media file cannot be deleted - Этот медиафайл PASE нельзя удалить + + TRYX Panorama Manager + TRYX Panorama Manager - - This PASE media file cannot be deleted: %1 - Этот медиафайл PASE нельзя удалить: %1 + + Homepage + Главная - - Delete this file from PASE? - -Name: %1 -Size: %2 bytes - -The operation cannot be undone. - Удалить этот файл с PASE? - -Имя: %1 -Размер: %2 байт - -Операцию нельзя отменить. + + Panorama + Панорама - - Delete %1 files from PASE (%2 bytes total)? - -%3 - -The operation cannot be undone. - Удалить файлов с PASE: %1 (общий размер: %2 байт)? - -%3 - -Операцию нельзя отменить. + + Rota + Rota - - - The PASE display state is not ready yet. Reconnect the device and wait for synchronization. - Состояние дисплея PASE ещё не готово. Переподключите устройство и дождитесь синхронизации. + + Settings + Настройки - - DEVICE PRESET - ПРЕСЕТ УСТРОЙСТВА + + ROTA + ROTA - - USER UPLOAD - ФАЙЛ ПОЛЬЗОВАТЕЛЯ + + Lighting & Fan Speed Control + Управление подсветкой и скоростью вентиляторов - - UNKNOWN ORIGIN - ИСТОЧНИК НЕИЗВЕСТЕН + + In Development + В разработке - - Metrics configured; waiting for the PASE session - Метрики настроены, ожидание сессии PASE + + ROTA is the ARGB lighting and fan speed controller for TRYX coolers. + +Planned features: + - ARGB lighting effects (15+ presets) + - Fan speed control (Smart/Fixed modes) + - Per-fan speed curves + - Motherboard ARGB sync + ROTA это контроллер ARGB-подсветки и скорости вентиляторов для кулеров TRYX. + +Запланированные функции: + - эффекты ARGB-подсветки (15+ пресетов) + - управление скоростью вентиляторов (Smart/Fixed) + - кривые скорости для каждого вентилятора + - синхронизация ARGB с материнской платой - - Retry is blocked until PASE is power-cycled and its display session becomes active. - Повтор заблокирован, пока PASE не будет полностью выключен и включён, а сеанс дисплея не станет активным. + + + + Disconnected + Отключено - - Cancel - Отмена + + Connected: %1 (%2) + Подключено: %1 (%2) - - CPU Power - Мощность CPU + + Connected: %1 (S/N: %2, FW: %3) + Подключено: %1 (S/N: %2, FW: %3) - - GPU Power - Мощность GPU + + + Device connected + Устройство подключено - - Memory Usage - Использование памяти + + Error: %1 + Ошибка: %1 - - Media Library - Медиатека + + + Connected + Подключено + + + MediaCatalogModel - - Refresh - Обновить + + + Media is not present in the current catalog + Медиа отсутствует в текущем каталоге - - Display Settings - Настройки дисплея + + The current catalog cannot identify this media + Текущий каталог не позволяет идентифицировать этот медиафайл - - Brightness: - Яркость: + + Built-in and preset media cannot be exported or edited + Встроенные и предустановленные медиафайлы нельзя экспортировать или редактировать - - Mirror Mode - Зеркальный режим + + Read-only media cannot be exported or edited + Медиафайлы, доступные только для чтения, нельзя экспортировать или редактировать + + + MediaEditor - - Text Color - Цвет текста + + Media Editor + Редактор медиафайла - - - - Metrics active - Метрики активны + This device copy is already encoded at 2240 × 1080, so the current settings will not visibly change it. Existing padding is baked into the video. Choose Crop and raise Zoom above 100%, or rotate the video. Previously lost areas cannot be restored. + Эта копия с устройства уже закодирована в разрешении 2240 × 1080, поэтому текущие настройки визуально её не изменят. Существующие поля уже встроены в видео. Выберите Crop и увеличьте Zoom выше 100% либо поверните видео. Ранее потерянные области восстановить невозможно. - - PASE metrics are controlled by the saved display configuration - Метрики PASE управляются сохранённой конфигурацией дисплея + This is a private working copy recovered from the device. Save it as new media or replace the original after editing. Saving re-encodes the video; areas lost before the original upload cannot be restored. + Это приватная рабочая копия, полученная с устройства. После редактирования сохраните её как новый медиафайл или замените оригинал. При сохранении видео кодируется повторно; области, потерянные до первоначальной загрузки, восстановить невозможно. - + + Select a supported media file + Выберите поддерживаемый медиафайл + + + Mode + Режим + + + + Fit + Вместить + + + + Fill + Заполнить + + + + Crop + Кадрировать + + + + Stretch + Растянуть + + + + Show the whole image and fill any free space with the selected background color. + Показывает изображение целиком, а свободное пространство заполняет выбранным цветом фона. + + + + Fill the screen while preserving proportions; edges are cropped from the center. + Заполняет экран с сохранением пропорций; края обрезаются относительно центра. + + + + Fill the screen and adjust zoom and position manually. + Заполняет экран и позволяет вручную настроить масштаб и положение. + + + + Fill the screen exactly without preserving proportions. The image may be distorted. + Заполняет экран без сохранения пропорций. Изображение может быть искажено. + + + This is a private working copy recovered from the device. Save it as new media or replace the original after editing. + Это приватная рабочая копия, полученная с устройства. После редактирования сохраните её как новый медиафайл или замените оригинал. + + + + Sizing + Масштабирование + + + + Rotation + Поворот + + + + Zoom + Масштаб + + + + %1% + %1% + + + + Horizontal position + Положение по горизонтали + + + + Vertical position + Положение по вертикали + + + Focus X + Фокус по X + + + Focus Y + Фокус по Y + + + + This device copy is already encoded at 2240 × 1080, so the current settings will not visibly change it. Existing padding is baked into the video. Choose Crop and raise Zoom above 100%, or rotate the video. Save as new does not change the active display; select the new copy in the library and apply it. Previously lost areas cannot be restored. + Эта копия с устройства уже закодирована в разрешении 2240 × 1080, поэтому текущие настройки визуально её не изменят. Существующие поля уже встроены в видео. Выберите Crop и увеличьте Zoom выше 100% либо поверните видео. Действие Save as new не меняет содержимое активного дисплея: выберите новую копию в медиатеке и примените её. Ранее потерянные области восстановить невозможно. + + + + This is a private working copy recovered from the device. Save as new stores another media item but does not change the active display; select the new copy in the library and apply it. Replace original updates the original item. Saving re-encodes the video; areas lost before the original upload cannot be restored. + Это приватная рабочая копия, полученная с устройства. Действие Save as new создаёт новый медиафайл, но не меняет содержимое активного дисплея: выберите новую копию в медиатеке и примените её. Действие Replace original обновляет исходный медиафайл. При сохранении видео кодируется повторно; области, потерянные до первоначальной загрузки, восстановить невозможно. + + + + Fit background + Фон при вписывании + + + + Reset + Сбросить + + + + Cancel + Отмена + + + + Please wait… + Подождите… + + + + Upload + Загрузить + + + + Replacing… + Замена… + + + + Replace original + Заменить оригинал + + + + Saving… + Сохранение… + + + + Save as new + Сохранить как новый + + + + Creates a new media item without changing the active display + Создаёт новый медиафайл, не меняя содержимое активного дисплея + + + + Fit background color + Цвет фона при вписывании + + + + MediaEditorController + + + The original media identity is unavailable + Идентификатор исходного медиафайла недоступен + + + + Wait for the current media operation to finish + Дождитесь завершения текущей операции с медиафайлом + + + + The recovered media operation failed + Операция с восстановленным медиафайлом завершилась ошибкой + + + + + Wait for the runtime to acknowledge the current upload request + Дождитесь подтверждения текущего запроса загрузки фоновым сервисом + + + + Drop exactly one local media file into the editor + Перетащите в редактор ровно один локальный медиафайл + + + + Wait for the runtime to accept or reject the upload request + Дождитесь, пока фоновая служба примет или отклонит запрос на загрузку + + + + Wait until a valid preview has been decoded + Дождитесь декодирования корректного превью + + + + Choose Save as new or Replace for a recovered device copy + Для восстановленной копии с устройства выберите «Сохранить как новый» или «Заменить» + + + + The private media snapshot is no longer available + Приватная копия медиа больше недоступна + + + + + Wait until the recovered video preview is ready + Дождитесь готовности предпросмотра восстановленного видео + + + + MediaExportPicker + + + Export device media copy + Экспорт копии медиафайла с устройства + + + + Choose a local folder and H.264 file name + Выберите локальную папку и имя файла H.264 + + + + Close + Закрыть + + + + Home + Домашняя папка + + + + Up + Вверх + + + + Show hidden folders + Показывать скрытые папки + + + + DIR + ПАПКА + + + + This folder cannot be opened + Не удалось открыть эту папку + + + + Loading folder… + Загрузка папки… + + + + This folder has no subfolders + В этой папке нет вложенных папок + + + + File name + Имя файла + + + + device-media-copy.h264 + device-media-copy.h264 + + + + The exported file is the device-ready raw H.264 copy. + Экспортируемый файл является готовой для устройства копией необработанного потока H.264. + + + + Cancel + Отмена + + + + Export + Экспортировать + + + + MediaFilePicker + + Select media file Выберите медиафайл - - Assign media to both left and right sides - Назначьте медиа для левой и правой сторон + + Choose one image, GIF or video to edit before upload + Выберите изображение, GIF или видео для настройки перед загрузкой - - Screen Splitting configuration applied - Конфигурация разделения экрана применена + + Close + Закрыть - - - Select files to display - Выберите файлы для отображения + + Home + Домой - - Full Screen configuration applied - Конфигурация полного экрана применена + + Up + Вверх - - Set to left side - Установить слева + + Show hidden files + Показывать скрытые файлы - - Set to right side - Установить справа + + DIR + ПАПКА - - Set as display - Установить на дисплей + + FILE + ФАЙЛ - - - Screen config applied - Конфигурация экрана применена + + %1 MB + %1 МБ - - - Delete - Удалить + + This folder cannot be opened + Не удалось открыть эту папку + + + + Loading folder… + Загрузка папки… + + + + No supported media files in this folder + В этой папке нет поддерживаемых медиафайлов + + + + Selected: %1 + Выбрано: %1 + + + + Select a media file to continue + Выберите медиафайл для продолжения + + + + Cancel + Отмена + + + + Open + Открыть + + + + MediaPreviewController + + + Preview generation timed out + Истекло время ожидания создания превью + + + + Creating the private media snapshot timed out + Время создания приватной копии медиа истекло + + + + Could not start ffmpeg + Не удалось запустить ffmpeg + + + + + + Wait for the runtime to acknowledge the current upload request + Дождитесь подтверждения текущего запроса загрузки фоновым сервисом + + + + Only one local media file can be previewed + Для превью можно выбрать только один локальный медиафайл + + + + The selected media source is not a regular file + Выбранный источник медиа не является обычным файлом + + + + The selected media source has an unsupported size + Выбранный источник медиа имеет неподдерживаемый размер + + + + Unsupported media type + Неподдерживаемый тип медиафайла + + + + + ffmpeg was not found + ffmpeg не найден + + + + The selected media source could not be resolved + Не удалось определить выбранный исходный файл медиа + + + + The runtime rejected the upload after the staged source disappeared + Фоновый сервис отклонил загрузку после исчезновения временного исходника + + + + The private runtime directory path is empty + Путь к приватному рабочему каталогу пуст + + + + Could not create the private runtime directory: %1 + Не удалось создать приватный рабочий каталог: %1 + + + + Could not protect the private runtime directory: %1 + Не удалось защитить приватный рабочий каталог: %1 + + + + The private runtime directory must be owned by this user with mode 0700 + Приватный рабочий каталог должен принадлежать текущему пользователю и иметь права 0700 + + + + The private runtime directory layout is invalid + Структура приватного рабочего каталога некорректна + + + + The claimed device media artifact is invalid + Полученный артефакт медиафайла с устройства недействителен + + + + The claimed device media artifact is not a private regular file + Полученный артефакт медиафайла с устройства не является приватным обычным файлом + + + + The claimed device media artifact cannot be opened + Не удалось открыть полученный артефакт медиафайла с устройства + + + + The claimed device media artifact could not be verified + Не удалось проверить полученный артефакт медиафайла с устройства + + + + The claimed device media artifact changed during verification + Полученный артефакт медиафайла с устройства изменился во время проверки + + + + The claimed device media artifact failed integrity verification + Полученный артефакт медиафайла с устройства не прошёл проверку целостности + + + + The claimed device media artifact path is not canonical + Путь к полученному артефакту медиафайла с устройства не является каноническим + + + + The previous media snapshot helper is still stopping + Предыдущий вспомогательный процесс создания копии медиа ещё завершается + + + + + The per-user runtime directory is unavailable + Рабочий каталог текущего пользователя недоступен + + + + The media snapshot helper is unavailable + Вспомогательный процесс создания копии медиа недоступен + + + + Could not start the media snapshot helper: %1 + Не удалось запустить вспомогательный процесс создания копии медиа: %1 + + + + The media snapshot helper failed + Вспомогательный процесс создания копии медиа завершился с ошибкой + + + + The media snapshot helper failed: %1 + Вспомогательный процесс создания копии медиа завершился с ошибкой: %1 + + + + The private media snapshot is invalid + Приватная копия медиа некорректна + + + + The selected media transform is invalid + Выбранное преобразование медиа некорректно + + + + ffmpeg could not decode the transformed preview + FFmpeg не смог декодировать преобразованное превью + + + + %1: %2 + %1: %2 + + + + + The transformed preview frame is invalid + Преобразованный кадр превью некорректен + + + Could not create a private preview directory + Не удалось создать приватный каталог для превью + + + ffmpeg could not decode a preview frame + Не удалось декодировать кадр превью с помощью ffmpeg + + + ffmpeg could not decode a preview frame: %1 + Не удалось декодировать кадр превью с помощью ffmpeg: %1 + + + + OperationBanner + + + Cancel + Отмена + + + + PanoramaPage + + + PANORAMA + PANORAMA + + + + Retry transfer + Повторить передачу + + + + CPU Temperature + Температура CPU + + + + CPU Frequency + Частота CPU + + + + CPU Usage + Использование CPU + + + + GPU Temperature + Температура GPU + + + + GPU Frequency + Частота GPU + + + + GPU Usage + Использование GPU + + + + Memory Frequency + Частота памяти + + + + Date&Time + Дата и время + + + + + Center + По центру + + + + Align: + Выравнивание: + + + + + Left + Слева + + + + + Right + Справа + + + + Color + Цвет + + + + + + + CPU Badge + Бейдж CPU + + + + + + + GPU Badge + Бейдж GPU + + + + Save + Сохранить + + + + + Full Screen + Полный экран + + + + + Screen Splitting + Разделение экрана + + + + Play Mode: + Режим воспроизведения: + + + + + + Single + Один файл + + + + + Shuffle + Случайно + + + + + Loop + Повтор + + + + Ratio: + Соотношение: + + + + System info: + Системная информация: + + + + Upload a file +(MP4, WEBM, GIF, JPG, PNG) + Загрузите файл +(MP4, WEBM, GIF, JPG, PNG) + + + + Upload File... + Загрузить файл... + + + + Display Off + Выключить экран + + + + Disable only the PASE display backlight + Отключить только подсветку дисплея PASE + + + + + Waterfall Mode + Режим водопада + + + + Rotate the user interface by 90 degrees + Повернуть пользовательский интерфейс на 90 градусов + + + + Waterfall Mode rotates the PASE interface and media by 90 degrees. Continue? + Режим водопада поворачивает интерфейс и медиа PASE на 90 градусов. Продолжить? + + + + + + + + The PASE display session is not ready. Reconnect or power-cycle the device and wait for it to become active. + Сеанс дисплея PASE не готов. Переподключите или полностью выключите и включите устройство, затем дождитесь активации. + + + + %1: %2 + %1: %2 + + + + Initial transfer error: %1 + Исходная ошибка передачи: %1 + + + + Confirmed in the previous attempt: %1 of %2 bytes + Подтверждено в предыдущей попытке: %1 из %2 байт + + + + %1 of %2 bytes + %1 из %2 байт + + + + Last confirmed chunk: %1 + Последний подтверждённый чанк: %1 + + + + Multiple prepared uploads require reconciliation; restart the background runtime before retrying + Обнаружено несколько подготовленных загрузок, требующих сверки; перезапустите фоновую службу перед повтором + + + + + Metrics active in background runtime + Метрики активны в фоновой службе + + + + Select %1 media file(s) for this screen mode + Выберите медиафайлы для этого режима экрана: %1 + + + + This PASE media file cannot be deleted + Этот медиафайл PASE нельзя удалить + + + + This PASE media file cannot be deleted: %1 + Этот медиафайл PASE нельзя удалить: %1 + + + + Delete this file from PASE? + +Name: %1 +Size: %2 bytes + +The operation cannot be undone. + Удалить этот файл с PASE? + +Имя: %1 +Размер: %2 байт + +Операцию нельзя отменить. + + + + Delete %1 files from PASE (%2 bytes total)? + +%3 + +The operation cannot be undone. + Удалить файлов с PASE: %1 (общий размер: %2 байт)? + +%3 + +Операцию нельзя отменить. + + + + + The PASE display state is not ready yet. Reconnect the device and wait for synchronization. + Состояние дисплея PASE ещё не готово. Переподключите устройство и дождитесь синхронизации. + + + + DEVICE PRESET + ПРЕСЕТ УСТРОЙСТВА + + + + USER UPLOAD + ФАЙЛ ПОЛЬЗОВАТЕЛЯ + + + + UNKNOWN ORIGIN + ИСТОЧНИК НЕИЗВЕСТЕН + + + + Metrics configured; waiting for the PASE session + Метрики настроены, ожидание сессии PASE + + + + Retry is blocked until PASE is power-cycled and its display session becomes active. + Повтор заблокирован, пока PASE не будет полностью выключен и включён, а сеанс дисплея не станет активным. + + + + Cancel + Отмена + + + + CPU Power + Мощность CPU + + + + GPU Power + Мощность GPU + + + + Memory Usage + Использование памяти + + + + + Media Library + Медиатека + + + + Refresh + Обновить + + + + Display Settings + Настройки дисплея + + + + Brightness: + Яркость: + + + + Mirror Mode + Зеркальный режим + + + + Text Color + Цвет текста + + + + + + Metrics active + Метрики активны + + + + PASE metrics are controlled by the saved display configuration + Метрики PASE управляются сохранённой конфигурацией дисплея + + + + Select media file + Выберите медиафайл + + + + Assign media to both left and right sides + Назначьте медиа для левой и правой сторон + + + + Screen Splitting configuration applied + Конфигурация разделения экрана применена + + + + + Select files to display + Выберите файлы для отображения + + + + Full Screen configuration applied + Конфигурация полного экрана применена + + + + Set to left side + Установить слева + + + + Set to right side + Установить справа + + + + Set as display + Установить на дисплей + + + + + Screen config applied + Конфигурация экрана применена + + + + + + Delete + Удалить + + + + Select files to delete + Выберите файлы для удаления + + + + Delete %1 file(s)? + Удалить файлов: %1? + + + + + Files on device: %1 + Файлов на устройстве: %1 + + + + %1 MB + %1 МБ + + + + %1 KB + %1 КБ + + + + Uploaded: %1 + Загружено: %1 + + + + Files deleted + Файлы удалены + + + Panorama + Панорама + + + Display session active + Сеанс дисплея активен + + + Waiting for PASE + Ожидание PASE + + + + Upload media… + Загрузить медиафайл… + + + Refresh library + Обновить медиатеку + + + Delete selected + Удалить выбранное + + + + CPU temperature + Температура CPU + + + + CPU frequency + Частота CPU + + + + CPU usage + Использование CPU + + + + CPU power + Мощность CPU + + + + GPU temperature + Температура GPU + + + + GPU frequency + Частота GPU + + + + GPU usage + Использование GPU + + + + GPU power + Мощность GPU + + + + Memory usage + Использование памяти + + + + Date and time + Дата и время + + + + Reload media library + Обновить медиатеку + + + + %1 selected + Выбрано: %1 + + + + Drop one MP4, WebM, MKV, AVI, MOV, GIF, JPG, PNG, BMP or WebP file here + Перетащите сюда один файл в формате MP4, WebM, MKV, AVI, MOV, GIF, JPG, PNG, BMP или WebP + + + + No preview + Нет превью + + + + Replace exported file? + Заменить экспортированный файл? + + + + “%1” already exists. Replace it with the device copy? + Файл «%1» уже существует. Заменить его копией с устройства? + + + + Delete media + Удалить медиафайл + + + + %1 MiB + %1 МиБ + + + + + Media actions + Действия с медиафайлом + + + + Edit + Редактировать + + + + Export copy… + Экспортировать копию… + + + + No uploaded media + Нет загруженных медиафайлов + + + + Display layout + Компоновка дисплея + + + + Full screen + На весь экран + + + + Split screen + Разделение экрана + + + + Play mode + Режим воспроизведения + + + + Left: %1 Right: %2 + Слева: %1 Справа: %2 + + + + + + not selected + не выбрано + + + + Media: %1 + Медиафайл: %1 + + + + Select up to three metrics per side + Выберите до трёх метрик для каждой стороны + + + + Select up to three overlay metrics + Выберите до трёх метрик для наложения + + + + Hardware badges + Бейджи оборудования + + + + Left metrics + Метрики слева + + + + Left badges + Бейджи слева + + + + Right metrics + Метрики справа + + + + Right badges + Бейджи справа + + + + Live metrics + Метрики в реальном времени + + + + Apply to display + Применить на дисплее + + + Metrics overlay + Наложение метрик + + + + Stop metrics overlay + Остановить наложение метрик + + + + Start metrics overlay + Запустить наложение метрик + + + + Screen controls + Управление экраном + + + + Apply brightness + Применить яркость + + + + Turn display off + Выключить дисплей + + + + Turn display on + Включить дисплей + + + + Orientation + Ориентация + + + + Delete “%1” from the device? This cannot be undone. + Удалить «%1» с устройства? Это действие нельзя отменить. + + + Apply layout + Применить компоновку + + + Live metrics service + Служба метрик реального времени + + + + Alignment + Выравнивание + + + + Text color + Цвет текста + + + Disable metrics + Отключить метрики + + + Enable metrics + Включить метрики + + + + Sampling is active + Сбор данных активен + + + + Sampling is inactive + Сбор данных неактивен + + + Display controls + Управление дисплеем + + + + Brightness + Яркость + + + Apply + Применить + + + + Backlight + Подсветка + + + + On + Вкл. + + + + Off + Выкл. + + + Turn off + Выключить + + + Turn on + Включить + + + + Mirror + Зеркальное отображение + + + + Waterfall + Водопад + + + + Apply orientation + Применить ориентацию + + + + Recent operations + Последние операции + + + + Retry + Повторить + + + Media files (*.mp4 *.webm *.mkv *.avi *.mov *.gif *.jpg *.jpeg *.png *.bmp *.webp) + Медиафайлы (*.mp4 *.webm *.mkv *.avi *.mov *.gif *.jpg *.jpeg *.png *.bmp *.webp) + + + + PrinterDeviceMonitor + + + Failed to initialize libudev for TRYX device monitoring + Не удалось инициализировать libudev для отслеживания устройства TRYX + + + + Failed to start passive TRYX udev monitoring + Не удалось запустить пассивное отслеживание TRYX через udev + + + + libudev did not provide a monitor file descriptor + libudev не предоставила файловый дескриптор монитора + + + + PrinterMediaPreparer + + + Media preparation was cancelled before it started + Подготовка медиа отменена до начала + + + + Media file does not exist + Медиафайл не существует + + + + + Unsupported media file type + Неподдерживаемый тип медиафайла + + + + Media transform is invalid + Недопустимые параметры преобразования медиафайла + + + + Could not calculate the source media content hash + Не удалось рассчитать хеш содержимого исходного медиафайла + + + + Source media content identity is invalid + Идентификатор содержимого исходного медиафайла недействителен + + + + Media transform is invalid: %1 + Недопустимые параметры преобразования медиафайла: %1 + + + + ffmpeg not found. Install it with your system package manager + ffmpeg не найден. Установите его через пакетный менеджер вашей системы + + + + Media transform filter is invalid + Недопустимый фильтр преобразования медиафайла + + + + Converting to printer-class H264... + Конвертация в H264 для printer-class... + + + + + Conversion to printer-class H264 timed out + Истекло время преобразования в H264 для printer-class + + + + Prepared H264 exceeds the supported upload size + Размер подготовленного H264 превышает допустимый для загрузки + + + + Conversion to printer-class H264 failed + Не удалось конвертировать в H264 для printer-class + + + + Conversion to printer-class H264 failed: %1 + Не удалось конвертировать в H264 для printer-class: %1 + + + + Could not verify the prepared H264 file + Не удалось проверить подготовленный файл H264 + + + + Source media changed after content analysis + Исходный медиафайл изменился после анализа содержимого + + + + Preparing a persistent preview... + Подготовка постоянного превью... + + + + Persistent preview timed out; continuing with a placeholder + Истекло время создания постоянного превью, используется заглушка + + + + + Prepared-media validation was cancelled + Проверка подготовленного медиафайла отменена + + + + Prepared media failed retry-cache validation + Подготовленный медиафайл не прошёл проверку кэша повтора + + + + Prepared media hash does not match the retry cache + Хэш подготовленного медиафайла не совпадает с кэшем повтора + + + + QObject + + + + + Command timed out: %1 + Истекло время ожидания команды: %1 + + + + + Extracted ZIP entry is empty: %1 + Извлеченный элемент ZIP пустой: %1 + + + + Cannot open %1: %2 + Не удалось открыть %1: %2 + + + + OK + OK + + + + Refusing unverified TRYX endpoint path: %1 + Отказ от непроверенного пути к endpoint TRYX: %1 + + + + TRYX endpoint is not a character device: %1 + Endpoint TRYX не является символьным устройством: %1 + + + + Endpoint %1 is not the expected 391a:1021 printer interface + Endpoint %1 не является ожидаемым интерфейсом принтера 391a:1021 + + + + Endpoint %1 does not match its usblp sysfs device + Endpoint %1 не соответствует своему устройству usblp в sysfs + + + + Endpoint %1 changed after it was opened + Endpoint %1 изменился после открытия + + + + not enough device storage + недостаточно места на устройстве + + + + file error + ошибка файла + + + + CRC check failed + проверка CRC не прошла + + + + File transfer failed: %1 + Передача файла не удалась: %1 + + + + + unknown transfer status + неизвестный статус передачи + + + + libusb event backend is not available + Обработчик событий libusb недоступен + + + + TRYX frame buffer is not available + Буфер кадров TRYX недоступен + + + + TRYX response has invalid frame magic + Ответ TRYX содержит неверную сигнатуру кадра + + + + TRYX response payload is too large: %1 bytes + Полезная нагрузка ответа TRYX слишком велика: %1 байт + + + + TRYX printer-class device is absent + Устройство TRYX printer-class отсутствует + + + + TRYX display is in 391a:0006 Rockchip gadget mode; PASE printer mode is not ready + Дисплей TRYX находится в режиме Rockchip gadget 391a:0006. Режим принтера PASE не готов + + + + Unknown TRYX printer-class state + Неизвестное состояние TRYX printer-class + + + + + TRYX request is not available + Запрос TRYX недоступен + + + + + + + Failed to serialize bounded TRYX protobuf request + Не удалось сериализовать ограниченный protobuf-запрос TRYX + + + + + + + TRYX request exceeds the maximum frame size + Запрос TRYX превышает максимальный размер кадра + + + + TRYX device rejected the request with error %1 + Устройство TRYX отклонило запрос с ошибкой %1 + + + + TRYX response body %1 does not match expected body %2 + Тело ответа TRYX %1 не соответствует ожидаемому телу %2 + + + + Too many unrelated TRYX response frames + Получено слишком много несвязанных кадров ответа TRYX + + + + Timed out waiting for the matching TRYX USB response + Истекло время ожидания соответствующего USB-ответа TRYX + + + + TRYX session bootstrap response has an unexpected header + Ответ bootstrap-сессии TRYX содержит неожиданный заголовок + + + + Failed to serialize bounded TRYX keepalive request + Не удалось сериализовать ограниченный по размеру запрос keepalive TRYX + + + + + + + + TRYX USB operation was cancelled because the device state changed + USB-операция TRYX отменена из-за изменения состояния устройства + + + + TRYX printer-class endpoint is not available: %1 + Endpoint TRYX printer-class недоступен: %1 + + + + Cannot open %1: permission denied. Grant read/write access to 391a:1021. + Не удалось открыть %1: доступ запрещён. Предоставьте доступ на чтение и запись к 391a:1021. + + + + TRYX USB poll failed: %1 + Ошибка опроса USB TRYX: %1 + + + + TRYX USB endpoint disconnected during the operation + USB endpoint TRYX отключился во время операции + + + + + Timed out writing the TRYX USB request + Истекло время записи USB-запроса TRYX + + + + + TRYX USB write failed: %1 + Ошибка записи в USB TRYX: %1 + + + + TRYX USB write returned zero bytes + Запись в USB TRYX вернула ноль байт + + + + Failed to parse a TRYX protobuf response + Не удалось разобрать protobuf-ответ TRYX + + + + Tracked TRYX response does not contain a header + Отслеживаемый ответ TRYX не содержит заголовок + + + + Failed to serialize bounded TRYX session bootstrap request + Не удалось сериализовать ограниченный bootstrap-запрос сессии TRYX + + + + Failed to create the bounded TRYX session bootstrap frame + Не удалось создать ограниченный bootstrap-кадр сессии TRYX + + + + Failed to parse a TRYX session bootstrap response + Не удалось разобрать bootstrap-ответ сессии TRYX + + + + TRYX device rejected the session bootstrap with error %1 + Устройство TRYX отклонило bootstrap сессии с ошибкой %1 + + + + Too many unrelated TRYX session bootstrap response frames + Получено слишком много посторонних кадров ответа во время bootstrap сессии TRYX + + + + Failed to create the bounded TRYX keepalive frame + Не удалось создать ограниченный по размеру кадр keepalive TRYX + + + + TRYX USB keepalive write failed: %1 + Ошибка записи USB keepalive TRYX: %1 + + + + TRYX USB keepalive was only partially written; the stream state is uncertain + USB keepalive TRYX записан частично; состояние потока неизвестно + + + + TRYX keepalive drain poll failed: %1 + Ошибка poll при очистке ответов keepalive TRYX: %1 + + + + TRYX USB endpoint disconnected before keepalive + USB endpoint TRYX отключён перед keepalive + + + + TRYX keepalive drain read failed: %1 + Ошибка чтения при очистке ответов keepalive TRYX: %1 + + + + + TRYX receive buffer exceeded its bounded size while draining keepalive + Буфер приёма TRYX превысил допустимый размер при очистке keepalive + + + + Too many queued TRYX keepalive response frames + Слишком много ожидающих кадров ответа keepalive TRYX + + + + + TRYX USB read failed: %1 + Ошибка чтения из USB TRYX: %1 + + + + unknown libusb error %1 + неизвестная ошибка libusb %1 + + + + completed + завершено + + + + I/O error + ошибка ввода-вывода + + + + timed out + превышено время ожидания + + + + cancelled + отменено + + + + endpoint stalled + конечная точка остановлена + + + + device disconnected + устройство отключено + + + + receive overflow + переполнение приёма + + + + Cannot initialize libusb: %1 + Не удалось инициализировать libusb: %1 + + + + Cannot enumerate TRYX USB devices: %1 + Не удалось перечислить USB-устройства TRYX: %1 + + + + Cannot open TRYX usbfs device: permission denied + Не удалось открыть устройство TRYX в usbfs: доступ запрещён + + + + Cannot open TRYX usbfs device: %1 + Не удалось открыть устройство TRYX в usbfs: %1 + + + + TRYX USB device is no longer available: %1 + USB-устройство TRYX больше недоступно: %1 + + + + Cannot detach usblp from the TRYX interface: %1 + Не удалось отключить usblp от интерфейса TRYX: %1 + + + + Cannot determine the TRYX kernel-driver owner: %1 + Не удалось определить драйвер ядра, владеющий интерфейсом TRYX: %1 + + + + Cannot reattach usblp after a failed TRYX interface claim: %1 + Не удалось повторно подключить usblp после ошибки захвата интерфейса TRYX: %1 + + + + Cannot claim the TRYX printer interface: %1 + Не удалось захватить интерфейс принтера TRYX: %1 + + + + Cannot release the TRYX printer interface after alternate-setting failure: %1 + Не удалось освободить интерфейс принтера TRYX после ошибки выбора альтернативной настройки: %1 + + + + Cannot reattach usblp after a failed TRYX alternate-setting selection: %1 + Не удалось повторно подключить usblp после ошибки выбора альтернативной настройки TRYX: %1 + + + + Cannot select the TRYX USB alternate setting: %1 + Не удалось выбрать альтернативную настройку USB-интерфейса TRYX: %1 + + + + + + + + TRYX USB input reached the persistent failure threshold + Входящий USB-транспорт TRYX достиг порога устойчивого сбоя + + + + TRYX USB input did not recover after %1 bounded empty or error completions + Входящий USB-канал TRYX не восстановился после %1 ограниченных пустых или ошибочных завершений + + + + Cannot allocate the TRYX asynchronous IN transfer + Не удалось выделить асинхронную IN-передачу TRYX + + + + TRYX asynchronous IN transport is not available + Асинхронный канал чтения TRYX недоступен + + + + Cannot submit the TRYX asynchronous IN transfer: %1 + Не удалось отправить асинхронную IN-передачу TRYX: %1 + + + + + Cannot cancel the TRYX asynchronous OUT transfer: %1 + Не удалось отменить асинхронную OUT-передачу TRYX: %1 + + + + Cannot cancel the TRYX asynchronous IN transfer: %1 + Не удалось отменить асинхронную IN-передачу TRYX: %1 + + + + TRYX libusb cancellation could not be drained safely; the runtime will exit so systemd can release the USB claim and restart it + Не удалось безопасно завершить отмену libusb TRYX. Служба завершится, чтобы systemd освободил захват USB и перезапустил её + + + + Cannot release the TRYX printer interface: %1 + Не удалось освободить интерфейс принтера TRYX: %1 + + + + Cannot reattach usblp to the TRYX printer interface: %1 + Не удалось повторно подключить usblp к интерфейсу принтера TRYX: %1 + + + + TRYX libusb transport is not open + Транспорт TRYX libusb не открыт + + + + A previous TRYX asynchronous OUT transfer is still active + Предыдущая асинхронная OUT-передача TRYX ещё активна + + + + Cannot allocate the TRYX asynchronous OUT transfer + Не удалось выделить асинхронную OUT-передачу TRYX + + + + Cannot submit the TRYX asynchronous OUT transfer: %1 + Не удалось отправить асинхронную OUT-передачу TRYX: %1 + + + + TRYX asynchronous OUT cancellation did not complete within its deadline + Отмена асинхронной OUT-передачи TRYX не завершилась за отведённое время + + + + TRYX USB request was only partially transferred: %1 of %2 bytes + USB-запрос TRYX передан частично: %1 из %2 байт + + + + TRYX libusb receive queue exceeded its bounded size + Очередь приёма TRYX libusb превысила допустимый размер + + + + TRYX libusb event handling failed: %1 + Обработка событий TRYX libusb завершилась ошибкой: %1 + + + + TRYX 391a:1021 is enumerating; waiting for a valid USB printer interface + TRYX 391a:1021 определяется; ожидается корректный USB-интерфейс принтера + + + + TRYX direct USB printer interface is ready + Прямой USB-интерфейс принтера TRYX готов + + + + TRYX usbfs device exists but is not readable and writable + Устройство TRYX в usbfs существует, но недоступно для чтения и записи + + + + TRYX direct USB device identifier is empty + Идентификатор прямого USB-устройства TRYX пуст + + + + + + TRYX response stream could not be resynchronized within %1 bytes + Не удалось повторно синхронизировать поток ответов TRYX в пределах %1 байт + + + + Media pull candidate storage is not available + Хранилище для извлекаемой копии медиафайла недоступно + + + + + Selected media is no longer present in the fresh device catalog + Выбранного медиафайла больше нет в актуальном каталоге устройства + + + + Selected media is not a unique writable user entry in the fresh device catalog + Выбранный медиафайл не является уникальной доступной для записи пользовательской записью в актуальном каталоге устройства + + + + Selected media is read-only and cannot be pulled + Выбранный медиафайл доступен только для чтения, поэтому его нельзя извлечь + + + + Selected media size changed or exceeds the bounded pull limit + Размер выбранного медиафайла изменился или превышает установленное ограничение для извлечения + + + + Selected media has an unsafe or ambiguous device path + Путь к выбранному медиафайлу на устройстве небезопасен или неоднозначен + + + + TRYX media pull response protocol version %1 is not supported + Версия %1 протокола ответа TRYX на извлечение медиафайла не поддерживается + + + + TRYX idempotent query retry budget was exhausted + Исчерпан лимит повторов идемпотентного запроса TRYX + + + + TRYX session bootstrap response body %1 does not match expected body %2 + Тело ответа инициализации сеанса TRYX %1 не совпадает с ожидаемым телом %2 + + + + Timed out waiting for an exact TRYX session bootstrap response + Истекло время ожидания точного ответа инициализации сеанса TRYX - - Select files to delete - Выберите файлы для удаления + + Simulated confirmed zero-byte TRYX DeviceInfo OUT + Сымитирована подтверждённая исходящая передача TRYX DeviceInfo с нулём записанных байт - - Delete %1 file(s)? - Удалить файлов: %1? + + TRYX DeviceInfo readiness failed after %1 attempts: %2 + Проверка готовности TRYX DeviceInfo завершилась сбоем после %1 попыток: %2 - - - Files on device: %1 - Файлов на устройстве: %1 + + TRYX DeviceInfo readiness did not become ready after %1 attempts within %2 ms: %3 + TRYX DeviceInfo не подтвердил готовность после %1 попыток за %2 мс: %3 - - %1 MB - %1 МБ + + TRYX readiness backoff poll failed: %1 + Сбой опроса задержки повторной проверки готовности TRYX: %1 - - %1 KB - %1 КБ + + + TRYX device rejected the optional response with error %1 + Устройство TRYX вернуло отказ в необязательном ответе, ошибка %1 - - Uploaded: %1 - Загружено: %1 + + Timed out while draining queued TRYX response frames + Истекло время ожидания при очистке очереди кадров ответов TRYX - - Files deleted - Файлы удалены + + Recovered a tracked TRYX protobuf response after the USB transport dropped its frame header + Отслеживаемый protobuf-ответ TRYX восстановлен после потери заголовка кадра USB-транспортом - - - PrinterDeviceMonitor - - Failed to initialize libudev for TRYX device monitoring - Не удалось инициализировать libudev для отслеживания устройства TRYX + + + TRYX receive buffer exceeded its bounded size + Буфер приёма TRYX превысил установленный предел - - Failed to start passive TRYX udev monitoring - Не удалось запустить пассивное отслеживание TRYX через udev + + + TRYX in-flight keepalive write remained unavailable after %1 attempts + Запись keepalive TRYX во время активного запроса осталась недоступной после %1 попыток - - libudev did not provide a monitor file descriptor - libudev не предоставила файловый дескриптор монитора + + + Timed out after discarding malformed TRYX response bytes + Превышено время ожидания после удаления повреждённых байтов ответа TRYX - - - PrinterMediaPreparer - - Media preparation was cancelled before it started - Подготовка медиа отменена до начала + + + Timed out with an incomplete TRYX USB response frame + Истекло время ожидания при получении неполного кадра USB-ответа TRYX - - Media file does not exist - Медиафайл не существует + + + Timed out waiting for the TRYX USB response + Истекло время ожидания USB-ответа TRYX - - - Unsupported media file type - Неподдерживаемый тип медиафайла + + TRYX USB transport disconnected while waiting for a response + USB-транспорт TRYX отключился во время ожидания ответа - - Could not calculate the source media content hash - Не удалось рассчитать хеш содержимого исходного медиафайла + + TRYX media pull was cancelled because the device state changed + Извлечение медиафайла TRYX отменено из-за изменения состояния устройства - - Source media content identity is invalid - Идентификатор содержимого исходного медиафайла недействителен + + Media pull requires a bounded decoded-chunk sink + Для извлечения медиафайла необходим приёмник декодированных фрагментов с заданными ограничениями - - ffmpeg not found. Install it with your system package manager - ffmpeg не найден. Установите его через пакетный менеджер вашей системы + + Selected media identity is not eligible for a bounded pull + Идентификатор выбранного медиафайла не подходит для извлечения с заданными ограничениями - - Converting to printer-class H264... - Конвертация в H264 для printer-class... + + + Media pull exceeded its bounded operation deadline during catalog preflight + При предварительной проверке каталога превышено предельное время операции извлечения медиафайла - - - Conversion to printer-class H264 timed out - Истекло время преобразования в H264 для printer-class + + Cannot read the fresh media catalog before pull: %1 + Не удалось прочитать актуальный каталог медиафайлов перед извлечением: %1 - - Prepared H264 exceeds the supported upload size - Размер подготовленного H264 превышает допустимый для загрузки + + Media pull exceeded its bounded operation deadline + Превышено предельное время операции извлечения медиафайла - - Conversion to printer-class H264 failed - Не удалось конвертировать в H264 для printer-class + + Media pull exceeded the bounded chunk count + Количество фрагментов при извлечении медиафайла превысило установленный предел - - Conversion to printer-class H264 failed: %1 - Не удалось конвертировать в H264 для printer-class: %1 + + Media pull exceeded its bounded operation deadline while waiting for a chunk + Во время ожидания фрагмента превышено предельное время операции извлечения медиафайла - - Could not verify the prepared H264 file - Не удалось проверить подготовленный файл H264 + + TRYX media pull request failed at offset %1: %2 + Запрос TRYX на извлечение медиафайла завершился ошибкой на смещении %1: %2 - - Source media changed after content analysis - Исходный медиафайл изменился после анализа содержимого + + Media pull exceeded its bounded operation deadline while receiving a chunk + Во время получения фрагмента превышено предельное время операции извлечения медиафайла - - Preparing a persistent preview... - Подготовка постоянного превью... + + TRYX device reported a file error while pulling media + Устройство TRYX сообщило об ошибке файла при извлечении медиафайла - - Persistent preview timed out; continuing with a placeholder - Истекло время создания постоянного превью, используется заглушка + + TRYX device returned an unknown media pull status + Устройство TRYX вернуло неизвестный статус извлечения медиафайла - - - Prepared-media validation was cancelled - Проверка подготовленного медиафайла отменена + + TRYX media pull response changed the validated device path + Ответ TRYX на извлечение медиафайла содержит другой путь на устройстве, чем был проверен - - Prepared media failed retry-cache validation - Подготовленный медиафайл не прошёл проверку кэша повтора + + TRYX media pull response changed the session identifier + Ответ TRYX на извлечение медиафайла содержит другой идентификатор сеанса - - Prepared media hash does not match the retry cache - Хэш подготовленного медиафайла не совпадает с кэшем повтора + + TRYX media pull response offset does not match the requested offset + Смещение в ответе TRYX на извлечение медиафайла не совпадает с запрошенным - - - QObject - - - - Command timed out: %1 - Истекло время ожидания команды: %1 + + TRYX media pull response changed the fresh catalog file size + Размер файла в ответе TRYX на извлечение медиафайла не совпадает с размером в актуальном каталоге - - - Extracted ZIP entry is empty: %1 - Извлеченный элемент ZIP пустой: %1 + + TRYX media pull made no progress before end of file + При извлечении медиафайла TRYX не было получено новых данных до конца файла - - Cannot open %1: %2 - Не удалось открыть %1: %2 + + TRYX media pull chunk exceeds the validated file size + Фрагмент извлекаемого медиафайла TRYX выходит за пределы подтверждённого размера файла - - OK - OK + + + Media pull exceeded its bounded operation deadline while decoding a chunk + Во время декодирования фрагмента превышено предельное время операции извлечения медиафайла - - Refusing unverified TRYX endpoint path: %1 - Отказ от непроверенного пути к endpoint TRYX: %1 + + TRYX media pull failed to decode a complete chunk + При извлечении медиафайла TRYX не удалось полностью декодировать фрагмент - - TRYX endpoint is not a character device: %1 - Endpoint TRYX не является символьным устройством: %1 + + Decoded media pull sink rejected a chunk + Приёмник декодированных данных извлечения медиафайла отклонил фрагмент - - Endpoint %1 is not the expected 391a:1021 printer interface - Endpoint %1 не является ожидаемым интерфейсом принтера 391a:1021 + + Media pull exceeded its bounded operation deadline while storing a chunk + Во время сохранения фрагмента превышено предельное время операции извлечения медиафайла - - Endpoint %1 does not match its usblp sysfs device - Endpoint %1 не соответствует своему устройству usblp в sysfs + + Media pull exceeded its bounded operation deadline while reporting progress + Во время отправки данных о прогрессе превышено предельное время операции извлечения медиафайла - - Endpoint %1 changed after it was opened - Endpoint %1 изменился после открытия + + TRYX media pull did not finish at the validated file size + При извлечении медиафайла TRYX не был получен весь подтверждённый размер файла - - not enough device storage - недостаточно места на устройстве + Selected media name is not eligible for reference preflight + Имя выбранного медиафайла не подходит для предварительной проверки ссылок - - file error - ошибка файла + + Cannot read the fresh media catalog before reference preflight: %1 + Не удалось прочитать актуальный каталог медиафайлов перед предварительной проверкой ссылок: %1 - - CRC check failed - проверка CRC не прошла + + Selected media identity changed in the fresh device catalog + Идентификатор выбранного медиафайла изменился в свежем каталоге устройства - - File transfer failed: %1 - Передача файла не удалась: %1 + + The expected replacement copy is absent from the fresh device catalog + Ожидаемая новая копия отсутствует в свежем каталоге устройства - - - unknown transfer status - неизвестный статус передачи + + The expected replacement identity changed in the fresh device catalog + Идентификатор ожидаемой новой копии изменился в свежем каталоге устройства - - libusb event backend is not available - Обработчик событий libusb недоступен + + Cannot read device configuration during reference preflight: %1 + Не удалось прочитать конфигурацию устройства во время предварительной проверки ссылок: %1 - - TRYX frame buffer is not available - Буфер кадров TRYX недоступен + + No media files were selected for deletion + Не выбраны медиафайлы для удаления - - TRYX response has invalid frame magic - Ответ TRYX содержит неверную сигнатуру кадра + + Expected delete media identity is invalid + Ожидаемый идентификатор удаляемого медиафайла недействителен - - TRYX response payload is too large: %1 bytes - Полезная нагрузка ответа TRYX слишком велика: %1 байт + + Expected replacement identity before deletion is invalid + Ожидаемый идентификатор новой копии перед удалением недействителен - - TRYX printer-class device is absent - Устройство TRYX printer-class отсутствует + + Media file is not eligible for deletion: %1 + Медиафайл нельзя удалить: %1 - - TRYX display is in 391a:0006 Rockchip gadget mode; PASE printer mode is not ready - Дисплей TRYX находится в режиме Rockchip gadget 391a:0006. Режим принтера PASE не готов + + Cannot read the media list before deletion: %1 + Не удалось прочитать список медиафайлов перед удалением: %1 - - Unknown TRYX printer-class state - Неизвестное состояние TRYX printer-class + + The file is still present during delete reconciliation; FileRemove will not be repeated: %1 + Во время сверки удаления файл всё ещё присутствует. FileRemove не будет отправлен повторно: %1 - - - TRYX request is not available - Запрос TRYX недоступен + + The media identity changed during delete reconciliation; FileRemove will not be repeated: %1 + Идентификатор медиафайла изменился при сверке удаления. FileRemove не будет отправлен повторно: %1 - - - - - Failed to serialize bounded TRYX protobuf request - Не удалось сериализовать ограниченный protobuf-запрос TRYX + + Cannot refresh the media list before deleting %1: %2 + Не удалось обновить список медиафайлов перед удалением %1: %2 - - - - - TRYX request exceeds the maximum frame size - Запрос TRYX превышает максимальный размер кадра + + Media file is absent from the fresh device list: %1 + Медиафайл отсутствует в новом списке устройства: %1 - - TRYX device rejected the request with error %1 - Устройство TRYX отклонило запрос с ошибкой %1 + + Media identity changed before deletion: %1 + Идентификатор медиафайла изменился перед удалением: %1 - - TRYX response body %1 does not match expected body %2 - Тело ответа TRYX %1 не соответствует ожидаемому телу %2 + + Media file is protected or ambiguous: %1 + Медиафайл защищён или определён неоднозначно: %1 - - Too many unrelated TRYX response frames - Получено слишком много несвязанных кадров ответа TRYX + + Cannot verify device configuration before deleting %1: %2 + Не удалось проверить конфигурацию устройства перед удалением %1: %2 - - Timed out waiting for the matching TRYX USB response - Истекло время ожидания соответствующего USB-ответа TRYX + + Media file is referenced by the active device configuration: %1 + Медиафайл используется активной конфигурацией устройства: %1 - - TRYX session bootstrap response has an unexpected header - Ответ bootstrap-сессии TRYX содержит неожиданный заголовок + + Cannot revalidate media identity immediately before deleting %1: %2 + Не удалось повторно проверить идентификатор медиафайла непосредственно перед удалением %1: %2 - - Failed to serialize bounded TRYX keepalive request - Не удалось сериализовать ограниченный по размеру запрос keepalive TRYX + + Media identity changed immediately before deletion: %1 + Идентификатор медиафайла изменился непосредственно перед удалением: %1 - - - - - - TRYX USB operation was cancelled because the device state changed - USB-операция TRYX отменена из-за изменения состояния устройства + + Replacement identity changed immediately before deleting the original: %1 + Идентификатор новой копии изменился непосредственно перед удалением исходного файла: %1 - - TRYX printer-class endpoint is not available: %1 - Endpoint TRYX printer-class недоступен: %1 + + Deletion was stopped before dispatch + Удаление остановлено до отправки команды - - Cannot open %1: permission denied. Grant read/write access to 391a:1021. - Не удалось открыть %1: доступ запрещён. Предоставьте доступ на чтение и запись к 391a:1021. + + FileRemove was not sent + FileRemove не был отправлен - - TRYX USB poll failed: %1 - Ошибка опроса USB TRYX: %1 + + FileRemove may have been sent, but the transport cannot safely reconcile FileList + FileRemove мог быть отправлен, но транспорт не позволяет безопасно сверить FileList - - TRYX USB endpoint disconnected during the operation - USB endpoint TRYX отключился во время операции + + FileRemove may have been sent, but FileList reconciliation failed for %1: %2 + FileRemove мог быть отправлен, но сверка FileList для %1 завершилась ошибкой: %2 - - - Timed out writing the TRYX USB request - Истекло время записи USB-запроса TRYX + + The device rejected deletion of %1 + Устройство отклонило удаление %1 - - - TRYX USB write failed: %1 - Ошибка записи в USB TRYX: %1 + + The device still reports %1 after bounded reconciliation; FileRemove will not be repeated + После ограниченной сверки устройство всё ещё сообщает о %1. FileRemove не будет отправлен повторно - - TRYX USB write returned zero bytes - Запись в USB TRYX вернула ноль байт + + Media file does not exist: %1 + Медиафайл не существует: %1 - - Failed to parse a TRYX protobuf response - Не удалось разобрать protobuf-ответ TRYX + + Media source is not a stable regular file: %1 + Источник медиа не является стабильным обычным файлом: %1 - - Tracked TRYX response does not contain a header - Отслеживаемый ответ TRYX не содержит заголовок + + Media file size is not supported: %1 bytes + Размер медиафайла не поддерживается: %1 байт - - Failed to serialize bounded TRYX session bootstrap request - Не удалось сериализовать ограниченный bootstrap-запрос сессии TRYX + + Prepared media has an invalid expected SHA-256 hash + Для подготовленного медиа указан некорректный ожидаемый SHA-256 - - Failed to create the bounded TRYX session bootstrap frame - Не удалось создать ограниченный bootstrap-кадр сессии TRYX + + Prepared-media hash validation was cancelled + Проверка хеша подготовленного медиа отменена - - Failed to parse a TRYX session bootstrap response - Не удалось разобрать bootstrap-ответ сессии TRYX + + Cannot validate prepared media: %1 + Не удалось проверить подготовленное медиа: %1 - - TRYX device rejected the session bootstrap with error %1 - Устройство TRYX отклонило bootstrap сессии с ошибкой %1 + + Prepared media changed after retry validation + Подготовленное медиа изменилось после проверки для повтора - - Too many unrelated TRYX session bootstrap response frames - Получено слишком много посторонних кадров ответа во время bootstrap сессии TRYX + + TRYX user configuration is missing display or work configuration + В пользовательской конфигурации TRYX отсутствуют настройки дисплея или рабочего режима - - Failed to create the bounded TRYX keepalive frame - Не удалось создать ограниченный по размеру кадр keepalive TRYX + + The PASE configuration request does not contain a change + Запрос конфигурации PASE не содержит изменений - - TRYX USB keepalive write failed: %1 - Ошибка записи USB keepalive TRYX: %1 + + PASE standby configuration is fixed by the firmware; use display power control instead + Конфигурация standby PASE зафиксирована прошивкой; используйте управление питанием дисплея - - TRYX USB keepalive was only partially written; the stream state is uncertain - USB keepalive TRYX записан частично; состояние потока неизвестно + + The PASE screen mode is not supported + Режим экрана PASE не поддерживается + + + + The PASE play mode is not supported for this screen mode + Режим воспроизведения PASE не поддерживается для выбранного режима экрана - - TRYX keepalive drain poll failed: %1 - Ошибка poll при очистке ответов keepalive TRYX: %1 + + The PASE screen mode requires %1 media file(s) + Для режима экрана PASE требуется медиафайлов: %1 - - TRYX USB endpoint disconnected before keepalive - USB endpoint TRYX отключён перед keepalive + + PASE brightness must be between 0 and 100 + Яркость PASE должна быть в диапазоне от 0 до 100 - - TRYX keepalive drain read failed: %1 - Ошибка чтения при очистке ответов keepalive TRYX: %1 + + TRYX user configuration has no work configuration; refusing a synthetic write + В пользовательской конфигурации TRYX нет рабочей конфигурации. Синтетическая запись запрещена - - - TRYX receive buffer exceeded its bounded size while draining keepalive - Буфер приёма TRYX превысил допустимый размер при очистке keepalive + + TRYX user configuration has no display configuration; refusing a synthetic write + В пользовательской конфигурации TRYX нет конфигурации дисплея. Синтетическая запись запрещена - - Too many queued TRYX keepalive response frames - Слишком много ожидающих кадров ответа keepalive TRYX + + display power + питание дисплея - - - TRYX USB read failed: %1 - Ошибка чтения из USB TRYX: %1 + + TRYX accepted and activated the configuration, but device readback failed + TRYX принял и активировал конфигурацию, но не удалось прочитать её обратно с устройства - - unknown libusb error %1 - неизвестная ошибка libusb %1 + + Selected media identity is not eligible for reference preflight + Идентификатор выбранного медиафайла не подходит для предварительной проверки ссылок - - completed - завершено + + Expected replacement media identity is not eligible for reference preflight + Идентификатор ожидаемой новой копии не подходит для предварительной проверки ссылок - - I/O error - ошибка ввода-вывода + + TRYX accepted and activated the configuration, but device readback failed: %1 + TRYX принял и активировал конфигурацию, но не удалось прочитать её обратно с устройства: %1 - - timed out - превышено время ожидания + + overlay activation + активация наложения - - cancelled - отменено + + screen mode + режим экрана - - endpoint stalled - конечная точка остановлена + + play mode + режим воспроизведения - - device disconnected - устройство отключено + + media + медиафайлы - - receive overflow - переполнение приёма + + brightness + яркость - - Cannot initialize libusb: %1 - Не удалось инициализировать libusb: %1 + + mirror + зеркальный режим - - Cannot enumerate TRYX USB devices: %1 - Не удалось перечислить USB-устройства TRYX: %1 + + waterfall + режим водопада - - Cannot open TRYX usbfs device: permission denied - Не удалось открыть устройство TRYX в usbfs: доступ запрещён + + TRYX device readback does not match the requested configuration: %1 + Обратное чтение конфигурации TRYX не совпало с запросом: %1 - - Cannot open TRYX usbfs device: %1 - Не удалось открыть устройство TRYX в usbfs: %1 + + The TRYX user configuration was fully sent, but its outcome was not confirmed. The configuration may already be stored; automatic rollback is disabled + Пользовательская конфигурация TRYX полностью отправлена, но её результат не подтверждён. Конфигурация уже могла быть сохранена. Автоматический откат отключён - - TRYX USB device is no longer available: %1 - USB-устройство TRYX больше недоступно: %1 + + The TRYX user configuration was fully sent, but its outcome was not confirmed: %1. The configuration may already be stored; automatic rollback is disabled + Пользовательская конфигурация TRYX полностью отправлена, но её результат не подтверждён: %1. Конфигурация уже могла быть сохранена. Автоматический откат отключён - - Cannot detach usblp from the TRYX interface: %1 - Не удалось отключить usblp от интерфейса TRYX: %1 + + TRYX accepted the user configuration but rejected overlay activation + TRYX принял пользовательскую конфигурацию, но отклонил активацию наложения - - Cannot determine the TRYX kernel-driver owner: %1 - Не удалось определить драйвер ядра, владеющий интерфейсом TRYX: %1 + + TRYX accepted the user configuration but rejected overlay activation: %1 + TRYX принял пользовательскую конфигурацию, но отклонил активацию наложения: %1 - - Cannot reattach usblp after a failed TRYX interface claim: %1 - Не удалось повторно подключить usblp после ошибки захвата интерфейса TRYX: %1 + + TRYX accepted the user configuration, but activation was not confirmed. The configuration may already be stored; automatic rollback is disabled + TRYX принял пользовательскую конфигурацию, но активация не подтверждена. Конфигурация уже могла быть сохранена. Автоматический откат отключён - - Cannot claim the TRYX printer interface: %1 - Не удалось захватить интерфейс принтера TRYX: %1 + + TRYX accepted the user configuration, but activation failed: %1. The configuration may already be stored; automatic rollback is disabled + TRYX принял пользовательскую конфигурацию, но активация завершилась ошибкой: %1. Конфигурация уже могла быть сохранена. Автоматический откат отключён - - Cannot release the TRYX printer interface after alternate-setting failure: %1 - Не удалось освободить интерфейс принтера TRYX после ошибки выбора альтернативной настройки: %1 + + Media file name is not supported: %1 + Имя медиафайла не поддерживается: %1 - - Cannot reattach usblp after a failed TRYX alternate-setting selection: %1 - Не удалось повторно подключить usblp после ошибки выбора альтернативной настройки TRYX: %1 + + Multiple TRYX printer-class devices or endpoints were found + Найдено несколько устройств или endpoint TRYX printer-class - - Cannot select the TRYX USB alternate setting: %1 - Не удалось выбрать альтернативную настройку USB-интерфейса TRYX: %1 + + TRYX USB monitoring is unavailable; printer-class I/O is disabled + Мониторинг USB TRYX недоступен. Ввод-вывод printer-class отключён - - - - - - TRYX USB input reached the persistent failure threshold - Входящий USB-транспорт TRYX достиг порога устойчивого сбоя + + + Cannot open media file: %1 + Не удалось открыть медиафайл: %1 - - TRYX USB input did not recover after %1 bounded empty or error completions - Входящий USB-канал TRYX не восстановился после %1 ограниченных пустых или ошибочных завершений + + Media file changed during transfer + Медиафайл изменился во время передачи - - Cannot allocate the TRYX asynchronous IN transfer - Не удалось выделить асинхронную IN-передачу TRYX + + Media file ended before its declared size + Медиафайл закончился раньше заявленного размера - - TRYX asynchronous IN transport is not available - Асинхронный канал чтения TRYX недоступен + + Failed to read media file: %1 + Не удалось прочитать медиафайл: %1 - - Cannot submit the TRYX asynchronous IN transfer: %1 - Не удалось отправить асинхронную IN-передачу TRYX: %1 + + Media file changed during transfer: sent %1 of %2 bytes + Медиафайл изменился во время передачи: отправлено %1 из %2 байт - - - Cannot cancel the TRYX asynchronous OUT transfer: %1 - Не удалось отменить асинхронную OUT-передачу TRYX: %1 + + Printer-class media name is not supported: %1 + Имя медиа printer-class не поддерживается: %1 - - Cannot cancel the TRYX asynchronous IN transfer: %1 - Не удалось отменить асинхронную IN-передачу TRYX: %1 + + + The user D-Bus session is unavailable + Пользовательский сеанс D-Bus недоступен - - TRYX libusb cancellation could not be drained safely; the runtime will exit so systemd can release the USB claim and restart it - Не удалось безопасно завершить отмену libusb TRYX. Служба завершится, чтобы systemd освободил захват USB и перезапустил её + + + TRYX background runtime did not acquire its D-Bus name before the startup deadline + Фоновая служба TRYX не получила имя D-Bus за отведённое время запуска - - Cannot release the TRYX printer interface: %1 - Не удалось освободить интерфейс принтера TRYX: %1 + + + Failed to start systemctl: %1 + Не удалось запустить systemctl: %1 - - Cannot reattach usblp to the TRYX printer interface: %1 - Не удалось повторно подключить usblp к интерфейсу принтера TRYX: %1 + + The running TRYX runtime uses API %1, but this GUI requires API %2 + Запущенная служба TRYX использует API %1, а этому интерфейсу требуется API %2 - - TRYX libusb transport is not open - Транспорт TRYX libusb не открыт + + + systemctl did not finish the TRYX runtime action before the deadline + systemctl не завершил операцию со службой TRYX до истечения времени ожидания - - A previous TRYX asynchronous OUT transfer is still active - Предыдущая асинхронная OUT-передача TRYX ещё активна + + + systemctl failed with exit code %1 + systemctl завершился с кодом %1 - - Cannot allocate the TRYX asynchronous OUT transfer - Не удалось выделить асинхронную OUT-передачу TRYX + + + An incompatible TRYX runtime is already running and its operation state could not be verified: %1 + Уже запущена несовместимая служба TRYX, состояние её операций проверить не удалось: %1 - - Cannot submit the TRYX asynchronous OUT transfer: %1 - Не удалось отправить асинхронную OUT-передачу TRYX: %1 + + The installed TRYX runtime must be restarted, but a media operation is still active. Finish or cancel it before reopening the GUI + Установленную службу TRYX нужно перезапустить, но медиаоперация ещё активна. Завершите или отмените её перед повторным открытием интерфейса - - TRYX asynchronous OUT cancellation did not complete within its deadline - Отмена асинхронной OUT-передачи TRYX не завершилась за отведённое время + + + The running TRYX runtime is incompatible and the systemd user unit is not installed + Запущенная служба TRYX несовместима, а пользовательский модуль systemd не установлен - - TRYX USB request was only partially transferred: %1 of %2 bytes - USB-запрос TRYX передан частично: %1 из %2 байт + + + The TRYX runtime remained incompatible after restart: %1 + После перезапуска служба TRYX осталась несовместимой: %1 - - TRYX libusb receive queue exceeded its bounded size - Очередь приёма TRYX libusb превысила допустимый размер + + + The systemd unit is not installed and the development runtime could not be started + Модуль systemd не установлен, а службу для разработки запустить не удалось - - TRYX libusb event handling failed: %1 - Обработка событий TRYX libusb завершилась ошибкой: %1 + + + The TRYX runtime started, but its API is incompatible: %1 + Фоновая служба TRYX запущена, но её API несовместим: %1 - - TRYX 391a:1021 is enumerating; waiting for a valid USB printer interface - TRYX 391a:1021 определяется; ожидается корректный USB-интерфейс принтера + + TRYX background runtime + Фоновая служба TRYX - - TRYX direct USB printer interface is ready - Прямой USB-интерфейс принтера TRYX готов + + Failed to start the background runtime: %1 + Не удалось запустить фоновую службу: %1 - - TRYX usbfs device exists but is not readable and writable - Устройство TRYX в usbfs существует, но недоступно для чтения и записи + + The private runtime directory path is empty + Путь к приватному рабочему каталогу пуст - - TRYX direct USB device identifier is empty - Идентификатор прямого USB-устройства TRYX пуст + + Cannot inspect private runtime directory %1: %2 + Не удалось проверить приватный рабочий каталог %1: %2 - - - - TRYX response stream could not be resynchronized within %1 bytes - Не удалось повторно синхронизировать поток ответов TRYX в пределах %1 байт + + Cannot create private runtime directory %1: %2 + Не удалось создать приватный рабочий каталог %1: %2 - - TRYX session bootstrap response body %1 does not match expected body %2 - Тело ответа инициализации сеанса TRYX %1 не совпадает с ожидаемым телом %2 + + Cannot verify private runtime directory %1: %2 + Не удалось подтвердить безопасность приватного рабочего каталога %1: %2 - - Timed out waiting for an exact TRYX session bootstrap response - Истекло время ожидания точного ответа инициализации сеанса TRYX + + Private runtime directory %1 must be a direct owner-only 0700 directory + Приватный рабочий каталог %1 должен быть непосредственным каталогом текущего пользователя с правами 0700 - - Simulated confirmed zero-byte TRYX DeviceInfo OUT - Сымитирована подтверждённая исходящая передача TRYX DeviceInfo с нулём записанных байт + + The staged source and daemon spool are not on the same filesystem + Временный исходник и буфер фонового сервиса находятся в разных файловых системах - - TRYX DeviceInfo readiness failed after %1 attempts: %2 - Проверка готовности TRYX DeviceInfo завершилась сбоем после %1 попыток: %2 + + Cannot claim staged media source: %1 + Не удалось принять временный исходник медиа: %1 - - TRYX DeviceInfo readiness did not become ready after %1 attempts within %2 ms: %3 - TRYX DeviceInfo не подтвердил готовность после %1 попыток за %2 мс: %3 + + Cannot start recovered media validation tool %1: %2 + Не удалось запустить инструмент %1 для проверки восстановленного медиафайла: %2 - - TRYX readiness backoff poll failed: %1 - Сбой опроса задержки повторной проверки готовности TRYX: %1 + + Recovered media validation exceeded its bounded deadline + Превышено заданное предельное время проверки восстановленного медиафайла - - - TRYX device rejected the optional response with error %1 - Устройство TRYX вернуло отказ в необязательном ответе, ошибка %1 + + Recovered media validation failed + Проверка восстановленного медиафайла завершилась ошибкой - - Timed out while draining queued TRYX response frames - Истекло время ожидания при очистке очереди кадров ответов TRYX + + Recovered media validation failed: %1 + Проверка восстановленного медиафайла завершилась ошибкой: %1 - - Recovered a tracked TRYX protobuf response after the USB transport dropped its frame header - Отслеживаемый protobuf-ответ TRYX восстановлен после потери заголовка кадра USB-транспортом + + Cannot open the recovered H264 stream: %1 + Не удалось открыть восстановленный поток H.264: %1 - - - TRYX receive buffer exceeded its bounded size - Буфер приёма TRYX превысил установленный предел + + Cannot inspect the recovered H264 stream: %1 + Не удалось проверить восстановленный поток H.264: %1 - - - TRYX in-flight keepalive write remained unavailable after %1 attempts - Запись keepalive TRYX во время активного запроса осталась недоступной после %1 попыток + + Recovered device media is not a complete Annex B H264 stream + Восстановленный медиафайл с устройства не является полным потоком H.264 в формате Annex B - - - Timed out after discarding malformed TRYX response bytes - Превышено время ожидания после удаления повреждённых байтов ответа TRYX + + Recovered device media does not match the validated file size + Размер восстановленного медиафайла с устройства не совпадает с подтверждённым размером - - - Timed out with an incomplete TRYX USB response frame - Истекло время ожидания при получении неполного кадра USB-ответа TRYX + + Recovered device media hash changed before validation + Хеш восстановленного медиафайла с устройства изменился до проверки - - - Timed out waiting for the TRYX USB response - Истекло время ожидания USB-ответа TRYX + + ffprobe and ffmpeg are required to validate recovered device media + Для проверки восстановленного медиафайла с устройства необходимы ffprobe и ffmpeg - - TRYX USB transport disconnected while waiting for a response - USB-транспорт TRYX отключился во время ожидания ответа + + Recovered device media is not H264 at 2240x1080 + Восстановленный медиафайл с устройства не является видео H.264 с разрешением 2240x1080 - - No media files were selected for deletion - Не выбраны медиафайлы для удаления + + Cannot open source media safely: %1 + Не удалось безопасно открыть исходный медиафайл: %1 - - Media file is not eligible for deletion: %1 - Медиафайл нельзя удалить: %1 + + Cannot read source media: %1 + Не удалось прочитать исходный медиафайл: %1 - - Cannot read the media list before deletion: %1 - Не удалось прочитать список медиафайлов перед удалением: %1 + + Source media is not a bounded regular file + Исходный медиафайл не является обычным файлом допустимого размера - - The file is still present during delete reconciliation; FileRemove will not be repeated: %1 - Во время сверки удаления файл всё ещё присутствует. FileRemove не будет отправлен повторно: %1 + + Cannot hash source media: %1 + Не удалось рассчитать хеш исходного медиафайла: %1 - - Cannot refresh the media list before deleting %1: %2 - Не удалось обновить список медиафайлов перед удалением %1: %2 + + Source media changed while its content hash was calculated + Исходный медиафайл изменился во время расчёта хеша содержимого - - Media file is absent from the fresh device list: %1 - Медиафайл отсутствует в новом списке устройства: %1 + + Cannot create the state directory: %1 + Не удалось создать каталог состояния: %1 - - Media file is protected or ambiguous: %1 - Медиафайл защищён или определён неоднозначно: %1 + + Recovered media chunks are not sequential + Фрагменты восстановленного медиафайла получены не по порядку - - Cannot verify device configuration before deleting %1: %2 - Не удалось проверить конфигурацию устройства перед удалением %1: %2 + + Cannot write the recovered media artifact: %1 + Не удалось записать артефакт восстановленного медиафайла: %1 - - Media file is referenced by the active device configuration: %1 - Медиафайл используется активной конфигурацией устройства: %1 + + The running TRYX runtime uses API %1, but this client requires API %2 + Запущенная фоновая служба TRYX использует API %1, а этому клиенту требуется API %2 - - Deletion was stopped before dispatch - Удаление остановлено до отправки команды + + The installed TRYX runtime must be restarted, but a media operation is still active + Необходимо перезапустить установленную фоновую службу TRYX, однако медиаоперация всё ещё выполняется + + + + Could not open the selected media source + Не удалось открыть выбранный исходный файл медиа - - FileRemove was not sent - FileRemove не был отправлен + + Could not create the private media snapshot + Не удалось создать приватную копию медиа - - FileRemove may have been sent, but the transport cannot safely reconcile FileList - FileRemove мог быть отправлен, но транспорт не позволяет безопасно сверить FileList + + Could not read the selected media source + Не удалось прочитать выбранный исходный файл медиа - - FileRemove may have been sent, but FileList reconciliation failed for %1: %2 - FileRemove мог быть отправлен, но сверка FileList для %1 завершилась ошибкой: %2 + + Could not write the private media snapshot + Не удалось записать приватную копию медиа - - The device rejected deletion of %1 - Устройство отклонило удаление %1 + + The media source changed while it was being copied + Исходный файл медиа изменился во время копирования - - The device still reports %1 after bounded reconciliation; FileRemove will not be repeated - После ограниченной сверки устройство всё ещё сообщает о %1. FileRemove не будет отправлен повторно + + Could not flush the private media snapshot + Не удалось завершить запись приватной копии медиа - - Media file does not exist: %1 - Медиафайл не существует: %1 + + Could not protect the private media snapshot + Не удалось защитить приватную копию медиа - - Media source is not a stable regular file: %1 - Источник медиа не является стабильным обычным файлом: %1 + + + + + The private media snapshot is invalid + Приватная копия медиа некорректна - - Media file size is not supported: %1 bytes - Размер медиафайла не поддерживается: %1 байт + + Could not finalize the private media snapshot + Не удалось завершить создание приватной копии медиа - - Prepared media has an invalid expected SHA-256 hash - Для подготовленного медиа указан некорректный ожидаемый SHA-256 + + The media operation did not complete + Операция с медиафайлом не завершена + + + RuntimeClient - - Prepared-media hash validation was cancelled - Проверка хеша подготовленного медиа отменена + + The D-Bus session bus is unavailable + Шина сеанса D-Bus недоступна - - Cannot validate prepared media: %1 - Не удалось проверить подготовленное медиа: %1 + + + Runtime service is not running + Фоновая служба не запущена - - Prepared media changed after retry validation - Подготовленное медиа изменилось после проверки для повтора + + Runtime API is incompatible + API фоновой службы несовместим - - TRYX user configuration is missing display or work configuration - В пользовательской конфигурации TRYX отсутствуют настройки дисплея или рабочего режима + + PASE printer-class device is not present + Printer-class устройство PASE не обнаружено - - The PASE configuration request does not contain a change - Запрос конфигурации PASE не содержит изменений + + PASE is present, but the display session is not ready + PASE обнаружено, но сеанс дисплея не готов - - PASE standby configuration is fixed by the firmware; use display power control instead - Конфигурация standby PASE зафиксирована прошивкой; используйте управление питанием дисплея + + PASE display session is active + Сеанс дисплея PASE активен - - The PASE screen mode is not supported - Режим экрана PASE не поддерживается + + %1: %2 + %1: %2 - - The PASE play mode is not supported for this screen mode - Режим воспроизведения PASE не поддерживается для выбранного режима экрана + + Upload + Загрузка - - The PASE screen mode requires %1 media file(s) - Для режима экрана PASE требуется медиафайлов: %1 + + The upload source path is empty + Путь к исходному файлу для загрузки пуст - - PASE brightness must be between 0 and 100 - Яркость PASE должна быть в диапазоне от 0 до 100 + + Export or edit + Экспорт или редактирование - - TRYX user configuration has no work configuration; refusing a synthetic write - В пользовательской конфигурации TRYX нет рабочей конфигурации. Синтетическая запись запрещена + + The selected media cannot be staged + Невозможно подготовить выбранный медиафайл - - TRYX user configuration has no display configuration; refusing a synthetic write - В пользовательской конфигурации TRYX нет конфигурации дисплея. Синтетическая запись запрещена + + The staged device media artifact cannot be claimed + Не удалось получить подготовленный артефакт медиафайла с устройства - - display power - питание дисплея + + The runtime changed before the artifact was claimed + Экземпляр фоновой службы сменился до получения артефакта - - TRYX accepted and activated the configuration, but device readback failed - TRYX принял и активировал конфигурацию, но не удалось прочитать её обратно с устройства + + The runtime returned an invalid artifact claim + Фоновая служба вернула недействительное подтверждение получения артефакта - - TRYX accepted and activated the configuration, but device readback failed: %1 - TRYX принял и активировал конфигурацию, но не удалось прочитать её обратно с устройства: %1 + + The artifact lease cannot be renewed + Не удалось продлить аренду артефакта - - overlay activation - активация наложения + + The runtime changed before the lease was renewed + Экземпляр фоновой службы сменился до продления аренды - - screen mode - режим экрана + + The runtime rejected the artifact lease renewal + Фоновая служба отклонила продление аренды артефакта - - play mode - режим воспроизведения + + The runtime is unavailable + Фоновая служба недоступна - - media - медиафайлы + + The runtime changed before the artifact was released + Экземпляр фоновой службы сменился до освобождения артефакта - - brightness - яркость + + The runtime rejected the artifact release + Фоновая служба отклонила освобождение артефакта - - mirror - зеркальный режим + + Save as new + Сохранить как новый - - waterfall - режим водопада + + The recovered media artifact is unavailable + Артефакт восстановленного медиафайла недоступен - - TRYX device readback does not match the requested configuration: %1 - Обратное чтение конфигурации TRYX не совпало с запросом: %1 + + Replace + Заменить - - The TRYX user configuration was fully sent, but its outcome was not confirmed. The configuration may already be stored; automatic rollback is disabled - Пользовательская конфигурация TRYX полностью отправлена, но её результат не подтверждён. Конфигурация уже могла быть сохранена. Автоматический откат отключён + + The recovered media replacement is unavailable + Замена восстановленного медиафайла недоступна - - The TRYX user configuration was fully sent, but its outcome was not confirmed: %1. The configuration may already be stored; automatic rollback is disabled - Пользовательская конфигурация TRYX полностью отправлена, но её результат не подтверждён: %1. Конфигурация уже могла быть сохранена. Автоматический откат отключён + + + Apply + Применение - - TRYX accepted the user configuration but rejected overlay activation - TRYX принял пользовательскую конфигурацию, но отклонил активацию наложения + Full-screen mode requires one media file, a supported play mode and at most three metrics + Для полноэкранного режима нужен один медиафайл, поддерживаемый режим воспроизведения и не более трёх метрик - - TRYX accepted the user configuration but rejected overlay activation: %1 - TRYX принял пользовательскую конфигурацию, но отклонил активацию наложения: %1 + Split-screen mode requires two different media files, Single play mode and at most three metrics per side + Для режима разделения экрана нужны два разных медиафайла, режим воспроизведения «Один файл» и не более трёх метрик на каждой стороне - - TRYX accepted the user configuration, but activation was not confirmed. The configuration may already be stored; automatic rollback is disabled - TRYX принял пользовательскую конфигурацию, но активация не подтверждена. Конфигурация уже могла быть сохранена. Автоматический откат отключён + + Delete + Удаление - - TRYX accepted the user configuration, but activation failed: %1. The configuration may already be stored; automatic rollback is disabled - TRYX принял пользовательскую конфигурацию, но активация завершилась ошибкой: %1. Конфигурация уже могла быть сохранена. Автоматический откат отключён + + Select exactly one deletable media file + Выберите ровно один медиафайл, который можно удалить - - Media file name is not supported: %1 - Имя медиафайла не поддерживается: %1 + + Retry + Повтор - - Multiple TRYX printer-class devices or endpoints were found - Найдено несколько устройств или endpoint TRYX printer-class + + Metrics + Метрики - - TRYX USB monitoring is unavailable; printer-class I/O is disabled - Мониторинг USB TRYX недоступен. Ввод-вывод printer-class отключён + Select at most three metrics + Выберите не более трёх метрик - - - Cannot open media file: %1 - Не удалось открыть медиафайл: %1 + Full-screen mode requires one media file, a supported play mode and a valid selection of at most three metrics%1 + Полноэкранный режим требует один медиафайл, поддерживаемый режим воспроизведения и корректный выбор не более трёх метрик%1 - - Media file changed during transfer - Медиафайл изменился во время передачи + Split-screen mode requires two different media files, Single play mode and a valid selection of at most three metrics per side%1 + Режим разделённого экрана требует два разных медиафайла, одиночный режим воспроизведения и корректный выбор не более трёх метрик для каждой стороны%1 - - Media file ended before its declared size - Медиафайл закончился раньше заявленного размера + + Full-screen mode requires one media file, a supported play mode and valid metric and badge selections%1 + Для полноэкранного режима нужны один медиафайл, поддерживаемый режим воспроизведения и допустимый выбор метрик и бейджей%1 - - Failed to read media file: %1 - Не удалось прочитать медиафайл: %1 + + Split-screen mode requires two different media files, Single play mode and valid metric and badge selections per side%1 + Для разделённого экрана нужны два разных медиафайла, режим «Один файл» и допустимый выбор метрик и бейджей для каждой стороны%1 - - Media file changed during transfer: sent %1 of %2 bytes - Медиафайл изменился во время передачи: отправлено %1 из %2 байт + + The source operation identity is empty + Идентификатор исходной операции пуст - - Printer-class media name is not supported: %1 - Имя медиа printer-class не поддерживается: %1 + + Runtime service stopped + Фоновая служба остановлена - - The user D-Bus session is unavailable - Пользовательский сеанс D-Bus недоступен + + Could not subscribe to all runtime signals + Не удалось подписаться на все сигналы фоновой службы - - TRYX background runtime did not acquire its D-Bus name before the startup deadline - Фоновая служба TRYX не получила имя D-Bus за отведённое время запуска + + Runtime API %1 is active, but this client requires API %2 + Фоновая служба использует API %1, а этому клиенту требуется API %2 - - Failed to start systemctl: %1 - Не удалось запустить systemctl: %1 + + %1 is unavailable because the runtime is not ready + %1: фоновая служба не готова - - The running TRYX runtime uses API %1, but this GUI requires API %2 - Запущенная служба TRYX использует API %1, а этому интерфейсу требуется API %2 + + %1 requires a PASE printer-class device + %1: требуется printer-class устройство PASE - - systemctl did not finish the TRYX runtime action before the deadline - systemctl не завершил операцию со службой TRYX до истечения времени ожидания + + %1 is blocked until the PASE display session is active + Невозможно выполнить «%1», пока сеанс дисплея PASE не станет активным - - systemctl failed with exit code %1 - systemctl завершился с кодом %1 + + %1 is blocked while another operation is active + Невозможно выполнить «%1», пока выполняется другая операция - - An incompatible TRYX runtime is already running and its operation state could not be verified: %1 - Уже запущена несовместимая служба TRYX, состояние её операций проверить не удалось: %1 + + The runtime changed before the operation request was acknowledged + Фоновая служба изменилась до подтверждения запроса операции - - The installed TRYX runtime must be restarted, but a media operation is still active. Finish or cancel it before reopening the GUI - Установленную службу TRYX нужно перезапустить, но медиаоперация ещё активна. Завершите или отмените её перед повторным открытием интерфейса + + The runtime returned an unexpected operation identity + Фоновая служба вернула неожиданный идентификатор операции - - The running TRYX runtime is incompatible and the systemd user unit is not installed - Запущенная служба TRYX несовместима, а пользовательский модуль systemd не установлен + + + %1 operation accepted + Операция «%1» принята - - The TRYX runtime remained incompatible after restart: %1 - После перезапуска служба TRYX осталась несовместимой: %1 + + The runtime changed before the operation request could be reconciled + Фоновая служба изменилась до завершения сверки запроса операции - - The systemd unit is not installed and the development runtime could not be started - Модуль systemd не установлен, а службу для разработки запустить не удалось + + The expected operation is absent from the runtime + Ожидаемая операция отсутствует в фоновой службе - - The TRYX runtime started, but its API is incompatible: %1 - Фоновая служба TRYX запущена, но её API несовместим: %1 + + select at most three metrics + выберите не более трёх метрик - - TRYX background runtime - Фоновая служба TRYX + + select between one and three metrics + выберите от одной до трёх метрик - - Failed to start the background runtime: %1 - Не удалось запустить фоновую службу: %1 + + the metric selection contains an unavailable or duplicate value + выбор метрик содержит недоступное или повторяющееся значение - - Cannot open source media safely: %1 - Не удалось безопасно открыть исходный медиафайл: %1 + + select at most two badges + выберите не более двух бейджей - - Cannot read source media: %1 - Не удалось прочитать исходный медиафайл: %1 + + the badge selection contains an unsupported or duplicate value + в списке бейджей есть неподдерживаемое или повторяющееся значение - - Source media is not a bounded regular file - Исходный медиафайл не является обычным файлом допустимого размера + + The metrics request destination is unavailable + Получатель запроса настройки метрик недоступен - - Cannot hash source media: %1 - Не удалось рассчитать хеш исходного медиафайла: %1 + + Select a supported metrics alignment + Выберите поддерживаемое выравнивание метрик - - Source media changed while its content hash was calculated - Исходный медиафайл изменился во время расчёта хеша содержимого + + Enter a valid metrics text color + Укажите корректный цвет текста метрик - - Cannot create the state directory: %1 - Не удалось создать каталог состояния: %1 + + Display settings + Настройки дисплея @@ -3573,6 +6396,7 @@ The operation cannot be undone. + Device Устройство @@ -3840,6 +6664,201 @@ If the cooler is visible over ADB, it will reboot into Rockchip Loader mode. If Settings reset Настройки сброшены + + Settings + Настройки + + + Runtime status + Состояние фоновой службы + + + Service + Служба + + + Available + Доступна + + + Unavailable + Недоступна + + + Compatibility + Совместимость + + + API compatible + API совместим + + + API incompatible + API несовместим + + + Printer-class device + Printer-class устройство + + + Present + Обнаружено + + + Not present + Не обнаружено + + + + English + Английский + + + + Russian + Русский + + + + System language + Язык системы + + + + General + Общие + + + + Application language + Язык приложения + + + + Changes are applied immediately. + Изменения применяются сразу. + + + + Startup + Автозапуск + + + + Start the background service when you sign in + Запускать фоновую службу при входе в систему + + + + Updating autostart… + Обновление состояния автозапуска… + + + + Managed by your user systemd session + Управляется пользовательским сеансом systemd + + + + Autostart state is unavailable + Состояние автозапуска недоступно + + + + On + Вкл. + + + + Off + Выкл. + + + + Refresh status + Обновить состояние + + + + Background service + Фоновая служба + + + + Running + Запущена + + + + Not running + Не запущена + + + + USB device + USB-устройство + + + + Detected + Обнаружено + + + + Not detected + Не обнаружено + + + + Display session + Сеанс дисплея + + + + Ready + Готов + + + + Waiting + Ожидание + + + + About + О приложении + + + + TRYX Panorama Manager %1 + TRYX Panorama Manager %1 + + + + Open-source Linux control application + Приложение с открытым исходным кодом для управления в Linux + + + + Open GitHub + Открыть GitHub + + + Active + Активен + + + Not ready + Не готов + + + Language + Язык + + + Refresh runtime state + Обновить состояние фоновой службы + SplitConfigWidget @@ -4084,4 +7103,49 @@ If the cooler is visible over ADB, it will reboot into Rockchip Loader mode. If Отключено + + TryxRuntimeExportedObject + + + The operation requires a unique D-Bus caller identity + Для операции требуется уникальный идентификатор вызывающего клиента D-Bus + + + + TryxRuntimeOperationsAdaptor + + + Device media could not be staged for this caller + Не удалось подготовить медиафайл с устройства для этого вызывающего клиента + + + + The device media artifact is unavailable + Артефакт медиафайла с устройства недоступен + + + + The device media artifact lease could not be renewed + Не удалось продлить аренду артефакта медиафайла с устройства + + + + The device media artifact could not be released + Не удалось освободить артефакт медиафайла с устройства + + + + The recovered media artifact is unavailable for upload + Артефакт восстановленного медиафайла недоступен для загрузки + + + + The recovered media artifact is unavailable for replacement + Артефакт восстановленного медиафайла недоступен для замены + + + The operation requires a unique D-Bus caller identity + Для операции требуется уникальный идентификатор вызывающего клиента D-Bus + + diff --git a/tryx-panorama-all.pro b/tryx-panorama-all.pro new file mode 100644 index 0000000..532b8c7 --- /dev/null +++ b/tryx-panorama-all.pro @@ -0,0 +1,20 @@ +TEMPLATE = subdirs +CONFIG += ordered + +runtime.file = $$PWD/tryx-panorama.pro +runtime.makefile = Makefile.runtime +quick.file = $$PWD/tryx-panorama-quick.pro +quick.makefile = Makefile.quick +quick.depends = runtime + +SUBDIRS += runtime quick + +# qmake's subdirs template propagates build/install/clean targets, but not +# project-specific test targets. Keep one package-facing check entry point and +# let each child project own its test implementation. +aggregate_check.target = package-check +aggregate_check.depends = all +aggregate_check.commands = \ + $(MAKE) -f Makefile.runtime check && \ + $(MAKE) -f Makefile.quick quick-check +QMAKE_EXTRA_TARGETS += aggregate_check diff --git a/tryx-panorama-quick.pro b/tryx-panorama-quick.pro new file mode 100644 index 0000000..91aec9a --- /dev/null +++ b/tryx-panorama-quick.pro @@ -0,0 +1,147 @@ +QT += concurrent core dbus gui qml quick quickcontrols2 + +CONFIG += c++17 lrelease embed_translations +TARGET = tryx-panorama-manager +TEMPLATE = app + +!versionAtLeast(QT_VERSION, 6.4.0) { + error("tryx-panorama-manager requires Qt 6.4 or newer") +} + +VERSION = $$cat($$PWD/VERSION, lines) +isEmpty(VERSION): error("VERSION is empty or missing") +DEFINES += TRYX_APP_VERSION=\\\"$$VERSION\\\" + +INCLUDEPATH += $$PWD/src $$PWD/src/quick +INCLUDEPATH += $$PWD/include + +TRANSLATIONS += translations/tryx-panorama_ru.ts +LRELEASE_DIR = build/quick/i18n + +DESTDIR = $$PWD/build/quick +OBJECTS_DIR = $$PWD/build/quick/obj +MOC_DIR = $$PWD/build/quick/moc +RCC_DIR = $$PWD/build/quick/rcc + +HEADERS += \ + src/applicationpaths.h \ + src/systemmonitor.h \ + src/runtimecontract.h \ + src/mediatransform.h \ + src/quick/appsettingscontroller.h \ + src/quick/devicemediaworkflowcontroller.h \ + src/quick/firmwarecontroller.h \ + src/quick/linuxtraycontroller.h \ + src/quick/mediacatalogmodel.h \ + src/quick/mediaeditorcontroller.h \ + src/quick/mediapreviewcontroller.h \ + src/quick/operationlistmodel.h \ + src/quick/runtimebootstrap.h \ + src/quick/runtimeclient.h \ + src/quick/systemmetricsmodel.h \ + src/quick/windowchromecontroller.h + +SOURCES += \ + src/systemmonitor.cpp \ + src/runtimecontract.cpp \ + src/mediatransform.cpp \ + src/core/config.cpp \ + src/quick/appsettingscontroller.cpp \ + src/quick/devicemediaworkflowcontroller.cpp \ + src/quick/firmwarecontroller.cpp \ + src/quick/linuxtraycontroller.cpp \ + src/quick/main.cpp \ + src/quick/mediacatalogmodel.cpp \ + src/quick/mediaeditorcontroller.cpp \ + src/quick/mediapreviewcontroller.cpp \ + src/quick/operationlistmodel.cpp \ + src/quick/runtimebootstrap.cpp \ + src/quick/runtimeclient.cpp \ + src/quick/systemmetricsmodel.cpp \ + src/quick/windowchromecontroller.cpp + +RESOURCES += resources/quick.qrc + +QML_FILES = \ + qml/Main.qml \ + qml/components/MediaEditor.qml \ + qml/components/MediaExportPicker.qml \ + qml/components/MediaFilePicker.qml \ + qml/components/FirmwareFilePicker.qml \ + qml/components/FirmwarePanel.qml \ + qml/components/MetricCard.qml \ + qml/components/NavButton.qml \ + qml/components/OperationBanner.qml \ + qml/components/PrimaryButton.qml \ + qml/components/WindowResizeHandle.qml \ + qml/pages/HomePage.qml \ + qml/pages/PanoramaPage.qml \ + qml/pages/SettingsPage.qml + +QML_TEST_FILES = \ + tests/quick/qml/tst_firmwarefilepickerlayout.qml \ + tests/quick/qml/tst_homepagelayout.qml \ + tests/quick/qml/tst_mediaeditorlayout.qml \ + tests/quick/qml/tst_mediaexportpickerlayout.qml \ + tests/quick/qml/tst_mediafilepickerlayout.qml \ + tests/quick/qml/tst_panoramalayout.qml \ + tests/quick/qml/tst_settingslayout.qml + +QML_ALL_FILES = $$QML_FILES $$QML_TEST_FILES +QML_LINT_FILES = +for(qml_file, QML_ALL_FILES) { + QML_LINT_FILES += $$shell_path($$absolute_path($$qml_file, $$PWD)) +} + +DISTFILES += \ + $$QML_FILES \ + resources/tryx-panorama.png \ + tests/quick/quick_tests.pro \ + tests/quick/linuxtraycontroller_tests.pro \ + tests/quick/linuxtraycontroller_tests.cpp \ + tests/quick/tst_quickmodels.cpp \ + $$QML_TEST_FILES + +QMLLINT = $$[QT_HOST_BINS]/qmllint +QMLTESTRUNNER = $$[QT_HOST_BINS]/qmltestrunner +QMLIMPORTSCANNER = $$[QT_HOST_LIBEXECS]/qmlimportscanner +QMLLINT_FLAGS = +versionAtLeast(QT_VERSION, 6.8.0) { + QMLLINT_FLAGS += -W 0 +} else { + QMLLINT_FLAGS += --deferred-property-id info +} +exists($$QMLLINT) { + qml_lint.target = qml-lint + qml_lint.commands = \ + $$QMLLINT $$QMLLINT_FLAGS $$QML_LINT_FILES + QMAKE_EXTRA_TARGETS += qml_lint +} + +quick_tests.target = quick-check +quick_tests.depends = \ + $$relative_path($$DESTDIR/$$TARGET, $$OUT_PWD) +quick_tests.commands = \ + $$QMLLINT $$QMLLINT_FLAGS $$QML_LINT_FILES && \ + QT_QPA_PLATFORM=offscreen $$QMLTESTRUNNER \ + -input $$shell_path($$PWD/tests/quick/qml) \ + -import $$shell_path($$PWD/qml) -o -,txt && \ + cd $$shell_path($$PWD/tests/quick) && \ + $$QMAKE_QMAKE quick_tests.pro && $(MAKE) && \ + TRYX_QMLIMPORTSCANNER=$$shell_path($$QMLIMPORTSCANNER) \ + TRYX_QML_ROOT=$$shell_path($$PWD/qml) \ + TRYX_QML_IMPORT_PATH=$$shell_path($$[QT_INSTALL_QML]) \ + $$shell_path($$PWD/build/quick-tests/tryx-quick-tests) && \ + $$QMAKE_QMAKE linuxtraycontroller_tests.pro && $(MAKE) && \ + dbus-run-session -- \ + $$shell_path($$PWD/build/linuxtray-tests/linuxtraycontroller-tests) \ + -txt && \ + env -u DBUS_SESSION_BUS_ADDRESS QT_QPA_PLATFORM=offscreen \ + $$shell_path($$PWD/build/quick/tryx-panorama-manager) \ + --smoke-test +QMAKE_EXTRA_TARGETS += quick_tests + +unix { + target.path = /usr/bin + INSTALLS += target +} diff --git a/tryx-panorama.pro b/tryx-panorama.pro index 81edf43..f8b1fbf 100644 --- a/tryx-panorama.pro +++ b/tryx-panorama.pro @@ -1,7 +1,7 @@ -QT += core dbus gui widgets network +QT += core dbus gui CONFIG += c++17 lrelease embed_translations link_pkgconfig -TARGET = tryx-panorama-manager +TARGET = tryx-panorama-runtime TEMPLATE = app VERSION = $$cat($$PWD/VERSION, lines) @@ -28,9 +28,9 @@ isEmpty(PROTOBUF_RUNTIME_PATCH_VERSION): PROTOBUF_RUNTIME_NORMALIZED_VERSION = $ !equals(PROTOC_NORMALIZED_VERSION, $$PROTOBUF_RUNTIME_NORMALIZED_VERSION): error("protoc $$PROTOC_VERSION does not match libprotobuf $$PROTOBUF_RUNTIME_VERSION") TRANSLATIONS += translations/tryx-panorama_ru.ts -LRELEASE_DIR = build/i18n +LRELEASE_DIR = build/runtime/i18n -INCLUDEPATH += $$PWD/include +INCLUDEPATH += $$PWD/include $$PWD/src # Minimal, independently named schema for the confirmed PASE wire contract. PROTO_DIR = $$PWD/protocol/wire-v1 @@ -62,14 +62,14 @@ protobuf_source.dependency_type = TYPE_C QMAKE_EXTRA_COMPILERS += protobuf_header protobuf_source protocol_tests.target = check -protocol_tests.commands = sh $$shell_path($$PWD/tests/check_no_bundled_video.sh) && cd $$shell_path($$PWD/tests) && $$QMAKE_QMAKE printerprotocol_tests.pro && $(MAKE) && $$shell_path($$PWD/build/tests/printerprotocol-tests) +protocol_tests.commands = sh $$shell_path($$PWD/tests/check_no_bundled_video.sh) && cd $$shell_path($$PWD/tests) && $$QMAKE_QMAKE printerprotocol_tests.pro && $(MAKE) && $$shell_path($$PWD/build/tests/printerprotocol-tests) && $$QMAKE_QMAKE replacejournal_tests.pro && $(MAKE) && $$shell_path($$PWD/build/replacejournal-tests/replacejournal-tests) QMAKE_EXTRA_TARGETS += protocol_tests # Build output -DESTDIR = $$PWD/build -OBJECTS_DIR = $$PWD/build/obj -MOC_DIR = $$PWD/build/moc -RCC_DIR = $$PWD/build/rcc +DESTDIR = $$PWD/build/runtime +OBJECTS_DIR = $$PWD/build/runtime/obj +MOC_DIR = $$PWD/build/runtime/moc +RCC_DIR = $$PWD/build/runtime/rcc # Core library SOURCES += \ @@ -79,37 +79,31 @@ SOURCES += \ src/core/media.cpp \ src/core/config.cpp -# GUI HEADERS += \ + src/applicationpaths.h \ src/devicemanager.h \ + src/firmwarebridge.h \ + src/firmwarerecoveryjournal.h \ src/systemmonitor.h \ - src/homepage.h \ - src/panoramapage.h \ - src/displaypage.h \ src/firmwareupdater.h \ + src/mediatransform.h \ src/printerprotocol.h \ - src/runtimebridge.h \ - src/settingspage.h \ - src/traymanager.h \ - src/mainwindow.h \ - src/splitconfig.h + src/replacejournal.h \ + src/runtimecontract.h \ + src/runtimebridge.h SOURCES += \ - src/main.cpp \ + src/runtime/main.cpp \ src/devicemanager.cpp \ + src/firmwarebridge.cpp \ + src/firmwarerecoveryjournal.cpp \ src/systemmonitor.cpp \ - src/homepage.cpp \ - src/panoramapage.cpp \ - src/displaypage.cpp \ src/firmwareupdater.cpp \ + src/mediatransform.cpp \ src/printerprotocol.cpp \ - src/runtimebridge.cpp \ - src/settingspage.cpp \ - src/traymanager.cpp \ - src/mainwindow.cpp \ - src/splitconfig.cpp - -RESOURCES += resources/resources.qrc + src/replacejournal.cpp \ + src/runtimecontract.cpp \ + src/runtimebridge.cpp DISTFILES += \ VERSION \ @@ -133,7 +127,7 @@ unix { SYSTEMD_USER_PRESET_DIR = $$system(pkg-config --variable=systemduserpresetdir systemd) isEmpty(SYSTEMD_USER_PRESET_DIR): error("systemd user preset directory was not found") - target.path = /usr/bin + target.path = /usr/lib/tryx-panorama-manager pase_udev_rules.path = /usr/lib/udev/rules.d pase_udev_rules.files = \ packaging/70-tryx-pase-access.rules \