diff --git a/.clang-format b/.clang-format index 610ee65..8ec602a 100644 --- a/.clang-format +++ b/.clang-format @@ -1,6 +1,6 @@ AlignArrayOfStructures: None AlignAfterOpenBracket: BlockIndent -AllowShortBlocksOnASingleLine: Always +AllowShortBlocksOnASingleLine: Empty AllowShortCaseLabelsOnASingleLine: true AllowShortEnumsOnASingleLine: true AllowShortFunctionsOnASingleLine: All diff --git a/.clang-tidy b/.clang-tidy index 002c444..da14682 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -20,6 +20,8 @@ Checks: > -cppcoreguidelines-avoid-do-while, -cppcoreguidelines-pro-type-reinterpret-cast, -cppcoreguidelines-pro-type-vararg, + -cppcoreguidelines-pro-type-union-access, + -cppcoreguidelines-use-enum-class, google-global-names-in-headers, google-readability-casting, google-runtime-int, @@ -63,6 +65,8 @@ CheckOptions: readability-identifier-naming.ParameterCase: camelBack readability-identifier-naming.VariableCase: camelBack + misc-const-correctness.WarnPointersAsPointers: false + # does not appear to work readability-operators-representation.BinaryOperators: '&&;&=;&;|;~;!;!=;||;|=;^;^=' readability-operators-representation.OverloadedOperators: '&&;&=;&;|;~;!;!=;||;|=;^;^=' diff --git a/.github/ISSUE_TEMPLATE/crash.yml b/.github/ISSUE_TEMPLATE/crash.yml index c8b4804..958c884 100644 --- a/.github/ISSUE_TEMPLATE/crash.yml +++ b/.github/ISSUE_TEMPLATE/crash.yml @@ -1,82 +1,17 @@ -name: Crash Report -description: Quickshell has crashed -labels: ["bug", "crash"] +name: Crash Report (v1) +description: Quickshell has crashed (old) +labels: ["unactionable"] body: - - type: textarea - id: crashinfo + - type: markdown attributes: - label: General crash information - description: | - Paste the contents of the `info.txt` file in your crash folder here. - value: "
General information - - - ``` - - - - ``` - - -
" - validations: - required: true - - type: textarea - id: userinfo + value: | + Thank you for taking the time to click the report button. + At this point most of the worst issues in 0.2.1 and before have been fixed and we are + preparing for a new release. Please do not submit this report. + - type: checkboxes + id: donotcheck attributes: - label: What caused the crash - description: | - Any information likely to help debug the crash. What were you doing when the crash occurred, - what changes did you make, can you get it to happen again? - - type: textarea - id: dump - attributes: - label: Minidump - description: | - Attach `minidump.dmp.log` here. If it is too big to upload, compress it. - - You may skip this step if quickshell crashed while processing a password - or other sensitive information. If you skipped it write why instead. - validations: - required: true - - type: textarea - id: logs - attributes: - label: Log file - description: | - Attach `log.qslog.log` here. If it is too big to upload, compress it. - - You can preview the log if you'd like using `quickshell read-log `. - validations: - required: true - - type: textarea - id: config - attributes: - label: Configuration - description: | - Attach your configuration here, preferrably in full (not just one file). - Compress it into a zip, tar, etc. - - This will help us reproduce the crash ourselves. - - type: textarea - id: bt - attributes: - label: Backtrace - description: | - If you have gdb installed and use systemd, or otherwise know how to get a backtrace, - we would appreciate one. (You may have gdb installed without knowing it) - - 1. Run `coredumpctl debug ` where `pid` is the number shown after "Crashed process ID" - in the crash reporter. - 2. Once it loads, type `bt -full` (then enter) - 3. Copy the output and attach it as a file or in a spoiler. - - type: textarea - id: exe - attributes: - label: Executable - description: | - If the crash folder contains a executable.txt file, upload it here. If not you can ignore this field. - If it is too big to upload, compress it. - - Note: executable.txt is the quickshell binary. It has a .txt extension due to github's limitations on - filetypes. + label: Read the text above. Do not submit the report. + options: + - label: Yes I want this report to be deleted. + required: true diff --git a/.github/ISSUE_TEMPLATE/crash2.yml b/.github/ISSUE_TEMPLATE/crash2.yml new file mode 100644 index 0000000..86f490c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/crash2.yml @@ -0,0 +1,49 @@ +name: Crash Report (v2) +description: Quickshell has crashed +labels: ["bug", "crash"] +body: + - type: textarea + id: userinfo + attributes: + label: What caused the crash + description: | + Any information likely to help debug the crash. What were you doing when the crash occurred, + what changes did you make, can you get it to happen again? + - type: upload + id: report + attributes: + label: Report file + description: Attach `report.txt` here. + validations: + required: true + - type: upload + id: logs + attributes: + label: Log file + description: | + Attach `log.qslog.log` here. If it is too big to upload, compress it. + + You can preview the log if you'd like using `qs log -r '*=true'`. + validations: + required: true + - type: textarea + id: config + attributes: + label: Configuration + description: | + Attach or link your configuration here, preferrably in full (not just one file). + Compress it into a zip, tar, etc. + + This will help us reproduce the crash ourselves. + - type: textarea + id: bt + attributes: + label: Backtrace + description: | + GDB usually produces better stacktraces than quickshell can. Consider attaching a gdb backtrace + following the instructions below. + + 1. Run `coredumpctl debug ` where `pid` is the number shown after "Crashed process ID" + in the crash reporter. + 2. Once it loads, type `bt -full` (then enter) + 3. Copy the output and attach it as a file or in a spoiler. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 93b8458..7b8cbce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,17 +6,23 @@ jobs: name: Nix strategy: matrix: - qtver: [qt6.9.0, qt6.8.3, qt6.8.2, qt6.8.1, qt6.8.0, qt6.7.3, qt6.7.2, qt6.7.1, qt6.7.0, qt6.6.3, qt6.6.2, qt6.6.1, qt6.6.0] + qtver: [qt6.10.1, qt6.10.0, qt6.9.2, qt6.9.1, qt6.9.0, qt6.8.3, qt6.8.2, qt6.8.1, qt6.8.0, qt6.7.3, qt6.7.2, qt6.7.1, qt6.7.0, qt6.6.3, qt6.6.2, qt6.6.1, qt6.6.0] compiler: [clang, gcc] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 # Use cachix action over detsys for testing with act. # - uses: cachix/install-nix-action@v27 - uses: DeterminateSystems/nix-installer-action@main + - uses: DeterminateSystems/magic-nix-cache-action@main + with: + use-flakehub: false - name: Download Dependencies - run: nix-build --no-out-link --expr '((import ./ci/matrix.nix) { qtver = "${{ matrix.qtver }}"; compiler = "${{ matrix.compiler }}"; }).inputDerivation' + run: nix-build --no-out-link --expr '((import ./ci/matrix.nix) { qtver = "${{ matrix.qtver }}"; compiler = "${{ matrix.compiler }}"; }).unwrapped.inputDerivation' - name: Build run: nix-build --no-out-link --expr '(import ./ci/matrix.nix) { qtver = "${{ matrix.qtver }}"; compiler = "${{ matrix.compiler }}"; }' @@ -44,13 +50,16 @@ jobs: wayland-protocols \ wayland \ libdrm \ + vulkan-headers \ libxcb \ libpipewire \ cli11 \ - jemalloc + polkit \ + jemalloc \ + libunwind \ + git # for cpptrace clone - name: Build - # breakpad is annoying to build in ci due to makepkg not running as root run: | - cmake -GNinja -B build -DCRASH_REPORTER=OFF + cmake -GNinja -B build -DVENDOR_CPPTRACE=ON cmake --build build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index da329cc..de0c304 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,11 +5,17 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 # Use cachix action over detsys for testing with act. # - uses: cachix/install-nix-action@v27 - uses: DeterminateSystems/nix-installer-action@main + - uses: DeterminateSystems/magic-nix-cache-action@main + with: + use-flakehub: false - uses: nicknovitski/nix-develop@v1 - name: Check formatting diff --git a/BUILD.md b/BUILD.md index 3172dbe..d624a06 100644 --- a/BUILD.md +++ b/BUILD.md @@ -15,15 +15,7 @@ Please make this descriptive enough to identify your specific package, for examp - `Nixpkgs` - `Fedora COPR (errornointernet/quickshell)` -`-DDISTRIBUTOR_DEBUGINFO_AVAILABLE=YES/NO` - -If we can retrieve binaries and debug information for the package without actually running your -distribution (e.g. from an website), and you would like to strip the binary, please set this to `YES`. - -If we cannot retrieve debug information, please set this to `NO` and -**ensure you aren't distributing stripped (non debuggable) binaries**. - -In both cases you should build with `-DCMAKE_BUILD_TYPE=RelWithDebInfo` (then split or keep the debuginfo). +If you are forking quickshell, please change `CRASHREPORT_URL` to your own issue tracker. ### QML Module dir Currently all QML modules are statically linked to quickshell, but this is where @@ -41,16 +33,22 @@ Quickshell has a set of base dependencies you will always need, names vary by di - `cmake` - `qt6base` - `qt6declarative` -- `qtshadertools` (build-time only) -- `spirv-tools` (build-time only) -- `pkg-config` (build-time only) -- `cli11` +- `libdrm` +- `qtshadertools` (build-time) +- `spirv-tools` (build-time) +- `pkg-config` (build-time) +- `cli11` (static library) + +Build time dependencies and static libraries don't have to exist at runtime, +however build time dependencies must be compiled for the architecture of +the builder, while static libraries must be compiled for the architecture +of the target. On some distros, private Qt headers are in separate packages which you may have to install. We currently require private headers for the following libraries: - `qt6declarative` -- `qt6wayland` +- `qt6wayland` (for Qt versions prior to 6.10) We recommend an implicit dependency on `qt6svg`. If it is not installed, svg images and svg icons will not work, including system ones. @@ -59,14 +57,24 @@ At least Qt 6.6 is required. All features are enabled by default and some have their own dependencies. -### Crash Reporter -The crash reporter catches crashes, restarts quickshell when it crashes, +### Crash Handler +The crash reporter catches crashes, restarts Quickshell when it crashes, and collects useful crash information in one place. Leaving this enabled will enable us to fix bugs far more easily. -To disable: `-DCRASH_REPORTER=OFF` +To disable: `-DCRASH_HANDLER=OFF` -Dependencies: `google-breakpad` +Dependencies: `cpptrace` + +Note: `-DVENDOR_CPPTRACE=ON` can be set to vendor cpptrace using FetchContent. + +When using FetchContent, `libunwind` is required, and `libdwarf` can be provided by the +package manager or fetched with FetchContent. + +*Please ensure binaries have usable symbols.* We do not necessarily need full debuginfo, but +leaving symbols in the binary is extremely helpful. You can check if symbols are useful +by sending a SIGSEGV to the process and ensuring symbols for the quickshell binary are present +in the trace. ### Jemalloc We recommend leaving Jemalloc enabled as it will mask memory fragmentation caused @@ -99,10 +107,13 @@ Currently supported Qt versions: `6.6`, `6.7`. To disable: `-DWAYLAND=OFF` Dependencies: - - `qt6wayland` + - `qt6wayland` (for Qt versions prior to 6.10) - `wayland` (libwayland-client) - - `wayland-scanner` (may be part of your distro's wayland package) - - `wayland-protocols` + - `wayland-scanner` (build time) + - `wayland-protocols` (static library) + +Note that one or both of `wayland-scanner` and `wayland-protocols` may be bundled +with you distro's wayland package. #### Wlroots Layershell Enables wlroots layershell integration through the [zwlr-layer-shell-v1] protocol, @@ -136,8 +147,8 @@ Enables streaming video from monitors and toplevel windows through various proto To disable: `-DSCREENCOPY=OFF` Dependencies: -- `libdrm` - `libgbm` +- `vulkan-headers` (build-time) Specific protocols can also be disabled: - `DSCREENCOPY_ICC=OFF` - Disable screencopy via [ext-image-copy-capture-v1] @@ -184,6 +195,13 @@ To disable: `-DSERVICE_PAM=OFF` Dependencies: `pam` +### Polkit +This feature enables creating Polkit agents that can prompt user for authentication. + +To disable: `-DSERVICE_POLKIT=OFF` + +Dependencies: `polkit`, `glib` + ### Hyprland This feature enables hyprland specific integrations. It requires wayland support but has no extra dependencies. @@ -220,11 +238,11 @@ To disable: `-DI3_IPC=OFF` ## Building *For developers and prospective contributors: See [CONTRIBUTING.md](CONTRIBUTING.md).* -We highly recommend using `ninja` to run the build, but you can use makefiles if you must. +Only `ninja` builds are tested, but makefiles may work. #### Configuring the build ```sh -$ cmake -GNinja -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo [additional disable flags from above here] +$ cmake -GNinja -B build -DCMAKE_BUILD_TYPE=Release [additional disable flags from above here] ``` Note that features you do not supply dependencies for MUST be disabled with their associated flags diff --git a/CMakeLists.txt b/CMakeLists.txt index 846a280..8293f23 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,11 @@ cmake_minimum_required(VERSION 3.20) -project(quickshell VERSION "0.1.0" LANGUAGES CXX C) +project(quickshell VERSION "0.2.1" LANGUAGES CXX C) + +set(UNRELEASED_FEATURES + "network.2" + "colorquant-imagerect" + "window-parent" +) set(QT_MIN_VERSION "6.6.0") set(CMAKE_CXX_STANDARD 20) @@ -7,6 +13,9 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(QS_BUILD_OPTIONS "") +# should be changed for forks +set(CRASHREPORT_URL "https://github.com/outfoxxed/quickshell/issues/new?template=crash2.yml" CACHE STRING "Bugreport URL") + function(boption VAR NAME DEFAULT) cmake_parse_arguments(PARSE_ARGV 3 arg "" "REQUIRES" "") @@ -38,14 +47,17 @@ string(APPEND QS_BUILD_OPTIONS " Distributor: ${DISTRIBUTOR}") message(STATUS "Quickshell configuration") message(STATUS " Distributor: ${DISTRIBUTOR}") -boption(DISTRIBUTOR_DEBUGINFO_AVAILABLE "Distributor provided debuginfo" NO) boption(NO_PCH "Disable precompild headers (dev)" OFF) boption(BUILD_TESTING "Build tests (dev)" OFF) boption(ASAN "ASAN (dev)" OFF) # note: better output with gcc than clang boption(FRAME_POINTERS "Keep Frame Pointers (dev)" ${ASAN}) -boption(CRASH_REPORTER "Crash Handling" ON) -boption(USE_JEMALLOC "Use jemalloc" ON) +if (CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") + boption(USE_JEMALLOC "Use jemalloc" OFF) +else() + boption(USE_JEMALLOC "Use jemalloc" ON) +endif() +boption(CRASH_HANDLER "Crash Handling" ON) boption(SOCKETS "Unix Sockets" ON) boption(WAYLAND "Wayland" ON) boption(WAYLAND_WLR_LAYERSHELL " Wlroots Layer-Shell" ON REQUIRES WAYLAND) @@ -67,17 +79,21 @@ boption(SERVICE_STATUS_NOTIFIER "System Tray" ON) boption(SERVICE_PIPEWIRE "PipeWire" ON) boption(SERVICE_MPRIS "Mpris" ON) boption(SERVICE_PAM "Pam" ON) +boption(SERVICE_POLKIT "Polkit" ON) boption(SERVICE_GREETD "Greetd" ON) boption(SERVICE_UPOWER "UPower" ON) boption(SERVICE_NOTIFICATIONS "Notifications" ON) +boption(BLUETOOTH "Bluetooth" ON) +boption(NETWORK "Network" ON) include(cmake/install-qml-module.cmake) include(cmake/util.cmake) add_compile_options(-Wall -Wextra -Wno-vla-cxx-extension) -# pipewire defines this, breaking PCH +# pipewire defines these, breaking PCH add_compile_definitions(_REENTRANT) +add_compile_options(-fno-strict-overflow) if (FRAME_POINTERS) add_compile_options(-fno-omit-frame-pointer) @@ -99,6 +115,7 @@ if (NOT CMAKE_BUILD_TYPE) endif() set(QT_FPDEPS Gui Qml Quick QuickControls2 Widgets ShaderTools) +set(QT_PRIVDEPS QuickPrivate) include(cmake/pch.cmake) @@ -114,9 +131,10 @@ endif() if (WAYLAND) list(APPEND QT_FPDEPS WaylandClient) + list(APPEND QT_PRIVDEPS WaylandClientPrivate) endif() -if (SERVICE_STATUS_NOTIFIER OR SERVICE_MPRIS OR SERVICE_UPOWER OR SERVICE_NOTIFICATIONS) +if (SERVICE_STATUS_NOTIFIER OR SERVICE_MPRIS OR SERVICE_UPOWER OR SERVICE_NOTIFICATIONS OR BLUETOOTH OR NETWORK) set(DBUS ON) endif() @@ -126,6 +144,13 @@ endif() find_package(Qt6 REQUIRED COMPONENTS ${QT_FPDEPS}) +# In Qt 6.10, private dependencies are required to be explicit, +# but they could not be explicitly depended on prior to 6.9. +if (Qt6_VERSION VERSION_GREATER_EQUAL "6.9.0") + set(QT_NO_PRIVATE_MODULE_WARNING ON) + find_package(Qt6 REQUIRED COMPONENTS ${QT_PRIVDEPS}) +endif() + set(CMAKE_AUTOUIC OFF) qt_standard_project_setup(REQUIRES 6.6) set(QT_QML_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/qml_modules) @@ -145,3 +170,14 @@ install(CODE " ${CMAKE_INSTALL_FULL_BINDIR}/quickshell \$ENV{DESTDIR}${CMAKE_INSTALL_FULL_BINDIR}/qs ) ") + +install( + FILES ${CMAKE_SOURCE_DIR}/assets/org.quickshell.desktop + DESTINATION ${CMAKE_INSTALL_DATADIR}/applications +) + +install( + FILES ${CMAKE_SOURCE_DIR}/assets/quickshell.svg + DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps + RENAME org.quickshell.svg +) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39fab13..73e7931 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,235 +1,40 @@ -# Contributing / Development -Instructions for development setup and upstreaming patches. +# Contributing -If you just want to build or package quickshell see [BUILD.md](BUILD.md). +Thank you for taking the time to contribute. +To ensure nobody's time is wasted, please follow the rules below. -## Development +## Acceptable Code Contributions -Install the dependencies listed in [BUILD.md](BUILD.md). -You probably want all of them even if you don't use all of them -to ensure tests work correctly and avoid passing a bunch of configure -flags when you need to wipe the build directory. +- All changes submitted MUST be **fully understood by the submitter**. If you do not know why or how + your change works, do not submit it to be merged. You must be able to explain your reasoning + for every change. -Quickshell also uses `just` for common development command aliases. +- Changes MUST be submitted by a human who will be responsible for them. Changes submitted without + a human in the loop such as automated tooling and AI Agents are **strictly disallowed**. Accounts + responsible for such contribution attempts **will be banned**. -The dependencies are also available as a nix shell or nix flake which we recommend -using with nix-direnv. +- Changes MUST respect Quickshell's license and the license of any source works. Changes including + code from any other works must disclose the source of the code, explain why it was used, and + ensure the license is compatible. -Common aliases: -- `just configure [ [extra cmake args]]` (note that you must specify debug/release to specify extra args) -- `just build` - runs the build, configuring if not configured already. -- `just run [args]` - runs quickshell with the given arguments -- `just clean` - clean up build artifacts. `just clean build` is somewhat common. +- Changes must follow the guidelines outlined in [HACKING.md](HACKING.md) for style and substance. -### Formatting -All contributions should be formatted similarly to what already exists. -Group related functionality together. +- Changes must stand on their own as a unit. Do not make multiple unrelated changes in one PR. + Changes depending on prior merges should be marked as a draft. -Run the formatter using `just fmt`. -If the results look stupid, fix the clang-format file if possible, -or disable clang-format in the affected area -using `// clang-format off` and `// clang-format on`. +## Acceptable Non-code Contributions -#### Style preferences not caught by clang-format -These are flexible. You can ignore them if it looks or works better to -for one reason or another. +- Bug and crash reports. You must follow the instructions in the issue templates and provide the + information requested. -Use `auto` if the type of a variable can be deduced automatically, instead of -redeclaring the returned value's type. Additionally, auto should be used when a -constructor takes arguments. +- Feature requests can be made via Issues. Please check to ensure nobody else has requested the same feature. -```cpp -auto x = ; // ok -auto x = QString::number(3); // ok -QString x; // ok -QString x = "foo"; // ok -auto x = QString("foo"); // ok +- Do not make insubstantial or pointless changes. -auto x = QString(); // avoid -QString x(); // avoid -QString x("foo"); // avoid -``` +- Changes to project rules / policy / governance will not be entertained, except from significant + long-term contributors. These changes should not be addressed through contribution channels. -Put newlines around logical units of code, and after closing braces. If the -most reasonable logical unit of code takes only a single line, it should be -merged into the next single line logical unit if applicable. -```cpp -// multiple units -auto x = ; // unit 1 -auto y = ; // unit 2 +## Merge timelines -auto x = ; // unit 1 -emit this->y(); // unit 2 - -auto x1 = ; // unit 1 -auto x2 = ; // unit 1 -auto x3 = ; // unit 1 - -auto y1 = ; // unit 2 -auto y2 = ; // unit 2 -auto y3 = ; // unit 2 - -// one unit -auto x = ; -if (x...) { - // ... -} - -// if more than one variable needs to be used then add a newline -auto x = ; -auto y = ; - -if (x && y) { - // ... -} -``` - -Class formatting: -```cpp -//! Doc comment summary -/// Doc comment body -class Foo: public QObject { - // The Q_OBJECT macro comes first. Macros are ; terminated. - Q_OBJECT; - QML_ELEMENT; - QML_CLASSINFO(...); - // Properties must stay on a single line or the doc generator won't be able to pick them up - Q_PROPERTY(...); - /// Doc comment - Q_PROPERTY(...); - /// Doc comment - Q_PROPERTY(...); - -public: - // Classes should have explicit constructors if they aren't intended to - // implicitly cast. The constructor can be inline in the header if it has no body. - explicit Foo(QObject* parent = nullptr): QObject(parent) {} - - // Instance functions if applicable. - static Foo* instance(); - - // Member functions unrelated to properties come next - void function(); - void function(); - void function(); - - // Then Q_INVOKABLEs - Q_INVOKABLE function(); - /// Doc comment - Q_INVOKABLE function(); - /// Doc comment - Q_INVOKABLE function(); - - // Then property related functions, in the order (bindable, getter, setter). - // Related functions may be included here as well. Function bodies may be inline - // if they are a single expression. There should be a newline between each - // property's methods. - [[nodiscard]] QBindable bindableFoo() { return &this->bFoo; } - [[nodiscard]] T foo() const { return this->foo; } - void setFoo(); - - [[nodiscard]] T bar() const { return this->foo; } - void setBar(); - -signals: - // Signals that are not property change related go first. - // Property change signals go in property definition order. - void asd(); - void asd2(); - void fooChanged(); - void barChanged(); - -public slots: - // generally Q_INVOKABLEs are preferred to public slots. - void slot(); - -private slots: - // ... - -private: - // statics, then functions, then fields - static const foo BAR; - static void foo(); - - void foo(); - void bar(); - - // property related members are prefixed with `m`. - QString mFoo; - QString bar; - - // Bindables go last and should be prefixed with `b`. - Q_OBJECT_BINDABLE_PROPERTY(Foo, QString, bFoo, &Foo::fooChanged); -}; -``` - -### Linter -All contributions should pass the linter. - -Note that running the linter requires disabling precompiled -headers and including the test codepaths: -```sh -$ just configure debug -DNO_PCH=ON -DBUILD_TESTING=ON -$ just lint-changed -``` - -If the linter is complaining about something that you think it should not, -please disable the lint in your MR and explain your reasoning if it isn't obvious. - -### Tests -If you feel like the feature you are working on is very complex or likely to break, -please write some tests. We will ask you to directly if you send in an MR for an -overly complex or breakable feature. - -At least all tests that passed before your changes should still be passing -by the time your contribution is ready. - -You can run the tests using `just test` but you must enable them first -using `-DBUILD_TESTING=ON`. - -### Documentation -Most of quickshell's documentation is automatically generated from the source code. -You should annotate `Q_PROPERTY`s and `Q_INVOKABLE`s with doc comments. Note that the parser -cannot handle random line breaks and will usually require you to disable clang-format if the -lines are too long. - -Before submitting an MR, if adding new features please make sure the documentation is generated -reasonably using the `quickshell-docs` repo. We recommend checking it out at `/docs` in this repo. - -Doc comments take the form `///` or `///!` (summary) and work with markdown. -You can reference other types using the `@@[Module.][Type.][member]` shorthand -where all parts are optional. If module or type are not specified they will -be inferred as the current module. Member can be a `property`, `function()` or `signal(s)`. -Look at existing code for how it works. - -Quickshell modules additionally have a `module.md` file which contains a summary, description, -and list of headers to scan for documentation. - -## Contributing - -### Commits -Please structure your commit messages as `scope[!]: commit` where -the scope is something like `core` or `service/mpris`. (pick what has been -used historically or what makes sense if new). Add `!` for changes that break -existing APIs or functionality. - -Commit descriptions should contain a summary of the changes if they are not -sufficiently addressed in the commit message. - -Please squash/rebase additions or edits to previous changes and follow the -commit style to keep the history easily searchable at a glance. -Depending on the change, it is often reasonable to squash it into just -a single commit. (If you do not follow this we will squash your changes -for you.) - -### Sending patches -You may contribute by submitting a pull request on github, asking for -an account on our git server, or emailing patches / git bundles -directly to `outfoxxed@outfoxxed.me`. - -### Getting help -If you're getting stuck, you can come talk to us in the -[quickshell-development matrix room](https://matrix.to/#/#quickshell-development:outfoxxed.me) -for help on implementation, conventions, etc. -Feel free to ask for advice early in your implementation if you are -unsure. +We handle work for the most part on a push basis. If your PR has been ignored for a while +and is still relevant please bump it. diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 0000000..69357f1 --- /dev/null +++ b/HACKING.md @@ -0,0 +1,226 @@ +## Development + +Install the dependencies listed in [BUILD.md](BUILD.md). +You probably want all of them even if you don't use all of them +to ensure tests work correctly and avoid passing a bunch of configure +flags when you need to wipe the build directory. + +The dependencies are also available as a nix shell or nix flake which we recommend +using with nix-direnv. + +Quickshell uses `just` for common development command aliases. + +Common aliases: +- `just configure [ [extra cmake args]]` (note that you must specify debug/release to specify extra args) +- `just build` - runs the build, configuring if not configured already. +- `just run [args]` - runs quickshell with the given arguments +- `just clean` - clean up build artifacts. `just clean build` is somewhat common. + +### Formatting +All contributions should be formatted similarly to what already exists. +Group related functionality together. + +Run the formatter using `just fmt`. +If the results look stupid, fix the clang-format file if possible, +or disable clang-format in the affected area +using `// clang-format off` and `// clang-format on`. + +#### Style preferences not caught by clang-format +These are flexible. You can ignore them if it looks or works better to +for one reason or another. + +Use `auto` if the type of a variable can be deduced automatically, instead of +redeclaring the returned value's type. Additionally, auto should be used when a +constructor takes arguments. + +```cpp +auto x = ; // ok +auto x = QString::number(3); // ok +QString x; // ok +QString x = "foo"; // ok +auto x = QString("foo"); // ok + +auto x = QString(); // avoid +QString x(); // avoid +QString x("foo"); // avoid +``` + +Put newlines around logical units of code, and after closing braces. If the +most reasonable logical unit of code takes only a single line, it should be +merged into the next single line logical unit if applicable. +```cpp +// multiple units +auto x = ; // unit 1 +auto y = ; // unit 2 + +auto x = ; // unit 1 +emit this->y(); // unit 2 + +auto x1 = ; // unit 1 +auto x2 = ; // unit 1 +auto x3 = ; // unit 1 + +auto y1 = ; // unit 2 +auto y2 = ; // unit 2 +auto y3 = ; // unit 2 + +// one unit +auto x = ; +if (x...) { + // ... +} + +// if more than one variable needs to be used then add a newline +auto x = ; +auto y = ; + +if (x && y) { + // ... +} +``` + +Class formatting: +```cpp +//! Doc comment summary +/// Doc comment body +class Foo: public QObject { + // The Q_OBJECT macro comes first. Macros are ; terminated. + Q_OBJECT; + QML_ELEMENT; + QML_CLASSINFO(...); + // Properties must stay on a single line or the doc generator won't be able to pick them up + Q_PROPERTY(...); + /// Doc comment + Q_PROPERTY(...); + /// Doc comment + Q_PROPERTY(...); + +public: + // Classes should have explicit constructors if they aren't intended to + // implicitly cast. The constructor can be inline in the header if it has no body. + explicit Foo(QObject* parent = nullptr): QObject(parent) {} + + // Instance functions if applicable. + static Foo* instance(); + + // Member functions unrelated to properties come next + void function(); + void function(); + void function(); + + // Then Q_INVOKABLEs + Q_INVOKABLE function(); + /// Doc comment + Q_INVOKABLE function(); + /// Doc comment + Q_INVOKABLE function(); + + // Then property related functions, in the order (bindable, getter, setter). + // Related functions may be included here as well. Function bodies may be inline + // if they are a single expression. There should be a newline between each + // property's methods. + [[nodiscard]] QBindable bindableFoo() { return &this->bFoo; } + [[nodiscard]] T foo() const { return this->foo; } + void setFoo(); + + [[nodiscard]] T bar() const { return this->foo; } + void setBar(); + +signals: + // Signals that are not property change related go first. + // Property change signals go in property definition order. + void asd(); + void asd2(); + void fooChanged(); + void barChanged(); + +public slots: + // generally Q_INVOKABLEs are preferred to public slots. + void slot(); + +private slots: + // ... + +private: + // statics, then functions, then fields + static const foo BAR; + static void foo(); + + void foo(); + void bar(); + + // property related members are prefixed with `m`. + QString mFoo; + QString bar; + + // Bindables go last and should be prefixed with `b`. + Q_OBJECT_BINDABLE_PROPERTY(Foo, QString, bFoo, &Foo::fooChanged); +}; +``` + +Use lowercase .h suffixed Qt headers, e.g. `` over ``. + +### Linter +All contributions should pass the linter. + +Note that running the linter requires disabling precompiled +headers and including the test codepaths: +```sh +$ just configure debug -DNO_PCH=ON -DBUILD_TESTING=ON +$ just lint-changed +``` + +If the linter is complaining about something that you think it should not, +please disable the lint in your MR and explain your reasoning if it isn't obvious. + +### Tests +If you feel like the feature you are working on is very complex or likely to break, +please write some tests. We will ask you to directly if you send in an MR for an +overly complex or breakable feature. + +At least all tests that passed before your changes should still be passing +by the time your contribution is ready. + +You can run the tests using `just test` but you must enable them first +using `-DBUILD_TESTING=ON`. + +### Documentation +Most of quickshell's documentation is automatically generated from the source code. +You should annotate `Q_PROPERTY`s and `Q_INVOKABLE`s with doc comments. Note that the parser +cannot handle random line breaks and will usually require you to disable clang-format if the +lines are too long. + +Make sure new files containing doc comments are added to a `module.md` file. +See existing module files for reference. + +Doc comments take the form `///` or `///!` (summary) and work with markdown. +You can reference other types using the `@@[Module.][Type.][member]` shorthand +where all parts are optional. If module or type are not specified they will +be inferred as the current module. Member can be a `property`, `function()` or `signal(s)`. +Look at existing code for how it works. + +If you have made a user visible change since the last tagged release, describe it in +[changelog/next.md](changelog/next.md). + +## Contributing + +### Commits +Please structure your commit messages as `scope: commit` where +the scope is something like `core` or `service/mpris`. (pick what has been +used historically or what makes sense if new). + +Commit descriptions should contain a summary of the changes if they are not +sufficiently addressed in the commit message. + +Please squash/rebase additions or edits to previous changes and follow the +commit style to keep the history easily searchable at a glance. +Depending on the change, it is often reasonable to squash it into just +a single commit. (If you do not follow this we will squash your changes +for you.) + +### Getting help +If you're getting stuck, you can come talk to us in the +[quickshell-development matrix room](https://matrix.to/#/#quickshell-development:outfoxxed.me) +for help on implementation, conventions, etc. There is also a bridged [discord server](https://discord.gg/UtZeT3xNyT). +Feel free to ask for advice early in your implementation if you are +unsure. diff --git a/Justfile b/Justfile index f60771a..801eb2a 100644 --- a/Justfile +++ b/Justfile @@ -12,6 +12,9 @@ lint-ci: lint-changed: git diff --name-only HEAD | grep "^.*\.cpp\$" | parallel -j$(nproc) --no-notice --will-cite --tty --bar clang-tidy --load={{ env_var("TIDYFOX") }} +lint-staged: + git diff --staged --name-only --diff-filter=d HEAD | grep "^.*\.cpp\$" | parallel -j$(nproc) --no-notice --will-cite --tty --bar clang-tidy --load={{ env_var("TIDYFOX") }} + configure target='debug' *FLAGS='': cmake -GNinja -B {{builddir}} \ -DCMAKE_BUILD_TYPE={{ if target == "debug" { "Debug" } else { "RelWithDebInfo" } }} \ diff --git a/README.md b/README.md index adefcb8..365bdb5 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,14 @@ See the [website](https://quickshell.outfoxxed.me) for more information and installation instructions. +This repo is hosted at: +- https://git.outfoxxed.me/quickshell/quickshell +- https://github.com/quickshell-mirror/quickshell + # Contributing / Development -See [CONTRIBUTING.md](CONTRIBUTING.md) for details. +- [HACKING.md](HACKING.md) - Development instructions and policy. +- [CONTRIBUTING.md](CONTRIBUTING.md) - Contribution policy. +- [BUILD.md](BUILD.md) - Packaging and build instructions. #### License diff --git a/assets/org.quickshell.desktop b/assets/org.quickshell.desktop new file mode 100644 index 0000000..63f65fd --- /dev/null +++ b/assets/org.quickshell.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Version=1.5 +Type=Application +NoDisplay=true + +Name=Quickshell +Icon=org.quickshell diff --git a/assets/quickshell.svg b/assets/quickshell.svg new file mode 100644 index 0000000..7d0f948 --- /dev/null +++ b/assets/quickshell.svg @@ -0,0 +1 @@ + diff --git a/changelog/next.md b/changelog/next.md new file mode 100644 index 0000000..761161d --- /dev/null +++ b/changelog/next.md @@ -0,0 +1,84 @@ +## Breaking Changes + +### Config paths are no longer canonicalized + +This fixes nix configs changing shell-ids on rebuild as the shell id is now derived from +the symlink path. Configs with a symlink in their path will have a different shell id. + +Shell ids are used to derive the default config / state / cache folders, so those files +will need to be manually moved if using a config behind a symlinked path without an explicitly +set shell id. + +## New Features + +- Added support for creating Polkit agents. +- Added support for creating wayland idle inhibitors. +- Added support for wayland idle timeouts. +- Added support for inhibiting wayland compositor shortcuts for focused windows. +- Added the ability to override Quickshell.cacheDir with a custom path. +- Added minimized, maximized, and fullscreen properties to FloatingWindow. +- Added the ability to handle move and resize events to FloatingWindow. +- Pipewire service now reconnects if pipewire dies or a protocol error occurs. +- Added pipewire audio peak detection. +- Added network management support. +- Added support for grabbing focus from popup windows. +- Added support for IPC signal listeners. +- Added Quickshell version checking and version gated preprocessing. +- Added a way to detect if an icon is from the system icon theme or not. +- Added vulkan support to screencopy. +- Added generic WindowManager interface implementing ext-workspace. +- Added ext-background-effect window blur support. +- Added per-corner radius support to Region. +- Added ColorQuantizer region selection. +- Added dialog window support to FloatingWindow. + +## Other Changes + +- FreeBSD is now partially supported. +- IPC operations filter available instances to the current display connection by default. +- PwNodeLinkTracker ignores sound level monitoring programs. +- Replaced breakpad with cpptrace. +- Reloads are prevented if no file content has changed. +- Added `QS_DISABLE_FILE_WATCHER` environment variable to disable file watching. +- Added `QS_DISABLE_CRASH_HANDLER` environment variable to disable crash handling. +- Added `QS_CRASHREPORT_URL` environment variable to allow overriding the crash reporter link. +- Added `AppId` pragma and `QS_APP_ID` environment variable to allow overriding the desktop application ID. +- Added `DropExpensiveFonts` pragma and `QS_DROP_EXPENSIVE_FONTS` environment variable which avoids loading fonts which may cause lag and excessive memory usage if many variants are used. +- Unrecognized pragmas are no longer a hard error for future backward compatibility. + +## Bug Fixes + +- Fixed volume control breaking with pipewire pro audio mode. +- Fixed volume control breaking with bluez streams and potentially others. +- Fixed volume control breaking for devices without route definitions. +- Fixed escape sequence handling in desktop entries. +- Fixed volumes not initializing if a pipewire device was already loaded before its node. +- Fixed hyprland active toplevel not resetting after window closes. +- Fixed hyprland ipc window names and titles being reversed. +- Fixed a hyprland ipc crash when refreshing toplevels before workspaces. +- Fixed missing signals for system tray item title and description updates. +- Fixed asynchronous loaders not working after reload. +- Fixed asynchronous loaders not working before window creation. +- Fixed memory leak in IPC handlers. +- Fixed ClippingRectangle related crashes. +- Fixed crashes when monitors are unplugged. +- Fixed crashes when default pipewire devices are lost. +- Fixed ToplevelManager not clearing activeToplevel on deactivation. +- Desktop action order is now preserved. +- Fixed partial socket reads in greetd and hyprland on slow machines. +- Worked around Qt bug causing crashes when plugging and unplugging monitors. +- Fixed HyprlandFocusGrab crashing if windows were destroyed after being passed to it. +- Fixed ScreencopyView pixelation when scaled. +- Fixed JsonAdapter crashing and providing bad data on read when using JsonObject. +- Fixed JsonAdapter sending unnecessary property changes for primitive values. +- Fixed JsonAdapter serialization for lists. +- Fixed pipewire crashes after hotplugging devices and changing default outputs. +- Fixed launches failing for `--daemonize` on some systems. + +## Packaging Changes + +- `glib` and `polkit` have been added as dependencies when compiling with polkit agent support. +- `vulkan-headers` has been added as a build-time dependency for screencopy (Vulkan backend support). +- `breakpad` has been replaced by `cpptrace`, which is far easier to package, and the `CRASH_REPORTER` cmake variable has been replaced with `CRASH_HANDLER` to stop this from being easy to ignore. +- `DISTRIBUTOR_DEBUGINFO_AVAILABLE` was removed as it is no longer important without breakpad. +- `libdrm` is now unconditionally required as a direct dependency. diff --git a/changelog/v0.1.0.md b/changelog/v0.1.0.md new file mode 100644 index 0000000..f8a032f --- /dev/null +++ b/changelog/v0.1.0.md @@ -0,0 +1 @@ +Initial release diff --git a/changelog/v0.2.0.md b/changelog/v0.2.0.md new file mode 100644 index 0000000..2fbf74d --- /dev/null +++ b/changelog/v0.2.0.md @@ -0,0 +1,84 @@ +## Breaking Changes + +- Files outside of the shell directory can no longer be referenced with relative paths, e.g. '../../foo.png'. +- PanelWindow's Automatic exclusion mode now adds an exclusion zone for panels with a single anchor. +- `QT_QUICK_CONTROLS_STYLE` and `QT_STYLE_OVERRIDE` are ignored unless `//@ pragma RespectSystemStyle` is set. + +## New Features + +### Root-Relative Imports + +Quickshell 0.2 comes with a new method to import QML modules which is supported by QMLLS. +This replaces "root:/" imports for QML modules. + +The new syntax is `import qs.path.to.module`, where `path/to/module` is the path to +a module/subdirectory relative to the config root (`qs`). + +### Better LSP support + +LSP support for Singletons and Root-Relative imports can be enabled by creating a file named +`.qmlls.ini` in the shell root directory. Quickshell will detect this file and automatically +populate it with an LSP configuration. This file should be gitignored in your configuration, +as it is system dependent. + +The generated configuration also includes QML import paths available to Quickshell, meaning +QMLLS no longer requires the `-E` flag. + +### Bluetooth Module + +Quickshell can now manage your bluetooth devices through BlueZ. While authenticated pairing +has not landed in 0.2, support for connecting and disconnecting devices, basic device information, +and non-authenticated pairing are now supported. + +### Other Features + +- Added `HyprlandToplevel` and related toplevel/window management APIs in the Hyprland module. +- Added `Quickshell.execDetached()`, which spawns a detached process without a `Process` object. +- Added `Process.exec()` for easier reconfiguration of process commands when starting them. +- Added `FloatingWindow.title`, which allows changing the title of a floating window. +- Added `signal QsWindow.closed()`, fired when a window is closed externally. +- Added support for inline replies in notifications, when supported by applications. +- Added `DesktopEntry.startupWmClass` and `DesktopEntry.heuristicLookup()` to better identify toplevels. +- Added `DesktopEntry.command` which can be run as an alternative to `DesktopEntry.execute()`. +- Added `//@ pragma Internal`, which makes a QML component impossible to import outside of its module. +- Added dead instance selection for some subcommands, such as `qs log` and `qs list`. + +## Other Changes + +- `Quickshell.shellRoot` has been renamed to `Quickshell.shellDir`. +- PanelWindow margins opposite the window's anchorpoint are now added to exclusion zone. +- stdout/stderr or detached processes and executed desktop entries are now hidden by default. +- Various warnings caused by other applications Quickshell communicates with over D-BUS have been hidden in logs. +- Quickshell's new logo is now shown in any floating windows. + +## Bug Fixes + +- Fixed pipewire device volume and mute states not updating before the device has been used. +- Fixed a crash when changing the volume of any pipewire device on a sound card another removed device was using. +- Fixed a crash when accessing a removed previous default pipewire node from the default sink/source changed signals. +- Fixed session locks crashing if all monitors are disconnected. +- Fixed session locks crashing if unsupported by the compositor. +- Fixed a crash when creating a session lock and destroying it before acknowledged by the compositor. +- Fixed window input masks not updating after a reload. +- Fixed PanelWindows being unconfigurable unless `screen` was set under X11. +- Fixed a crash when anchoring a popup to a zero sized `Item`. +- Fixed `FileView` crashing if `watchChanges` was used. +- Fixed `SocketServer` sockets disappearing after a reload. +- Fixed `ScreencopyView` having incorrect rotation when displaying a rotated monitor. +- Fixed `MarginWrapperManager` breaking pixel alignment of child items when centering. +- Fixed `IpcHandler`, `NotificationServer` and `GlobalShortcut` not activating with certain QML structures. +- Fixed tracking of QML incubator destruction and deregistration, which occasionally caused crashes. +- Fixed FloatingWindows being constrained to the smallest window manager supported size unless max size was set. +- Fixed `MprisPlayer.lengthSupported` not updating reactively. +- Fixed normal tray icon being ignored when status is `NeedsAttention` and no attention icon is provided. +- Fixed `HyprlandWorkspace.activate()` sending invalid commands to Hyprland for named or special workspaces. +- Fixed file watcher occasionally breaking when using VSCode to edit QML files. +- Fixed crashes when screencopy buffer creation fails. +- Fixed a crash when wayland layer surfaces are recreated for the same window. +- Fixed the `QsWindow` attached object not working when using `WlrLayershell` directly. +- Fixed a crash when attempting to create a window without available VRAM. +- Fixed OOM crash when failing to write to detailed log file. +- Prevented distro logging configurations for Qt from interfering with Quickshell commands. +- Removed the "QProcess destroyed for running process" warning when destroying `Process` objects. +- Fixed `ColorQuantizer` printing a pointer to an error message instead of an error message. +- Fixed notification pixmap rowstride warning showing for correct rowstrides. diff --git a/changelog/v0.2.1.md b/changelog/v0.2.1.md new file mode 100644 index 0000000..596b82f --- /dev/null +++ b/changelog/v0.2.1.md @@ -0,0 +1,17 @@ +## New Features + +- Changes to desktop entries are now tracked in real time. + +## Other Changes + +- Added support for Qt 6.10 + +## Bug Fixes + +- Fixed volumes getting stuck on change for pipewire devices with few volume steps. +- Fixed a crash when running out of disk space to write log files. +- Fixed a rare crash when disconnecting a monitor. +- Fixed build issues preventing cross compilation from working. +- Fixed dekstop entries with lower priority than a hidden entry not being hidden. +- Fixed desktop entry keys with mismatched modifier or country not being discarded. +- Fixed greetd hanging when authenticating with a fingerprint. diff --git a/ci/matrix.nix b/ci/matrix.nix index be2da61..dd20fa5 100644 --- a/ci/matrix.nix +++ b/ci/matrix.nix @@ -2,7 +2,10 @@ qtver, compiler, }: let - nixpkgs = (import ./nix-checkouts.nix).${builtins.replaceStrings ["."] ["_"] qtver}; + checkouts = import ./nix-checkouts.nix; + nixpkgs = checkouts.${builtins.replaceStrings ["."] ["_"] qtver}; compilerOverride = (nixpkgs.callPackage ./variations.nix {}).${compiler}; - pkg = (nixpkgs.callPackage ../default.nix {}).override compilerOverride; + pkg = (nixpkgs.callPackage ../default.nix {}).override (compilerOverride // { + wayland-protocols = checkouts.latest.wayland-protocols; + }); in pkg diff --git a/ci/nix-checkouts.nix b/ci/nix-checkouts.nix index 73c2415..945973c 100644 --- a/ci/nix-checkouts.nix +++ b/ci/nix-checkouts.nix @@ -7,9 +7,28 @@ let url = "https://github.com/nixos/nixpkgs/archive/${commit}.tar.gz"; inherit sha256; }) {}; -in { - # For old qt versions, grab the commit before the version bump that has all the patches - # instead of the bumped version. +in rec { + latest = qt6_10_0; + + qt6_10_1 = byCommit { + commit = "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38"; + sha256 = "0fvbizl7j5rv2rf8j76yw0xb3d9l06hahkjys2a7k1yraznvnafm"; + }; + + qt6_10_0 = byCommit { + commit = "c5ae371f1a6a7fd27823bc500d9390b38c05fa55"; + sha256 = "18g0f8cb9m8mxnz9cf48sks0hib79b282iajl2nysyszph993yp0"; + }; + + qt6_9_2 = byCommit { + commit = "e9f00bd893984bc8ce46c895c3bf7cac95331127"; + sha256 = "0s2mhbrgzxlgkg2yxb0q0hpk8lby1a7w67dxvfmaz4gsmc0bnvfj"; + }; + + qt6_9_1 = byCommit { + commit = "4c202d26483c5ccf3cb95e0053163facde9f047e"; + sha256 = "06l2w4bcgfw7dfanpzpjcf25ydf84in240yplqsss82qx405y9di"; + }; qt6_9_0 = byCommit { commit = "546c545bd0594809a28ab7e869b5f80dd7243ef6"; diff --git a/ci/variations.nix b/ci/variations.nix index b0889be..b1d2947 100644 --- a/ci/variations.nix +++ b/ci/variations.nix @@ -2,6 +2,6 @@ clangStdenv, gccStdenv, }: { - clang = { buildStdenv = clangStdenv; }; - gcc = { buildStdenv = gccStdenv; }; + clang = { stdenv = clangStdenv; }; + gcc = { stdenv = gccStdenv; }; } diff --git a/default.nix b/default.nix index 79c9b7a..749ef49 100644 --- a/default.nix +++ b/default.nix @@ -2,23 +2,31 @@ lib, nix-gitignore, pkgs, + stdenv, keepDebugInfo, - buildStdenv ? pkgs.clangStdenv, + pkg-config, cmake, ninja, - qt6, spirv-tools, - cli11, - breakpad, + qt6, + cpptrace ? null, + libunwind, + libdwarf, jemalloc, + cli11, wayland, wayland-protocols, + wayland-scanner, + xorg, + libxcb ? xorg.libxcb, libdrm, libgbm ? null, - xorg, + vulkan-headers, pipewire, pam, + polkit, + glib, gitRev ? (let headExists = builtins.pathExists ./.git/HEAD; @@ -41,64 +49,107 @@ withPam ? true, withHyprland ? true, withI3 ? true, -}: buildStdenv.mkDerivation { - pname = "quickshell${lib.optionalString debug "-debug"}"; - version = "0.1.0"; - src = nix-gitignore.gitignoreSource "/docs\n/examples\n" ./.; + withPolkit ? true, + withNetworkManager ? true, +}: let + withCrashHandler = withCrashReporter && cpptrace != null && lib.strings.compareVersions cpptrace.version "0.7.2" >= 0; - nativeBuildInputs = with pkgs; [ - cmake - ninja - qt6.qtshadertools - spirv-tools - qt6.wrapQtAppsHook - pkg-config - ] ++ (lib.optionals withWayland [ - wayland-protocols - wayland-scanner - ]); + unwrapped = stdenv.mkDerivation { + pname = "quickshell${lib.optionalString debug "-debug"}"; + version = "0.2.1"; + src = nix-gitignore.gitignoreSource "/default.nix\n" ./.; - buildInputs = [ - qt6.qtbase - qt6.qtdeclarative - cli11 - ] - ++ lib.optional withCrashReporter breakpad - ++ lib.optional withJemalloc jemalloc - ++ lib.optional withQtSvg qt6.qtsvg - ++ lib.optionals withWayland ([ qt6.qtwayland wayland ] ++ (if libgbm != null then [ libdrm libgbm ] else [])) - ++ lib.optional withX11 xorg.libxcb - ++ lib.optional withPam pam - ++ lib.optional withPipewire pipewire; + dontWrapQtApps = true; # see wrappers - cmakeBuildType = if debug then "Debug" else "RelWithDebInfo"; + nativeBuildInputs = [ + cmake + ninja + spirv-tools + pkg-config + ] + ++ lib.optional (withWayland && lib.strings.compareVersions qt6.qtbase.version "6.10.0" == -1) qt6.qtwayland + ++ lib.optionals withWayland [ + qt6.qtwayland # qtwaylandscanner required at build time + wayland-scanner + ]; - cmakeFlags = [ - (lib.cmakeFeature "DISTRIBUTOR" "Official-Nix-Flake") - (lib.cmakeFeature "INSTALL_QML_PREFIX" qt6.qtbase.qtQmlPrefix) - (lib.cmakeBool "DISTRIBUTOR_DEBUGINFO_AVAILABLE" true) - (lib.cmakeFeature "GIT_REVISION" gitRev) - (lib.cmakeBool "CRASH_REPORTER" withCrashReporter) - (lib.cmakeBool "USE_JEMALLOC" withJemalloc) - (lib.cmakeBool "WAYLAND" withWayland) - (lib.cmakeBool "SCREENCOPY" (libgbm != null)) - (lib.cmakeBool "SERVICE_PIPEWIRE" withPipewire) - (lib.cmakeBool "SERVICE_PAM" withPam) - (lib.cmakeBool "HYPRLAND" withHyprland) - (lib.cmakeBool "I3" withI3) - ]; + buildInputs = [ + qt6.qtbase + qt6.qtdeclarative + libdrm + cli11 + ] + ++ lib.optional withQtSvg qt6.qtsvg + ++ lib.optional withCrashHandler (cpptrace.overrideAttrs (prev: { + cmakeFlags = prev.cmakeFlags ++ [ + "-DCPPTRACE_UNWIND_WITH_LIBUNWIND=TRUE" + ]; + buildInputs = prev.buildInputs ++ [ libunwind ]; + })) + ++ lib.optional withJemalloc jemalloc + ++ lib.optional (withWayland && lib.strings.compareVersions qt6.qtbase.version "6.10.0" == -1) qt6.qtwayland + ++ lib.optionals withWayland [ wayland wayland-protocols ] + ++ lib.optionals (withWayland && libgbm != null) [ libgbm vulkan-headers ] + ++ lib.optional withX11 libxcb + ++ lib.optional withPam pam + ++ lib.optional withPipewire pipewire + ++ lib.optionals withPolkit [ polkit glib ]; - # How to get debuginfo in gdb from a release build: - # 1. build `quickshell.debug` - # 2. set NIX_DEBUG_INFO_DIRS="/lib/debug" - # 3. launch gdb / coredumpctl and debuginfo will work - separateDebugInfo = !debug; - dontStrip = debug; + cmakeBuildType = if debug then "Debug" else "RelWithDebInfo"; - meta = with lib; { - homepage = "https://git.outfoxxed.me/outfoxxed/quickshell"; - description = "Flexbile QtQuick based desktop shell toolkit"; - license = licenses.lgpl3Only; - platforms = platforms.linux; + cmakeFlags = [ + (lib.cmakeFeature "DISTRIBUTOR" "Official-Nix-Flake") + (lib.cmakeFeature "INSTALL_QML_PREFIX" qt6.qtbase.qtQmlPrefix) + (lib.cmakeBool "DISTRIBUTOR_DEBUGINFO_AVAILABLE" true) + (lib.cmakeFeature "GIT_REVISION" gitRev) + (lib.cmakeBool "CRASH_HANDLER" withCrashHandler) + (lib.cmakeBool "USE_JEMALLOC" withJemalloc) + (lib.cmakeBool "WAYLAND" withWayland) + (lib.cmakeBool "SCREENCOPY" (libgbm != null)) + (lib.cmakeBool "SERVICE_PIPEWIRE" withPipewire) + (lib.cmakeBool "SERVICE_PAM" withPam) + (lib.cmakeBool "SERVICE_NETWORKMANAGER" withNetworkManager) + (lib.cmakeBool "SERVICE_POLKIT" withPolkit) + (lib.cmakeBool "HYPRLAND" withHyprland) + (lib.cmakeBool "I3" withI3) + ]; + + # How to get debuginfo in gdb from a release build: + # 1. build `quickshell.debug` + # 2. set NIX_DEBUG_INFO_DIRS="/lib/debug" + # 3. launch gdb / coredumpctl and debuginfo will work + separateDebugInfo = !debug; + dontStrip = debug; + + meta = with lib; { + homepage = "https://quickshell.org"; + description = "Flexbile QtQuick based desktop shell toolkit"; + license = licenses.lgpl3Only; + platforms = platforms.linux; + mainProgram = "quickshell"; + }; }; -} + + wrapper = unwrapped.stdenv.mkDerivation { + inherit (unwrapped) version meta buildInputs; + pname = "${unwrapped.pname}-wrapped"; + + nativeBuildInputs = unwrapped.nativeBuildInputs ++ [ qt6.wrapQtAppsHook ]; + + dontUnpack = true; + dontConfigure = true; + dontBuild = true; + + installPhase = '' + mkdir -p $out + cp -r ${unwrapped}/* $out + ''; + + passthru = { + unwrapped = unwrapped; + withModules = modules: wrapper.overrideAttrs (prev: { + buildInputs = prev.buildInputs ++ modules; + }); + }; + }; +in wrapper diff --git a/flake.lock b/flake.lock index df5aa3f..2f95a44 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1736012469, - "narHash": "sha256-/qlNWm/IEVVH7GfgAIyP6EsVZI6zjAx1cV5zNyrs+rI=", + "lastModified": 1768127708, + "narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8f3e1f807051e32d8c95cd12b9b421623850a34d", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index a0bc18d..8edda2c 100644 --- a/flake.nix +++ b/flake.nix @@ -4,22 +4,28 @@ }; outputs = { self, nixpkgs }: let - forEachSystem = fn: nixpkgs.lib.genAttrs - [ "x86_64-linux" "aarch64-linux" ] - (system: fn system nixpkgs.legacyPackages.${system}); - in { - packages = forEachSystem (system: pkgs: rec { - quickshell = pkgs.callPackage ./default.nix { - gitRev = self.rev or self.dirtyRev; - }; + overlayPkgs = p: p.appendOverlays [ self.overlays.default ]; + forEachSystem = fn: + nixpkgs.lib.genAttrs + nixpkgs.lib.platforms.linux + (system: fn system (overlayPkgs nixpkgs.legacyPackages.${system})); + in { + overlays.default = import ./overlay.nix { + rev = self.rev or self.dirtyRev; + }; + + packages = forEachSystem (system: pkgs: rec { + quickshell = pkgs.quickshell; default = quickshell; }); devShells = forEachSystem (system: pkgs: rec { default = import ./shell.nix { inherit pkgs; - inherit (self.packages.${system}) quickshell; + quickshell = self.packages.${system}.quickshell.override { + stdenv = pkgs.clangStdenv; + }; }; }); }; diff --git a/overlay.nix b/overlay.nix new file mode 100644 index 0000000..d8ea137 --- /dev/null +++ b/overlay.nix @@ -0,0 +1,5 @@ +{ rev ? null }: (final: prev: { + quickshell = final.callPackage ./default.nix { + gitRev = rev; + }; +}) diff --git a/quickshell.scm b/quickshell.scm index 05bf269..780bb96 100644 --- a/quickshell.scm +++ b/quickshell.scm @@ -35,13 +35,14 @@ pkg-config qtshadertools spirv-tools - wayland-protocols)) - (inputs (list cli11 - jemalloc + wayland-protocols + cli11)) + (inputs (list jemalloc libdrm libxcb libxkbcommon linux-pam + polkit mesa pipewire qtbase @@ -55,8 +56,7 @@ #~(list "-GNinja" "-DDISTRIBUTOR=\"In-tree Guix channel\"" "-DDISTRIBUTOR_DEBUGINFO_AVAILABLE=NO" - ;; Breakpad is not currently packaged for Guix. - "-DCRASH_REPORTER=OFF") + "-DCRASH_HANDLER=OFF") #:phases #~(modify-phases %standard-phases (replace 'build (lambda _ (invoke "cmake" "--build" "."))) diff --git a/shell.nix b/shell.nix index 82382f9..03a446d 100644 --- a/shell.nix +++ b/shell.nix @@ -1,14 +1,15 @@ { pkgs ? import {}, - quickshell ? pkgs.callPackage ./default.nix {}, + stdenv ? pkgs.clangStdenv, # faster compiles than gcc + quickshell ? pkgs.callPackage ./default.nix { inherit stdenv; }, ... }: let tidyfox = import (pkgs.fetchFromGitea { domain = "git.outfoxxed.me"; owner = "outfoxxed"; repo = "tidyfox"; - rev = "1f062cc198d1112d13e5128fa1f2ee3dbffe613b"; - sha256 = "kbt0Zc1qHE5fhqBkKz8iue+B+ZANjF1AR/RdgmX1r0I="; + rev = "9d85d7e7dea2602aa74ec3168955fee69967e92f"; + hash = "sha256-77ERiweF6lumonp2c/124rAoVG6/o9J+Aajhttwtu0w="; }) { inherit pkgs; }; in pkgs.mkShell.override { stdenv = quickshell.stdenv; } { inputsFrom = [ quickshell ]; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d3070b6..0c05419 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,8 +11,9 @@ add_subdirectory(window) add_subdirectory(io) add_subdirectory(widgets) add_subdirectory(ui) +add_subdirectory(windowmanager) -if (CRASH_REPORTER) +if (CRASH_HANDLER) add_subdirectory(crash) endif() @@ -29,3 +30,11 @@ if (X11) endif() add_subdirectory(services) + +if (BLUETOOTH) + add_subdirectory(bluetooth) +endif() + +if (NETWORK) + add_subdirectory(network) +endif() diff --git a/src/bluetooth/CMakeLists.txt b/src/bluetooth/CMakeLists.txt new file mode 100644 index 0000000..806ff04 --- /dev/null +++ b/src/bluetooth/CMakeLists.txt @@ -0,0 +1,42 @@ +set_source_files_properties(org.bluez.Adapter.xml PROPERTIES + CLASSNAME DBusBluezAdapterInterface +) + +set_source_files_properties(org.bluez.Device.xml PROPERTIES + CLASSNAME DBusBluezDeviceInterface +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.bluez.Adapter.xml + dbus_adapter +) + +qt_add_dbus_interface(DBUS_INTERFACES + org.bluez.Device.xml + dbus_device +) + +qt_add_library(quickshell-bluetooth STATIC + adapter.cpp + bluez.cpp + device.cpp + ${DBUS_INTERFACES} +) + +qt_add_qml_module(quickshell-bluetooth + URI Quickshell.Bluetooth + VERSION 0.1 + DEPENDENCIES QtQml +) + +install_qml_module(quickshell-bluetooth) + +# dbus headers +target_include_directories(quickshell-bluetooth PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +target_link_libraries(quickshell-bluetooth PRIVATE Qt::Qml Qt::DBus) +qs_add_link_dependencies(quickshell-bluetooth quickshell-dbus) + +qs_module_pch(quickshell-bluetooth SET dbus) + +target_link_libraries(quickshell PRIVATE quickshell-bluetoothplugin) diff --git a/src/bluetooth/adapter.cpp b/src/bluetooth/adapter.cpp new file mode 100644 index 0000000..7f70a27 --- /dev/null +++ b/src/bluetooth/adapter.cpp @@ -0,0 +1,223 @@ +#include "adapter.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "../dbus/properties.hpp" +#include "dbus_adapter.h" + +namespace qs::bluetooth { + +namespace { +QS_LOGGING_CATEGORY(logAdapter, "quickshell.bluetooth.adapter", QtWarningMsg); +} + +QString BluetoothAdapterState::toString(BluetoothAdapterState::Enum state) { + switch (state) { + case BluetoothAdapterState::Disabled: return QStringLiteral("Disabled"); + case BluetoothAdapterState::Enabled: return QStringLiteral("Enabled"); + case BluetoothAdapterState::Enabling: return QStringLiteral("Enabling"); + case BluetoothAdapterState::Disabling: return QStringLiteral("Disabling"); + case BluetoothAdapterState::Blocked: return QStringLiteral("Blocked"); + default: return QStringLiteral("Unknown"); + } +} + +BluetoothAdapter::BluetoothAdapter(const QString& path, QObject* parent): QObject(parent) { + this->mInterface = + new DBusBluezAdapterInterface("org.bluez", path, QDBusConnection::systemBus(), this); + + if (!this->mInterface->isValid()) { + qCWarning(logAdapter) << "Could not create DBus interface for adapter at" << path; + this->mInterface = nullptr; + return; + } + + this->properties.setInterface(this->mInterface); +} + +QString BluetoothAdapter::adapterId() const { + auto path = this->path(); + return path.sliced(path.lastIndexOf('/') + 1); +} + +void BluetoothAdapter::setEnabled(bool enabled) { + if (enabled == this->bEnabled) return; + + if (enabled && this->bState == BluetoothAdapterState::Blocked) { + qCCritical(logAdapter) << "Cannot enable adapter because it is blocked by rfkill."; + return; + } + + this->bEnabled = enabled; + this->pEnabled.write(); +} + +void BluetoothAdapter::setDiscoverable(bool discoverable) { + if (discoverable == this->bDiscoverable) return; + this->bDiscoverable = discoverable; + this->pDiscoverable.write(); +} + +void BluetoothAdapter::setDiscovering(bool discovering) { + if (discovering) { + this->startDiscovery(); + } else { + this->stopDiscovery(); + } +} + +void BluetoothAdapter::setDiscoverableTimeout(quint32 timeout) { + if (timeout == this->bDiscoverableTimeout) return; + this->bDiscoverableTimeout = timeout; + this->pDiscoverableTimeout.write(); +} + +void BluetoothAdapter::setPairable(bool pairable) { + if (pairable == this->bPairable) return; + this->bPairable = pairable; + this->pPairable.write(); +} + +void BluetoothAdapter::setPairableTimeout(quint32 timeout) { + if (timeout == this->bPairableTimeout) return; + this->bPairableTimeout = timeout; + this->pPairableTimeout.write(); +} + +void BluetoothAdapter::addInterface(const QString& interface, const QVariantMap& properties) { + if (interface == "org.bluez.Adapter1") { + this->properties.updatePropertySet(properties, false); + qCDebug(logAdapter) << "Updated Adapter properties for" << this; + } +} + +void BluetoothAdapter::removeDevice(const QString& devicePath) { + qCDebug(logAdapter) << "Removing device" << devicePath << "from adapter" << this; + + auto reply = this->mInterface->RemoveDevice(QDBusObjectPath(devicePath)); + + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this, devicePath](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to remove device " << devicePath << " from adapter" << this << ": " + << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully removed device" << devicePath << "from adapter" + << this; + } + + delete watcher; + } + ); +} + +void BluetoothAdapter::startDiscovery() { + if (this->bDiscovering) return; + qCDebug(logAdapter) << "Starting discovery for adapter" << this; + + auto reply = this->mInterface->StartDiscovery(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to start discovery on adapter" << this << ": " << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully started discovery on adapter" << this; + } + + delete watcher; + } + ); +} + +void BluetoothAdapter::stopDiscovery() { + if (!this->bDiscovering) return; + qCDebug(logAdapter) << "Stopping discovery for adapter" << this; + + auto reply = this->mInterface->StopDiscovery(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logAdapter).nospace() + << "Failed to stop discovery on adapter " << this << ": " << reply.error().message(); + } else { + qCDebug(logAdapter) << "Successfully stopped discovery on adapter" << this; + } + + delete watcher; + } + ); +} + +} // namespace qs::bluetooth + +namespace qs::dbus { + +using namespace qs::bluetooth; + +DBusResult +DBusDataTransform::fromWire(const Wire& wire) { + if (wire == QStringLiteral("off")) { + return BluetoothAdapterState::Disabled; + } else if (wire == QStringLiteral("on")) { + return BluetoothAdapterState::Enabled; + } else if (wire == QStringLiteral("off-enabling")) { + return BluetoothAdapterState::Enabling; + } else if (wire == QStringLiteral("on-disabling")) { + return BluetoothAdapterState::Disabling; + } else if (wire == QStringLiteral("off-blocked")) { + return BluetoothAdapterState::Blocked; + } else { + return QDBusError( + QDBusError::InvalidArgs, + QString("Invalid BluetoothAdapterState: %1").arg(wire) + ); + } +} + +} // namespace qs::dbus + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothAdapter* adapter) { + auto saver = QDebugStateSaver(debug); + + if (adapter) { + debug.nospace() << "BluetoothAdapter(" << static_cast(adapter) + << ", path=" << adapter->path() << ")"; + } else { + debug << "BluetoothAdapter(nullptr)"; + } + + return debug; +} diff --git a/src/bluetooth/adapter.hpp b/src/bluetooth/adapter.hpp new file mode 100644 index 0000000..d7f21d7 --- /dev/null +++ b/src/bluetooth/adapter.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/model.hpp" +#include "../dbus/properties.hpp" +#include "dbus_adapter.h" + +namespace qs::bluetooth { + +///! Power state of a Bluetooth adapter. +class BluetoothAdapterState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// The adapter is powered off. + Disabled = 0, + /// The adapter is powered on. + Enabled = 1, + /// The adapter is transitioning from off to on. + Enabling = 2, + /// The adapter is transitioning from on to off. + Disabling = 3, + /// The adapter is blocked by rfkill. + Blocked = 4, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(BluetoothAdapterState::Enum state); +}; + +} // namespace qs::bluetooth + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = QString; + using Data = qs::bluetooth::BluetoothAdapterState::Enum; + static DBusResult fromWire(const Wire& wire); +}; + +} // namespace qs::dbus + +namespace qs::bluetooth { + +class BluetoothAdapter; +class BluetoothDevice; + +///! A Bluetooth adapter +class BluetoothAdapter: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + // clang-format off + /// System provided name of the adapter. See @@adapterId for the internal identifier. + Q_PROPERTY(QString name READ default NOTIFY nameChanged BINDABLE bindableName); + /// True if the adapter is currently enabled. More detailed state is available from @@state. + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged); + /// Detailed power state of the adapter. + Q_PROPERTY(BluetoothAdapterState::Enum state READ default NOTIFY stateChanged BINDABLE bindableState); + /// True if the adapter can be discovered by other bluetooth devices. + Q_PROPERTY(bool discoverable READ discoverable WRITE setDiscoverable NOTIFY discoverableChanged); + /// Timeout in seconds for how long the adapter stays discoverable after @@discoverable is set to true. + /// A value of 0 means the adapter stays discoverable forever. + Q_PROPERTY(quint32 discoverableTimeout READ discoverableTimeout WRITE setDiscoverableTimeout NOTIFY discoverableTimeoutChanged); + /// True if the adapter is scanning for new devices. + Q_PROPERTY(bool discovering READ discovering WRITE setDiscovering NOTIFY discoveringChanged); + /// True if the adapter is accepting incoming pairing requests. + /// + /// This only affects incoming pairing requests and should typically only be changed + /// by system settings applications. Defaults to true. + Q_PROPERTY(bool pairable READ pairable WRITE setPairable NOTIFY pairableChanged); + /// Timeout in seconds for how long the adapter stays pairable after @@pairable is set to true. + /// A value of 0 means the adapter stays pairable forever. Defaults to 0. + Q_PROPERTY(quint32 pairableTimeout READ pairableTimeout WRITE setPairableTimeout NOTIFY pairableTimeoutChanged); + /// Bluetooth devices connected to this adapter. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT); + /// The internal ID of the adapter (e.g., "hci0"). + Q_PROPERTY(QString adapterId READ adapterId CONSTANT); + /// DBus path of the adapter under the `org.bluez` system service. + Q_PROPERTY(QString dbusPath READ path CONSTANT); + // clang-format on + +public: + explicit BluetoothAdapter(const QString& path, QObject* parent = nullptr); + + [[nodiscard]] bool isValid() const { return this->mInterface->isValid(); } + [[nodiscard]] QString path() const { return this->mInterface->path(); } + [[nodiscard]] QString adapterId() const; + + [[nodiscard]] bool enabled() const { return this->bEnabled; } + void setEnabled(bool enabled); + + [[nodiscard]] bool discoverable() const { return this->bDiscoverable; } + void setDiscoverable(bool discoverable); + + [[nodiscard]] bool discovering() const { return this->bDiscovering; } + void setDiscovering(bool discovering); + + [[nodiscard]] quint32 discoverableTimeout() const { return this->bDiscoverableTimeout; } + void setDiscoverableTimeout(quint32 timeout); + + [[nodiscard]] bool pairable() const { return this->bPairable; } + void setPairable(bool pairable); + + [[nodiscard]] quint32 pairableTimeout() const { return this->bPairableTimeout; } + void setPairableTimeout(quint32 timeout); + + [[nodiscard]] QBindable bindableName() { return &this->bName; } + [[nodiscard]] QBindable bindableEnabled() { return &this->bEnabled; } + [[nodiscard]] QBindable bindableState() { return &this->bState; } + [[nodiscard]] QBindable bindableDiscoverable() { return &this->bDiscoverable; } + [[nodiscard]] QBindable bindableDiscoverableTimeout() { + return &this->bDiscoverableTimeout; + } + [[nodiscard]] QBindable bindableDiscovering() { return &this->bDiscovering; } + [[nodiscard]] QBindable bindablePairable() { return &this->bPairable; } + [[nodiscard]] QBindable bindablePairableTimeout() { return &this->bPairableTimeout; } + [[nodiscard]] ObjectModel* devices() { return &this->mDevices; } + + void addInterface(const QString& interface, const QVariantMap& properties); + void removeDevice(const QString& devicePath); + + void startDiscovery(); + void stopDiscovery(); + +signals: + void nameChanged(); + void enabledChanged(); + void stateChanged(); + void discoverableChanged(); + void discoverableTimeoutChanged(); + void discoveringChanged(); + void pairableChanged(); + void pairableTimeoutChanged(); + +private: + DBusBluezAdapterInterface* mInterface = nullptr; + ObjectModel mDevices {this}; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, QString, bName, &BluetoothAdapter::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bEnabled, &BluetoothAdapter::enabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, BluetoothAdapterState::Enum, bState, &BluetoothAdapter::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bDiscoverable, &BluetoothAdapter::discoverableChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, quint32, bDiscoverableTimeout, &BluetoothAdapter::discoverableTimeoutChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bDiscovering, &BluetoothAdapter::discoveringChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, bool, bPairable, &BluetoothAdapter::pairableChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothAdapter, quint32, bPairableTimeout, &BluetoothAdapter::pairableTimeoutChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothAdapter, properties); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pName, bName, properties, "Alias"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pEnabled, bEnabled, properties, "Powered"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pState, bState, properties, "PowerState"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscoverable, bDiscoverable, properties, "Discoverable"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscoverableTimeout, bDiscoverableTimeout, properties, "DiscoverableTimeout"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pDiscovering, bDiscovering, properties, "Discovering"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pPairable, bPairable, properties, "Pairable"); + QS_DBUS_PROPERTY_BINDING(BluetoothAdapter, pPairableTimeout, bPairableTimeout, properties, "PairableTimeout"); + // clang-format on +}; + +} // namespace qs::bluetooth + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothAdapter* adapter); diff --git a/src/bluetooth/bluez.cpp b/src/bluetooth/bluez.cpp new file mode 100644 index 0000000..f2c4300 --- /dev/null +++ b/src/bluetooth/bluez.cpp @@ -0,0 +1,168 @@ +#include "bluez.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "../dbus/dbus_objectmanager_types.hpp" +#include "../dbus/objectmanager.hpp" +#include "adapter.hpp" +#include "device.hpp" + +namespace qs::bluetooth { + +namespace { +QS_LOGGING_CATEGORY(logBluetooth, "quickshell.bluetooth", QtWarningMsg); +} + +Bluez* Bluez::instance() { + static auto* instance = new Bluez(); + return instance; +} + +Bluez::Bluez() { this->init(); } + +void Bluez::updateDefaultAdapter() { + const auto& adapters = this->mAdapters.valueList(); + this->bDefaultAdapter = adapters.empty() ? nullptr : adapters.first(); +} + +void Bluez::init() { + qCDebug(logBluetooth) << "Connecting to BlueZ"; + + auto bus = QDBusConnection::systemBus(); + + if (!bus.isConnected()) { + qCWarning(logBluetooth) << "Could not connect to DBus. Bluetooth integration is not available."; + return; + } + + this->objectManager = new qs::dbus::DBusObjectManager(this); + + QObject::connect( + this->objectManager, + &qs::dbus::DBusObjectManager::interfacesAdded, + this, + &Bluez::onInterfacesAdded + ); + + QObject::connect( + this->objectManager, + &qs::dbus::DBusObjectManager::interfacesRemoved, + this, + &Bluez::onInterfacesRemoved + ); + + if (!this->objectManager->setInterface("org.bluez", "/", bus)) { + qCDebug(logBluetooth) << "BlueZ is not running. Bluetooth integration will not work."; + return; + } +} + +void Bluez::onInterfacesAdded( + const QDBusObjectPath& path, + const DBusObjectManagerInterfaces& interfaces +) { + if (auto* adapter = this->mAdapterMap.value(path.path())) { + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + adapter->addInterface(interface, properties); + } + } else if (auto* device = this->mDeviceMap.value(path.path())) { + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + device->addInterface(interface, properties); + } + } else if (interfaces.contains("org.bluez.Adapter1")) { + auto* adapter = new BluetoothAdapter(path.path(), this); + + if (!adapter->isValid()) { + qCWarning(logBluetooth) << "Adapter path is not valid, cannot track: " << device; + delete adapter; + return; + } + + qCDebug(logBluetooth) << "Tracked new adapter" << adapter; + + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + adapter->addInterface(interface, properties); + } + + for (auto* device: this->mDevices.valueList()) { + if (device->adapterPath() == path) { + adapter->devices()->insertObject(device); + qCDebug(logBluetooth) << "Added tracked device" << device << "to new adapter" << adapter; + emit device->adapterChanged(); + } + } + + this->mAdapterMap.insert(path.path(), adapter); + this->mAdapters.insertObject(adapter); + this->updateDefaultAdapter(); + } else if (interfaces.contains("org.bluez.Device1")) { + auto* device = new BluetoothDevice(path.path(), this); + + if (!device->isValid()) { + qCWarning(logBluetooth) << "Device path is not valid, cannot track: " << device; + delete device; + return; + } + + qCDebug(logBluetooth) << "Tracked new device" << device; + + for (const auto& [interface, properties]: interfaces.asKeyValueRange()) { + device->addInterface(interface, properties); + } + + if (auto* adapter = device->adapter()) { + adapter->devices()->insertObject(device); + qCDebug(logBluetooth) << "Added device" << device << "to adapter" << adapter; + } + + this->mDeviceMap.insert(path.path(), device); + this->mDevices.insertObject(device); + } +} + +void Bluez::onInterfacesRemoved(const QDBusObjectPath& path, const QStringList& interfaces) { + if (auto* adapter = this->mAdapterMap.value(path.path())) { + if (interfaces.contains("org.bluez.Adapter1")) { + qCDebug(logBluetooth) << "Adapter removed:" << adapter; + + this->mAdapterMap.remove(path.path()); + this->mAdapters.removeObject(adapter); + this->updateDefaultAdapter(); + delete adapter; + } + } else if (auto* device = this->mDeviceMap.value(path.path())) { + if (interfaces.contains("org.bluez.Device1")) { + qCDebug(logBluetooth) << "Device removed:" << device; + + if (auto* adapter = device->adapter()) { + adapter->devices()->removeObject(device); + } + + this->mDeviceMap.remove(path.path()); + this->mDevices.removeObject(device); + delete device; + } else { + for (const auto& interface: interfaces) { + device->removeInterface(interface); + } + } + } +} + +BluezQml::BluezQml() { + QObject::connect( + Bluez::instance(), + &Bluez::defaultAdapterChanged, + this, + &BluezQml::defaultAdapterChanged + ); +} + +} // namespace qs::bluetooth diff --git a/src/bluetooth/bluez.hpp b/src/bluetooth/bluez.hpp new file mode 100644 index 0000000..9d7c93c --- /dev/null +++ b/src/bluetooth/bluez.hpp @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/model.hpp" +#include "../dbus/dbus_objectmanager_types.hpp" +#include "../dbus/objectmanager.hpp" + +namespace qs::bluetooth { + +class BluetoothAdapter; +class BluetoothDevice; + +class Bluez: public QObject { + Q_OBJECT; + +public: + [[nodiscard]] ObjectModel* adapters() { return &this->mAdapters; } + [[nodiscard]] ObjectModel* devices() { return &this->mDevices; } + + [[nodiscard]] BluetoothAdapter* adapter(const QString& path) { + return this->mAdapterMap.value(path); + } + + static Bluez* instance(); + +signals: + void defaultAdapterChanged(); + +private slots: + void + onInterfacesAdded(const QDBusObjectPath& path, const DBusObjectManagerInterfaces& interfaces); + void onInterfacesRemoved(const QDBusObjectPath& path, const QStringList& interfaces); + void updateDefaultAdapter(); + +private: + explicit Bluez(); + void init(); + + qs::dbus::DBusObjectManager* objectManager = nullptr; + QHash mAdapterMap; + QHash mDeviceMap; + ObjectModel mAdapters {this}; + ObjectModel mDevices {this}; + +public: + Q_OBJECT_BINDABLE_PROPERTY( + Bluez, + BluetoothAdapter*, + bDefaultAdapter, + &Bluez::defaultAdapterChanged + ); +}; + +///! Bluetooth manager +/// Provides access to bluetooth devices and adapters. +class BluezQml: public QObject { + Q_OBJECT; + QML_NAMED_ELEMENT(Bluetooth); + QML_SINGLETON; + // clang-format off + /// The default bluetooth adapter. Usually there is only one. + Q_PROPERTY(BluetoothAdapter* defaultAdapter READ default NOTIFY defaultAdapterChanged BINDABLE bindableDefaultAdapter); + QSDOC_TYPE_OVERRIDE(ObjectModel*); + /// A list of all bluetooth adapters. See @@defaultAdapter for the default. + Q_PROPERTY(UntypedObjectModel* adapters READ adapters CONSTANT); + QSDOC_TYPE_OVERRIDE(ObjectModel*); + /// A list of all connected bluetooth devices across all adapters. + /// See @@BluetoothAdapter.devices for the devices connected to a single adapter. + Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT); + // clang-format on + +signals: + void defaultAdapterChanged(); + +public: + explicit BluezQml(); + + [[nodiscard]] static ObjectModel* adapters() { + return Bluez::instance()->adapters(); + } + + [[nodiscard]] static ObjectModel* devices() { + return Bluez::instance()->devices(); + } + + [[nodiscard]] static QBindable bindableDefaultAdapter() { + return &Bluez::instance()->bDefaultAdapter; + } +}; + +} // namespace qs::bluetooth diff --git a/src/bluetooth/device.cpp b/src/bluetooth/device.cpp new file mode 100644 index 0000000..b140aa0 --- /dev/null +++ b/src/bluetooth/device.cpp @@ -0,0 +1,318 @@ +#include "device.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "../dbus/properties.hpp" +#include "adapter.hpp" +#include "bluez.hpp" +#include "dbus_device.h" + +namespace qs::bluetooth { + +namespace { +QS_LOGGING_CATEGORY(logDevice, "quickshell.bluetooth.device", QtWarningMsg); +} + +QString BluetoothDeviceState::toString(BluetoothDeviceState::Enum state) { + switch (state) { + case BluetoothDeviceState::Disconnected: return QStringLiteral("Disconnected"); + case BluetoothDeviceState::Connected: return QStringLiteral("Connected"); + case BluetoothDeviceState::Disconnecting: return QStringLiteral("Disconnecting"); + case BluetoothDeviceState::Connecting: return QStringLiteral("Connecting"); + default: return QStringLiteral("Unknown"); + } +} + +BluetoothDevice::BluetoothDevice(const QString& path, QObject* parent): QObject(parent) { + this->mInterface = + new DBusBluezDeviceInterface("org.bluez", path, QDBusConnection::systemBus(), this); + + if (!this->mInterface->isValid()) { + qCWarning(logDevice) << "Could not create DBus interface for device at" << path; + delete this->mInterface; + this->mInterface = nullptr; + return; + } + + this->properties.setInterface(this->mInterface); +} + +BluetoothAdapter* BluetoothDevice::adapter() const { + return Bluez::instance()->adapter(this->bAdapterPath.value().path()); +} + +void BluetoothDevice::setConnected(bool connected) { + if (connected == this->bConnected) return; + + if (connected) { + this->connect(); + } else { + this->disconnect(); + } +} + +void BluetoothDevice::setTrusted(bool trusted) { + if (trusted == this->bTrusted) return; + this->bTrusted = trusted; + this->pTrusted.write(); +} + +void BluetoothDevice::setBlocked(bool blocked) { + if (blocked == this->bBlocked) return; + this->bBlocked = blocked; + this->pBlocked.write(); +} + +void BluetoothDevice::setName(const QString& name) { + if (name == this->bName) return; + this->bName = name; + this->pName.write(); +} + +void BluetoothDevice::setWakeAllowed(bool wakeAllowed) { + if (wakeAllowed == this->bWakeAllowed) return; + this->bWakeAllowed = wakeAllowed; + this->pWakeAllowed.write(); +} + +void BluetoothDevice::connect() { + if (this->bConnected) { + qCCritical(logDevice) << "Device" << this << "is already connected"; + return; + } + + if (this->bState == BluetoothDeviceState::Connecting) { + qCCritical(logDevice) << "Device" << this << "is already connecting"; + return; + } + + qCDebug(logDevice) << "Connecting to device" << this; + this->bState = BluetoothDeviceState::Connecting; + + auto reply = this->mInterface->Connect(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to connect to device " << this << ": " << reply.error().message(); + + this->bState = this->bConnected ? BluetoothDeviceState::Connected + : BluetoothDeviceState::Disconnected; + } else { + qCDebug(logDevice) << "Successfully connected to to device" << this; + } + + delete watcher; + } + ); +} + +void BluetoothDevice::disconnect() { + if (!this->bConnected) { + qCCritical(logDevice) << "Device" << this << "is already disconnected"; + return; + } + + if (this->bState == BluetoothDeviceState::Disconnecting) { + qCCritical(logDevice) << "Device" << this << "is already disconnecting"; + return; + } + + qCDebug(logDevice) << "Disconnecting from device" << this; + this->bState = BluetoothDeviceState::Disconnecting; + + auto reply = this->mInterface->Disconnect(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to disconnect from device " << this << ": " << reply.error().message(); + + this->bState = this->bConnected ? BluetoothDeviceState::Connected + : BluetoothDeviceState::Disconnected; + } else { + qCDebug(logDevice) << "Successfully disconnected from from device" << this; + } + + delete watcher; + } + ); +} + +void BluetoothDevice::pair() { + if (this->bPaired) { + qCCritical(logDevice) << "Device" << this << "is already paired"; + return; + } + + if (this->bPairing) { + qCCritical(logDevice) << "Device" << this << "is already pairing"; + return; + } + + qCDebug(logDevice) << "Pairing with device" << this; + this->bPairing = true; + + auto reply = this->mInterface->Pair(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + if (reply.isError()) { + qCWarning(logDevice).nospace() + << "Failed to pair with device " << this << ": " << reply.error().message(); + } else { + qCDebug(logDevice) << "Successfully initiated pairing with device" << this; + } + + this->bPairing = false; + delete watcher; + } + ); +} + +void BluetoothDevice::cancelPair() { + if (!this->bPairing) { + qCCritical(logDevice) << "Device" << this << "is not currently pairing"; + return; + } + + qCDebug(logDevice) << "Cancelling pairing with device" << this; + + auto reply = this->mInterface->CancelPairing(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply<> reply = *watcher; + if (reply.isError()) { + qCWarning(logDevice) << "Failed to cancel pairing with device" << this << ":" + << reply.error().message(); + } else { + qCDebug(logDevice) << "Successfully cancelled pairing with device" << this; + } + + this->bPairing = false; + delete watcher; + } + ); +} + +void BluetoothDevice::forget() { + if (!this->mInterface || !this->mInterface->isValid()) { + qCCritical(logDevice) << "Cannot forget - device interface is invalid"; + return; + } + + if (auto* adapter = Bluez::instance()->adapter(this->bAdapterPath.value().path())) { + qCDebug(logDevice) << "Forgetting device" << this << "via adapter" << adapter; + adapter->removeDevice(this->path()); + } else { + qCCritical(logDevice) << "Could not find adapter for path" << this->bAdapterPath.value().path() + << "to forget from"; + } +} + +void BluetoothDevice::addInterface(const QString& interface, const QVariantMap& properties) { + if (interface == "org.bluez.Device1") { + this->properties.updatePropertySet(properties, false); + qCDebug(logDevice) << "Updated Device properties for" << this; + } else if (interface == "org.bluez.Battery1") { + if (!this->mBatteryInterface) { + this->mBatteryInterface = new QDBusInterface( + "org.bluez", + this->path(), + "org.bluez.Battery1", + QDBusConnection::systemBus(), + this + ); + + if (!this->mBatteryInterface->isValid()) { + qCWarning(logDevice) << "Could not create Battery interface for device at" << this; + delete this->mBatteryInterface; + this->mBatteryInterface = nullptr; + return; + } + } + + this->batteryProperties.setInterface(this->mBatteryInterface); + this->batteryProperties.updatePropertySet(properties, false); + + emit this->batteryAvailableChanged(); + qCDebug(logDevice) << "Updated Battery properties for" << this; + } +} + +void BluetoothDevice::removeInterface(const QString& interface) { + if (interface == "org.bluez.Battery1" && this->mBatteryInterface) { + this->batteryProperties.setInterface(nullptr); + delete this->mBatteryInterface; + this->mBatteryInterface = nullptr; + this->bBattery = 0; + + emit this->batteryAvailableChanged(); + qCDebug(logDevice) << "Battery interface removed from device" << this; + } +} + +void BluetoothDevice::onConnectedChanged() { + this->bState = + this->bConnected ? BluetoothDeviceState::Connected : BluetoothDeviceState::Disconnected; + emit this->connectedChanged(); +} + +} // namespace qs::bluetooth + +namespace qs::dbus { + +using namespace qs::bluetooth; + +DBusResult DBusDataTransform::fromWire(quint8 percentage) { + return DBusResult(percentage * 0.01); +} + +} // namespace qs::dbus + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothDevice* device) { + auto saver = QDebugStateSaver(debug); + + if (device) { + debug.nospace() << "BluetoothDevice(" << static_cast(device) + << ", path=" << device->path() << ")"; + } else { + debug << "BluetoothDevice(nullptr)"; + } + + return debug; +} diff --git a/src/bluetooth/device.hpp b/src/bluetooth/device.hpp new file mode 100644 index 0000000..23f230f --- /dev/null +++ b/src/bluetooth/device.hpp @@ -0,0 +1,225 @@ +#pragma once + +#include +#include +#include +#include + +#include "../dbus/properties.hpp" +#include "dbus_device.h" + +namespace qs::bluetooth { + +///! Connection state of a Bluetooth device. +class BluetoothDeviceState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// The device is not connected. + Disconnected = 0, + /// The device is connected. + Connected = 1, + /// The device is disconnecting. + Disconnecting = 2, + /// The device is connecting. + Connecting = 3, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(BluetoothDeviceState::Enum state); +}; + +struct BatteryPercentage {}; + +} // namespace qs::bluetooth + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = quint8; + using Data = qreal; + static DBusResult fromWire(Wire percentage); +}; + +} // namespace qs::dbus + +namespace qs::bluetooth { + +class BluetoothAdapter; + +///! A tracked Bluetooth device. +class BluetoothDevice: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + // clang-format off + /// MAC address of the device. + Q_PROPERTY(QString address READ default NOTIFY addressChanged BINDABLE bindableAddress); + /// The name of the Bluetooth device. This property may be written to create an alias, or set to + /// an empty string to fall back to the device provided name. + /// + /// See @@deviceName for the name provided by the device. + Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged); + /// The name of the Bluetooth device, ignoring user provided aliases. See also @@name + /// which returns a user provided alias if set. + Q_PROPERTY(QString deviceName READ default NOTIFY deviceNameChanged BINDABLE bindableDeviceName); + /// System icon representing the device type. Use @@Quickshell.Quickshell.iconPath() to display this in an image. + Q_PROPERTY(QString icon READ default NOTIFY iconChanged BINDABLE bindableIcon); + /// Connection state of the device. + Q_PROPERTY(BluetoothDeviceState::Enum state READ default NOTIFY stateChanged BINDABLE bindableState); + /// True if the device is currently connected to the computer. + /// + /// Setting this property is equivalent to calling @@connect() and @@disconnect(). + /// + /// > [!NOTE] @@state provides more detailed information if required. + Q_PROPERTY(bool connected READ connected WRITE setConnected NOTIFY connectedChanged); + /// True if the device is paired to the computer. + /// + /// > [!NOTE] @@pair() can be used to pair a device, however you must @@forget() the device to unpair it. + Q_PROPERTY(bool paired READ default NOTIFY pairedChanged BINDABLE bindablePaired); + /// True if pairing information is stored for future connections. + Q_PROPERTY(bool bonded READ default NOTIFY bondedChanged BINDABLE bindableBonded); + /// True if the device is currently being paired. + /// + /// > [!NOTE] @@cancelPair() can be used to cancel the pairing process. + Q_PROPERTY(bool pairing READ pairing NOTIFY pairingChanged); + /// True if the device is considered to be trusted by the system. + /// Trusted devices are allowed to reconnect themselves to the system without intervention. + Q_PROPERTY(bool trusted READ trusted WRITE setTrusted NOTIFY trustedChanged); + /// True if the device is blocked from connecting. + /// If a device is blocked, any connection attempts will be immediately rejected by the system. + Q_PROPERTY(bool blocked READ blocked WRITE setBlocked NOTIFY blockedChanged); + /// True if the device is allowed to wake up the host system from suspend. + Q_PROPERTY(bool wakeAllowed READ wakeAllowed WRITE setWakeAllowed NOTIFY wakeAllowedChanged); + /// True if the connected device reports its battery level. Battery level can be accessed via @@battery. + Q_PROPERTY(bool batteryAvailable READ batteryAvailable NOTIFY batteryAvailableChanged); + /// Battery level of the connected device, from `0.0` to `1.0`. Only valid if @@batteryAvailable is true. + Q_PROPERTY(qreal battery READ default NOTIFY batteryChanged BINDABLE bindableBattery); + /// The Bluetooth adapter this device belongs to. + Q_PROPERTY(BluetoothAdapter* adapter READ adapter NOTIFY adapterChanged); + /// DBus path of the device under the `org.bluez` system service. + Q_PROPERTY(QString dbusPath READ path CONSTANT); + // clang-format on + +public: + explicit BluetoothDevice(const QString& path, QObject* parent = nullptr); + + /// Attempt to connect to the device. + Q_INVOKABLE void connect(); + /// Disconnect from the device. + Q_INVOKABLE void disconnect(); + /// Attempt to pair the device. + /// + /// > [!NOTE] @@paired and @@pairing return the current pairing status of the device. + Q_INVOKABLE void pair(); + /// Cancel an active pairing attempt. + Q_INVOKABLE void cancelPair(); + /// Forget the device. + Q_INVOKABLE void forget(); + + [[nodiscard]] bool isValid() const { return this->mInterface && this->mInterface->isValid(); } + [[nodiscard]] QString path() const { + return this->mInterface ? this->mInterface->path() : QString(); + } + + [[nodiscard]] bool batteryAvailable() const { return this->mBatteryInterface != nullptr; } + [[nodiscard]] BluetoothAdapter* adapter() const; + [[nodiscard]] QDBusObjectPath adapterPath() const { return this->bAdapterPath.value(); } + + [[nodiscard]] bool connected() const { return this->bConnected; } + void setConnected(bool connected); + + [[nodiscard]] bool trusted() const { return this->bTrusted; } + void setTrusted(bool trusted); + + [[nodiscard]] bool blocked() const { return this->bBlocked; } + void setBlocked(bool blocked); + + [[nodiscard]] QString name() const { return this->bName; } + void setName(const QString& name); + + [[nodiscard]] bool wakeAllowed() const { return this->bWakeAllowed; } + void setWakeAllowed(bool wakeAllowed); + + [[nodiscard]] bool pairing() const { return this->bPairing; } + + [[nodiscard]] QBindable bindableAddress() { return &this->bAddress; } + [[nodiscard]] QBindable bindableDeviceName() { return &this->bDeviceName; } + [[nodiscard]] QBindable bindableName() { return &this->bName; } + [[nodiscard]] QBindable bindableConnected() { return &this->bConnected; } + [[nodiscard]] QBindable bindablePaired() { return &this->bPaired; } + [[nodiscard]] QBindable bindableBonded() { return &this->bBonded; } + [[nodiscard]] QBindable bindableTrusted() { return &this->bTrusted; } + [[nodiscard]] QBindable bindableBlocked() { return &this->bBlocked; } + [[nodiscard]] QBindable bindableWakeAllowed() { return &this->bWakeAllowed; } + [[nodiscard]] QBindable bindableIcon() { return &this->bIcon; } + [[nodiscard]] QBindable bindableBattery() { return &this->bBattery; } + [[nodiscard]] QBindable bindableState() { return &this->bState; } + + void addInterface(const QString& interface, const QVariantMap& properties); + void removeInterface(const QString& interface); + +signals: + void addressChanged(); + void deviceNameChanged(); + void nameChanged(); + void connectedChanged(); + void stateChanged(); + void pairedChanged(); + void bondedChanged(); + void pairingChanged(); + void trustedChanged(); + void blockedChanged(); + void wakeAllowedChanged(); + void iconChanged(); + void batteryAvailableChanged(); + void batteryChanged(); + void adapterChanged(); + +private: + void onConnectedChanged(); + + DBusBluezDeviceInterface* mInterface = nullptr; + QDBusInterface* mBatteryInterface = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bAddress, &BluetoothDevice::addressChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bDeviceName, &BluetoothDevice::deviceNameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bName, &BluetoothDevice::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bConnected, &BluetoothDevice::onConnectedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bPaired, &BluetoothDevice::pairedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bBonded, &BluetoothDevice::bondedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bTrusted, &BluetoothDevice::trustedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bBlocked, &BluetoothDevice::blockedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bWakeAllowed, &BluetoothDevice::wakeAllowedChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QString, bIcon, &BluetoothDevice::iconChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, QDBusObjectPath, bAdapterPath); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, qreal, bBattery, &BluetoothDevice::batteryChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, BluetoothDeviceState::Enum, bState, &BluetoothDevice::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(BluetoothDevice, bool, bPairing, &BluetoothDevice::pairingChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothDevice, properties); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pAddress, bAddress, properties, "Address"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pDeviceName, bDeviceName, properties, "Name"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pName, bName, properties, "Alias"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pConnected, bConnected, properties, "Connected"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pPaired, bPaired, properties, "Paired"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pBonded, bBonded, properties, "Bonded"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pTrusted, bTrusted, properties, "Trusted"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pBlocked, bBlocked, properties, "Blocked"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pWakeAllowed, bWakeAllowed, properties, "WakeAllowed"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pIcon, bIcon, properties, "Icon"); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, pAdapterPath, bAdapterPath, properties, "Adapter"); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(BluetoothDevice, batteryProperties); + QS_DBUS_PROPERTY_BINDING(BluetoothDevice, BatteryPercentage, pBattery, bBattery, batteryProperties, "Percentage", true); + // clang-format on +}; + +} // namespace qs::bluetooth + +QDebug operator<<(QDebug debug, const qs::bluetooth::BluetoothDevice* device); diff --git a/src/bluetooth/module.md b/src/bluetooth/module.md new file mode 100644 index 0000000..eb797d9 --- /dev/null +++ b/src/bluetooth/module.md @@ -0,0 +1,12 @@ +name = "Quickshell.Bluetooth" +description = "Bluetooth API" +headers = [ + "bluez.hpp", + "adapter.hpp", + "device.hpp", +] +----- +This module exposes Bluetooth management APIs provided by the BlueZ DBus interface. +Both DBus and BlueZ must be running to use it. + +See the @@Quickshell.Bluetooth.Bluetooth singleton. diff --git a/src/bluetooth/org.bluez.Adapter.xml b/src/bluetooth/org.bluez.Adapter.xml new file mode 100644 index 0000000..286991e --- /dev/null +++ b/src/bluetooth/org.bluez.Adapter.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/bluetooth/org.bluez.Device.xml b/src/bluetooth/org.bluez.Device.xml new file mode 100644 index 0000000..274e9fd --- /dev/null +++ b/src/bluetooth/org.bluez.Device.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/bluetooth/test/manual/test.qml b/src/bluetooth/test/manual/test.qml new file mode 100644 index 0000000..21c53b1 --- /dev/null +++ b/src/bluetooth/test/manual/test.qml @@ -0,0 +1,200 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Bluetooth + +FloatingWindow { + color: contentItem.palette.window + + ListView { + anchors.fill: parent + anchors.margins: 5 + model: Bluetooth.adapters + + delegate: WrapperRectangle { + width: parent.width + color: "transparent" + border.color: palette.button + border.width: 1 + margin: 5 + + ColumnLayout { + Label { text: `Adapter: ${modelData.name} (${modelData.adapterId})` } + + RowLayout { + Layout.fillWidth: true + + CheckBox { + text: "Enable" + checked: modelData.enabled + onToggled: modelData.enabled = checked + } + + Label { + color: modelData.state === BluetoothAdapterState.Blocked ? palette.errorText : palette.placeholderText + text: BluetoothAdapterState.toString(modelData.state) + } + + CheckBox { + text: "Discoverable" + checked: modelData.discoverable + onToggled: modelData.discoverable = checked + } + + CheckBox { + text: "Discovering" + checked: modelData.discovering + onToggled: modelData.discovering = checked + } + + CheckBox { + text: "Pairable" + checked: modelData.pairable + onToggled: modelData.pairable = checked + } + } + + RowLayout { + Layout.fillWidth: true + + Label { text: "Discoverable timeout:" } + + SpinBox { + from: 0 + to: 3600 + value: modelData.discoverableTimeout + onValueModified: modelData.discoverableTimeout = value + textFromValue: time => time === 0 ? "∞" : time + "s" + } + + Label { text: "Pairable timeout:" } + + SpinBox { + from: 0 + to: 3600 + value: modelData.pairableTimeout + onValueModified: modelData.pairableTimeout = value + textFromValue: time => time === 0 ? "∞" : time + "s" + } + } + + Repeater { + model: modelData.devices + + WrapperRectangle { + Layout.fillWidth: true + color: palette.button + border.color: palette.mid + border.width: 1 + margin: 5 + + RowLayout { + ColumnLayout { + Layout.fillWidth: true + + RowLayout { + IconImage { + Layout.fillHeight: true + implicitWidth: height + source: Quickshell.iconPath(modelData.icon) + } + + TextField { + text: modelData.name + font.bold: true + background: null + readOnly: false + selectByMouse: true + onEditingFinished: modelData.name = text + } + + Label { + visible: modelData.name && modelData.name !== modelData.deviceName + text: `(${modelData.deviceName})` + color: palette.placeholderText + } + } + + RowLayout { + Label { + text: modelData.address + color: palette.placeholderText + } + + Label { + visible: modelData.batteryAvailable + text: `| Battery: ${Math.round(modelData.battery * 100)}%` + color: palette.placeholderText + } + } + + RowLayout { + Label { + text: BluetoothDeviceState.toString(modelData.state) + + color: modelData.connected ? palette.link : palette.placeholderText + } + + Label { + text: modelData.pairing ? "Pairing" : (modelData.paired ? "Paired" : "Not Paired") + color: modelData.paired || modelData.pairing ? palette.link : palette.placeholderText + } + + Label { + visible: modelData.bonded + text: "| Bonded" + color: palette.link + } + + CheckBox { + text: "Trusted" + checked: modelData.trusted + onToggled: modelData.trusted = checked + } + + CheckBox { + text: "Blocked" + checked: modelData.blocked + onToggled: modelData.blocked = checked + } + + CheckBox { + text: "Wake Allowed" + checked: modelData.wakeAllowed + onToggled: modelData.wakeAllowed = checked + } + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignRight + + Button { + Layout.alignment: Qt.AlignRight + text: modelData.connected ? "Disconnect" : "Connect" + onClicked: modelData.connected = !modelData.connected + } + + Button { + Layout.alignment: Qt.AlignRight + text: modelData.pairing ? "Cancel" : (modelData.paired ? "Forget" : "Pair") + onClicked: { + if (modelData.pairing) { + modelData.cancelPair(); + } else if (modelData.paired) { + modelData.forget(); + } else { + modelData.pair(); + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/build/CMakeLists.txt b/src/build/CMakeLists.txt index bb35da9..c1ffa59 100644 --- a/src/build/CMakeLists.txt +++ b/src/build/CMakeLists.txt @@ -9,16 +9,10 @@ if (NOT DEFINED GIT_REVISION) ) endif() -if (CRASH_REPORTER) - set(CRASH_REPORTER_DEF 1) +if (CRASH_HANDLER) + set(CRASH_HANDLER_DEF 1) else() - set(CRASH_REPORTER_DEF 0) -endif() - -if (DISTRIBUTOR_DEBUGINFO_AVAILABLE) - set(DEBUGINFO_AVAILABLE 1) -else() - set(DEBUGINFO_AVAILABLE 0) + set(CRASH_HANDLER_DEF 0) endif() configure_file(build.hpp.in build.hpp @ONLY ESCAPE_QUOTES) diff --git a/src/build/build.hpp.in b/src/build/build.hpp.in index 075abd1..acc3c58 100644 --- a/src/build/build.hpp.in +++ b/src/build/build.hpp.in @@ -1,12 +1,17 @@ #pragma once // NOLINTBEGIN +#define QS_VERSION "@quickshell_VERSION@" +#define QS_VERSION_MAJOR @quickshell_VERSION_MAJOR@ +#define QS_VERSION_MINOR @quickshell_VERSION_MINOR@ +#define QS_VERSION_PATCH @quickshell_VERSION_PATCH@ +#define QS_UNRELEASED_FEATURES "@UNRELEASED_FEATURES@" #define GIT_REVISION "@GIT_REVISION@" #define DISTRIBUTOR "@DISTRIBUTOR@" -#define DISTRIBUTOR_DEBUGINFO_AVAILABLE @DEBUGINFO_AVAILABLE@ -#define CRASH_REPORTER @CRASH_REPORTER_DEF@ +#define CRASH_HANDLER @CRASH_HANDLER_DEF@ #define BUILD_TYPE "@CMAKE_BUILD_TYPE@" #define COMPILER "@CMAKE_CXX_COMPILER_ID@ (@CMAKE_CXX_COMPILER_VERSION@)" #define COMPILE_FLAGS "@CMAKE_CXX_FLAGS@" #define BUILD_CONFIGURATION "@QS_BUILD_OPTIONS@" +#define CRASHREPORT_URL "@CRASHREPORT_URL@" // NOLINTEND diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index eca7270..4824965 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,3 +1,4 @@ +pkg_check_modules(libdrm REQUIRED IMPORTED_TARGET libdrm) qt_add_library(quickshell-core STATIC plugin.cpp shell.cpp @@ -12,6 +13,7 @@ qt_add_library(quickshell-core STATIC singleton.cpp generation.cpp scan.cpp + scanenv.cpp qsintercept.cpp incubator.cpp lazyloader.cpp @@ -23,7 +25,7 @@ qt_add_library(quickshell-core STATIC model.cpp elapsedtimer.cpp desktopentry.cpp - objectrepeater.cpp + desktopentrymonitor.cpp platformmenu.cpp qsmenu.cpp retainable.cpp @@ -38,6 +40,9 @@ qt_add_library(quickshell-core STATIC iconprovider.cpp scriptmodel.cpp colorquantizer.cpp + toolsupport.cpp + streamreader.cpp + debuginfo.cpp ) qt_add_qml_module(quickshell-core @@ -50,7 +55,7 @@ qt_add_qml_module(quickshell-core install_qml_module(quickshell-core) -target_link_libraries(quickshell-core PRIVATE Qt::Quick Qt::Widgets) +target_link_libraries(quickshell-core PRIVATE Qt::Quick Qt::QuickPrivate Qt::Widgets quickshell-build PkgConfig::libdrm) qs_module_pch(quickshell-core SET large) diff --git a/src/core/colorquantizer.cpp b/src/core/colorquantizer.cpp index 667942c..d983f76 100644 --- a/src/core/colorquantizer.cpp +++ b/src/core/colorquantizer.cpp @@ -13,39 +13,55 @@ #include #include #include +#include #include #include #include #include +#include "logcat.hpp" + namespace { -Q_LOGGING_CATEGORY(logColorQuantizer, "quickshell.colorquantizer", QtWarningMsg); +QS_LOGGING_CATEGORY(logColorQuantizer, "quickshell.colorquantizer", QtWarningMsg); } -ColorQuantizerOperation::ColorQuantizerOperation(QUrl* source, qreal depth, qreal rescaleSize) +ColorQuantizerOperation::ColorQuantizerOperation( + QUrl* source, + qreal depth, + QRect imageRect, + qreal rescaleSize +) : source(source) , maxDepth(depth) + , imageRect(imageRect) , rescaleSize(rescaleSize) { - setAutoDelete(false); + this->setAutoDelete(false); } void ColorQuantizerOperation::quantizeImage(const QAtomicInteger& shouldCancel) { - if (shouldCancel.loadAcquire() || source->isEmpty()) return; + if (shouldCancel.loadAcquire() || this->source->isEmpty()) return; - colors.clear(); + this->colors.clear(); - auto image = QImage(source->toLocalFile()); - if ((image.width() > rescaleSize || image.height() > rescaleSize) && rescaleSize > 0) { + auto image = QImage(this->source->toLocalFile()); + + if (this->imageRect.isValid()) { + image = image.copy(this->imageRect); + } + + if ((image.width() > this->rescaleSize || image.height() > this->rescaleSize) + && this->rescaleSize > 0) + { image = image.scaled( - static_cast(rescaleSize), - static_cast(rescaleSize), + static_cast(this->rescaleSize), + static_cast(this->rescaleSize), Qt::KeepAspectRatio, Qt::SmoothTransformation ); } if (image.isNull()) { - qCWarning(logColorQuantizer) << "Failed to load image from" << source; + qCWarning(logColorQuantizer) << "Failed to load image from" << this->source->toString(); return; } @@ -61,7 +77,7 @@ void ColorQuantizerOperation::quantizeImage(const QAtomicInteger& shouldCa auto startTime = QDateTime::currentDateTime(); - colors = quantization(pixels, 0); + this->colors = this->quantization(pixels, 0); auto endTime = QDateTime::currentDateTime(); auto milliseconds = startTime.msecsTo(endTime); @@ -75,7 +91,7 @@ QList ColorQuantizerOperation::quantization( ) { if (shouldCancel.loadAcquire()) return QList(); - if (depth >= maxDepth || rgbValues.isEmpty()) { + if (depth >= this->maxDepth || rgbValues.isEmpty()) { if (rgbValues.isEmpty()) return QList(); auto totalR = 0; @@ -112,8 +128,8 @@ QList ColorQuantizerOperation::quantization( auto rightHalf = rgbValues.mid(mid); QList result; - result.append(quantization(leftHalf, depth + 1)); - result.append(quantization(rightHalf, depth + 1)); + result.append(this->quantization(leftHalf, depth + 1)); + result.append(this->quantization(rightHalf, depth + 1)); return result; } @@ -157,7 +173,7 @@ void ColorQuantizerOperation::finishRun() { } void ColorQuantizerOperation::finished() { - emit this->done(colors); + emit this->done(this->colors); delete this; } @@ -176,39 +192,50 @@ void ColorQuantizerOperation::run() { void ColorQuantizerOperation::tryCancel() { this->shouldCancel.storeRelease(true); } void ColorQuantizer::componentComplete() { - componentCompleted = true; - if (!mSource.isEmpty()) quantizeAsync(); + this->componentCompleted = true; + if (!this->mSource.isEmpty()) this->quantizeAsync(); } void ColorQuantizer::setSource(const QUrl& source) { - if (mSource != source) { - mSource = source; + if (this->mSource != source) { + this->mSource = source; emit this->sourceChanged(); - if (this->componentCompleted && !mSource.isEmpty()) quantizeAsync(); + if (this->componentCompleted && !this->mSource.isEmpty()) this->quantizeAsync(); } } void ColorQuantizer::setDepth(qreal depth) { - if (mDepth != depth) { - mDepth = depth; + if (this->mDepth != depth) { + this->mDepth = depth; emit this->depthChanged(); - if (this->componentCompleted) quantizeAsync(); + if (this->componentCompleted && !this->mSource.isEmpty()) this->quantizeAsync(); } } +void ColorQuantizer::setImageRect(QRect imageRect) { + if (this->mImageRect != imageRect) { + this->mImageRect = imageRect; + emit this->imageRectChanged(); + + if (this->componentCompleted && !this->mSource.isEmpty()) this->quantizeAsync(); + } +} + +void ColorQuantizer::resetImageRect() { this->setImageRect(QRect()); } + void ColorQuantizer::setRescaleSize(int rescaleSize) { - if (mRescaleSize != rescaleSize) { - mRescaleSize = rescaleSize; + if (this->mRescaleSize != rescaleSize) { + this->mRescaleSize = rescaleSize; emit this->rescaleSizeChanged(); - if (this->componentCompleted) quantizeAsync(); + if (this->componentCompleted && !this->mSource.isEmpty()) this->quantizeAsync(); } } void ColorQuantizer::operationFinished(const QList& result) { - bColors = result; + this->bColors = result; this->liveOperation = nullptr; emit this->colorsChanged(); } @@ -217,7 +244,13 @@ void ColorQuantizer::quantizeAsync() { if (this->liveOperation) this->cancelAsync(); qCDebug(logColorQuantizer) << "Starting color quantization asynchronously"; - this->liveOperation = new ColorQuantizerOperation(&mSource, mDepth, mRescaleSize); + + this->liveOperation = new ColorQuantizerOperation( + &this->mSource, + this->mDepth, + this->mImageRect, + this->mRescaleSize + ); QObject::connect( this->liveOperation, diff --git a/src/core/colorquantizer.hpp b/src/core/colorquantizer.hpp index d35a15a..0159181 100644 --- a/src/core/colorquantizer.hpp +++ b/src/core/colorquantizer.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -16,7 +17,7 @@ class ColorQuantizerOperation Q_OBJECT; public: - explicit ColorQuantizerOperation(QUrl* source, qreal depth, qreal rescaleSize); + explicit ColorQuantizerOperation(QUrl* source, qreal depth, QRect imageRect, qreal rescaleSize); void run() override; void tryCancel(); @@ -44,6 +45,7 @@ private: QList colors; QUrl* source; qreal maxDepth; + QRect imageRect; qreal rescaleSize; }; @@ -78,6 +80,13 @@ class ColorQuantizer /// binary split of the color space Q_PROPERTY(qreal depth READ depth WRITE setDepth NOTIFY depthChanged); + // clang-format off + /// Rectangle that the source image is cropped to. + /// + /// Can be set to `undefined` to reset. + Q_PROPERTY(QRect imageRect READ imageRect WRITE setImageRect RESET resetImageRect NOTIFY imageRectChanged); + // clang-format on + /// The size to rescale the image to, when rescaleSize is 0 then no scaling will be done. /// > [!NOTE] Results from color quantization doesn't suffer much when rescaling, it's /// > reccommended to rescale, otherwise the quantization process will take much longer. @@ -91,19 +100,24 @@ public: [[nodiscard]] QBindable> bindableColors() { return &this->bColors; } - [[nodiscard]] QUrl source() const { return mSource; } + [[nodiscard]] QUrl source() const { return this->mSource; } void setSource(const QUrl& source); - [[nodiscard]] qreal depth() const { return mDepth; } + [[nodiscard]] qreal depth() const { return this->mDepth; } void setDepth(qreal depth); - [[nodiscard]] qreal rescaleSize() const { return mRescaleSize; } + [[nodiscard]] QRect imageRect() const { return this->mImageRect; } + void setImageRect(QRect imageRect); + void resetImageRect(); + + [[nodiscard]] qreal rescaleSize() const { return this->mRescaleSize; } void setRescaleSize(int rescaleSize); signals: void colorsChanged(); void sourceChanged(); void depthChanged(); + void imageRectChanged(); void rescaleSizeChanged(); public slots: @@ -117,6 +131,7 @@ private: ColorQuantizerOperation* liveOperation = nullptr; QUrl mSource; qreal mDepth = 0; + QRect mImageRect; qreal mRescaleSize = 0; Q_OBJECT_BINDABLE_PROPERTY( diff --git a/src/core/common.cpp b/src/core/common.cpp index 5928e47..080019a 100644 --- a/src/core/common.cpp +++ b/src/core/common.cpp @@ -1,11 +1,9 @@ #include "common.hpp" #include -#include namespace qs { const QDateTime Common::LAUNCH_TIME = QDateTime::currentDateTime(); -QProcessEnvironment Common::INITIAL_ENVIRONMENT = {}; // NOLINT } // namespace qs diff --git a/src/core/common.hpp b/src/core/common.hpp index f2a01bc..ab8edb8 100644 --- a/src/core/common.hpp +++ b/src/core/common.hpp @@ -7,7 +7,7 @@ namespace qs { struct Common { static const QDateTime LAUNCH_TIME; - static QProcessEnvironment INITIAL_ENVIRONMENT; // NOLINT + static inline QProcessEnvironment INITIAL_ENVIRONMENT = {}; // NOLINT }; } // namespace qs diff --git a/src/core/debuginfo.cpp b/src/core/debuginfo.cpp new file mode 100644 index 0000000..abc467d --- /dev/null +++ b/src/core/debuginfo.cpp @@ -0,0 +1,176 @@ +#include "debuginfo.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "build.hpp" + +extern char** environ; // NOLINT + +namespace qs::debuginfo { + +QString qsVersion() { + return QS_VERSION " (revision " GIT_REVISION ", distributed by " DISTRIBUTOR ")"; +} + +QString qtVersion() { return qVersion() % QStringLiteral(" (built against " QT_VERSION_STR ")"); } + +QString gpuInfo() { + auto deviceCount = drmGetDevices2(0, nullptr, 0); + if (deviceCount < 0) return "Failed to get DRM device count: " % QString::number(deviceCount); + auto* devices = new drmDevicePtr[deviceCount]; + auto devicesArrayGuard = qScopeGuard([&] { delete[] devices; }); + auto r = drmGetDevices2(0, devices, deviceCount); + if (deviceCount < 0) return "Failed to get DRM devices: " % QString::number(r); + auto devicesGuard = qScopeGuard([&] { + for (auto i = 0; i != deviceCount; ++i) drmFreeDevice(&devices[i]); // NOLINT + }); + + QString info; + auto stream = QTextStream(&info); + + for (auto i = 0; i != deviceCount; ++i) { + auto* device = devices[i]; // NOLINT + + int deviceNodeType = -1; + if (device->available_nodes & (1 << DRM_NODE_RENDER)) deviceNodeType = DRM_NODE_RENDER; + else if (device->available_nodes & (1 << DRM_NODE_PRIMARY)) deviceNodeType = DRM_NODE_PRIMARY; + + if (deviceNodeType == -1) continue; + + auto* deviceNode = device->nodes[DRM_NODE_RENDER]; // NOLINT + + auto driver = [&]() -> QString { + auto fd = open(deviceNode, O_RDWR | O_CLOEXEC); + if (fd == -1) return ""; + auto fdGuard = qScopeGuard([&] { close(fd); }); + auto* ver = drmGetVersion(fd); + if (!ver) return ""; + auto verGuard = qScopeGuard([&] { drmFreeVersion(ver); }); + + // clang-format off + return QString(ver->name) + % ' ' % QString::number(ver->version_major) + % '.' % QString::number(ver->version_minor) + % '.' % QString::number(ver->version_patchlevel) + % " (" % ver->desc % ')'; + // clang-format on + }(); + + QString product = "unknown"; + QString address = "unknown"; + + auto hex = [](int num, int pad) { return QString::number(num, 16).rightJustified(pad, '0'); }; + + switch (device->bustype) { + case DRM_BUS_PCI: { + auto* b = device->businfo.pci; + auto* d = device->deviceinfo.pci; + address = "PCI " % hex(b->bus, 2) % ':' % hex(b->dev, 2) % '.' % hex(b->func, 1); + product = hex(d->vendor_id, 4) % ':' % hex(d->device_id, 4); + } break; + case DRM_BUS_USB: { + auto* b = device->businfo.usb; + auto* d = device->deviceinfo.usb; + address = "USB " % QString::number(b->bus) % ':' % QString::number(b->dev); + product = hex(d->vendor, 4) % ':' % hex(d->product, 4); + } break; + default: break; + } + + stream << "GPU " << deviceNode << "\n Driver: " << driver << "\n Model: " << product + << "\n Address: " << address << '\n'; + } + + return info; +} + +QString systemInfo() { + QString info; + auto stream = QTextStream(&info); + + stream << gpuInfo() << '\n'; + + stream << "/etc/os-release:"; + auto osReleaseFile = QFile("/etc/os-release"); + if (osReleaseFile.open(QFile::ReadOnly)) { + stream << '\n' << osReleaseFile.readAll() << '\n'; + osReleaseFile.close(); + } else { + stream << "FAILED TO OPEN\n"; + } + + stream << "/etc/lsb-release:"; + auto lsbReleaseFile = QFile("/etc/lsb-release"); + if (lsbReleaseFile.open(QFile::ReadOnly)) { + stream << '\n' << lsbReleaseFile.readAll(); + lsbReleaseFile.close(); + } else { + stream << "FAILED TO OPEN\n"; + } + + return info; +} + +QString envInfo() { + QString info; + auto stream = QTextStream(&info); + + for (auto** envp = environ; *envp != nullptr; ++envp) { // NOLINT + auto prefixes = std::array { + "QS_", + "QT_", + "QML_", + "QML2_", + "QSG_", + "XDG_CURRENT_DESKTOP=", + }; + + for (const auto& prefix: prefixes) { + if (strncmp(prefix.data(), *envp, prefix.length()) == 0) goto print; + } + continue; + + print: + stream << *envp << '\n'; + } + + return info; +} + +QString combinedInfo() { + QString info; + auto stream = QTextStream(&info); + + stream << "===== Version Information =====\n"; + stream << "Quickshell: " << qsVersion() << '\n'; + stream << "Qt: " << qtVersion() << '\n'; + + stream << "\n===== Build Information =====\n"; + stream << "Build Type: " << BUILD_TYPE << '\n'; + stream << "Compiler: " << COMPILER << '\n'; + stream << "Compile Flags: " << COMPILE_FLAGS << '\n'; + stream << "Configuration:\n" << BUILD_CONFIGURATION << '\n'; + + stream << "\n===== System Information =====\n"; + stream << systemInfo(); + + stream << "\n===== Environment (trimmed) =====\n"; + stream << envInfo(); + + return info; +} + +} // namespace qs::debuginfo diff --git a/src/core/debuginfo.hpp b/src/core/debuginfo.hpp new file mode 100644 index 0000000..fc766fc --- /dev/null +++ b/src/core/debuginfo.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace qs::debuginfo { + +QString qsVersion(); +QString qtVersion(); +QString gpuInfo(); +QString systemInfo(); +QString envInfo(); +QString combinedInfo(); + +} // namespace qs::debuginfo diff --git a/src/core/desktopentry.cpp b/src/core/desktopentry.cpp index 541207e..637f758 100644 --- a/src/core/desktopentry.cpp +++ b/src/core/desktopentry.cpp @@ -1,26 +1,33 @@ #include "desktopentry.hpp" +#include +#include #include #include #include +#include #include -#include -#include #include #include #include #include +#include #include -#include -#include +#include +#include #include +#include +#include #include -#include "common.hpp" +#include "../io/processcore.hpp" +#include "desktopentrymonitor.hpp" +#include "logcat.hpp" #include "model.hpp" +#include "qmlglobal.hpp" namespace { -Q_LOGGING_CATEGORY(logDesktopEntry, "quickshell.desktopentry", QtWarningMsg); +QS_LOGGING_CATEGORY(logDesktopEntry, "quickshell.desktopentry", QtWarningMsg); } struct Locale { @@ -54,12 +61,14 @@ struct Locale { [[nodiscard]] int matchScore(const Locale& other) const { if (this->language != other.language) return 0; - auto territoryMatches = !this->territory.isEmpty() && this->territory == other.territory; - auto modifierMatches = !this->modifier.isEmpty() && this->modifier == other.modifier; + + if (!other.modifier.isEmpty() && this->modifier != other.modifier) return 0; + if (!other.territory.isEmpty() && this->territory != other.territory) return 0; auto score = 1; - if (territoryMatches) score += 2; - if (modifierMatches) score += 1; + + if (!other.territory.isEmpty()) score += 2; + if (!other.modifier.isEmpty()) score += 1; return score; } @@ -85,51 +94,64 @@ struct Locale { QDebug operator<<(QDebug debug, const Locale& locale) { auto saver = QDebugStateSaver(debug); debug.nospace() << "Locale(language=" << locale.language << ", territory=" << locale.territory - << ", modifier" << locale.modifier << ')'; + << ", modifier=" << locale.modifier << ')'; return debug; } -void DesktopEntry::parseEntry(const QString& text) { +ParsedDesktopEntryData DesktopEntry::parseText(const QString& id, const QString& text) { + ParsedDesktopEntryData data; + data.id = id; const auto& system = Locale::system(); auto groupName = QString(); auto entries = QHash>(); - auto finishCategory = [this, &groupName, &entries]() { + auto actionOrder = QStringList(); + auto pendingActions = QHash(); + + auto finishCategory = [&data, &groupName, &entries, &actionOrder, &pendingActions]() { if (groupName == "Desktop Entry") { - if (entries["Type"].second != "Application") return; - if (entries.contains("Hidden") && entries["Hidden"].second == "true") return; + if (entries.value("Type").second != "Application") return; for (const auto& [key, pair]: entries.asKeyValueRange()) { auto& [_, value] = pair; - this->mEntries.insert(key, value); + data.entries.insert(key, value); - if (key == "Name") this->mName = value; - else if (key == "GenericName") this->mGenericName = value; - else if (key == "NoDisplay") this->mNoDisplay = value == "true"; - else if (key == "Comment") this->mComment = value; - else if (key == "Icon") this->mIcon = value; - else if (key == "Exec") this->mExecString = value; - else if (key == "Path") this->mWorkingDirectory = value; - else if (key == "Terminal") this->mTerminal = value == "true"; - else if (key == "Categories") this->mCategories = value.split(u';', Qt::SkipEmptyParts); - else if (key == "Keywords") this->mKeywords = value.split(u';', Qt::SkipEmptyParts); + if (key == "Name") data.name = value; + else if (key == "GenericName") data.genericName = value; + else if (key == "StartupWMClass") data.startupClass = value; + else if (key == "NoDisplay") data.noDisplay = value == "true"; + else if (key == "Hidden") data.hidden = value == "true"; + else if (key == "Comment") data.comment = value; + else if (key == "Icon") data.icon = value; + else if (key == "Exec") { + data.execString = value; + data.command = DesktopEntry::parseExecString(value); + } else if (key == "Path") data.workingDirectory = value; + else if (key == "Terminal") data.terminal = value == "true"; + else if (key == "Categories") data.categories = value.split(u';', Qt::SkipEmptyParts); + else if (key == "Keywords") data.keywords = value.split(u';', Qt::SkipEmptyParts); + else if (key == "Actions") actionOrder = value.split(u';', Qt::SkipEmptyParts); } } else if (groupName.startsWith("Desktop Action ")) { - auto actionName = groupName.sliced(16); - auto* action = new DesktopAction(actionName, this); + auto actionName = groupName.sliced(15); + DesktopActionData action; + action.id = actionName; for (const auto& [key, pair]: entries.asKeyValueRange()) { const auto& [_, value] = pair; - action->mEntries.insert(key, value); + action.entries.insert(key, value); - if (key == "Name") action->mName = value; - else if (key == "Icon") action->mIcon = value; - else if (key == "Exec") action->mExecString = value; + if (key == "Name") action.name = value; + else if (key == "Icon") action.icon = value; + else if (key == "Exec") { + action.execString = value; + action.command = DesktopEntry::parseExecString(value); + } } - this->mActions.insert(actionName, action); + pendingActions.insert(actionName, action); } entries.clear(); @@ -175,16 +197,73 @@ void DesktopEntry::parseEntry(const QString& text) { } finishCategory(); + + for (const auto& actionId: actionOrder) { + if (pendingActions.contains(actionId)) { + data.actions.append(pendingActions.value(actionId)); + } + } + + return data; +} + +void DesktopEntry::updateState(const ParsedDesktopEntryData& newState) { + Qt::beginPropertyUpdateGroup(); + this->bName = newState.name; + this->bGenericName = newState.genericName; + this->bStartupClass = newState.startupClass; + this->bNoDisplay = newState.noDisplay; + this->bComment = newState.comment; + this->bIcon = newState.icon; + this->bExecString = newState.execString; + this->bCommand = newState.command; + this->bWorkingDirectory = newState.workingDirectory; + this->bRunInTerminal = newState.terminal; + this->bCategories = newState.categories; + this->bKeywords = newState.keywords; + Qt::endPropertyUpdateGroup(); + + this->state = newState; + this->updateActions(newState.actions); +} + +void DesktopEntry::updateActions(const QVector& newActions) { + auto old = this->mActions; + this->mActions.clear(); + + for (const auto& d: newActions) { + DesktopAction* act = nullptr; + auto found = std::ranges::find(old, d.id, &DesktopAction::mId); + if (found != old.end()) { + act = *found; + old.erase(found); + } else { + act = new DesktopAction(d.id, this); + } + + Qt::beginPropertyUpdateGroup(); + act->bName = d.name; + act->bIcon = d.icon; + act->bExecString = d.execString; + act->bCommand = d.command; + Qt::endPropertyUpdateGroup(); + + act->mEntries = d.entries; + this->mActions.append(act); + } + + for (auto* leftover: old) { + leftover->deleteLater(); + } } void DesktopEntry::execute() const { - DesktopEntry::doExec(this->mExecString, this->mWorkingDirectory); + DesktopEntry::doExec(this->bCommand.value(), this->bWorkingDirectory.value()); } -bool DesktopEntry::isValid() const { return !this->mName.isEmpty(); } -bool DesktopEntry::noDisplay() const { return this->mNoDisplay; } +bool DesktopEntry::isValid() const { return !this->bName.value().isEmpty(); } -QVector DesktopEntry::actions() const { return this->mActions.values(); } +QVector DesktopEntry::actions() const { return this->mActions; } QVector DesktopEntry::parseExecString(const QString& execString) { QVector arguments; @@ -203,16 +282,22 @@ QVector DesktopEntry::parseExecString(const QString& execString) { currentArgument += '\\'; escape = 0; } + } else if (escape == 2) { + currentArgument += c; + escape = 0; } else if (escape != 0) { - if (escape != 2) { - // Technically this is an illegal state, but the spec has a terrible double escape - // rule in strings for no discernable reason. Assuming someone might understandably - // misunderstand it, treat it as a normal escape and log it. + switch (c.unicode()) { + case 's': currentArgument += u' '; break; + case 'n': currentArgument += u'\n'; break; + case 't': currentArgument += u'\t'; break; + case 'r': currentArgument += u'\r'; break; + case '\\': currentArgument += u'\\'; break; + default: qCWarning(logDesktopEntry).noquote() << "Illegal escape sequence in desktop entry exec string:" << execString; + currentArgument += c; + break; } - - currentArgument += c; escape = 0; } else if (c == u'"' || c == u'\'') { parsingString = false; @@ -250,75 +335,52 @@ QVector DesktopEntry::parseExecString(const QString& execString) { return arguments; } -void DesktopEntry::doExec(const QString& execString, const QString& workingDirectory) { - auto args = DesktopEntry::parseExecString(execString); - if (args.isEmpty()) { - qCWarning(logDesktopEntry) << "Tried to exec string" << execString << "which parsed as empty."; - return; - } - - auto process = QProcess(); - process.setProgram(args.at(0)); - process.setArguments(args.sliced(1)); - if (!workingDirectory.isEmpty()) process.setWorkingDirectory(workingDirectory); - process.setProcessEnvironment(qs::Common::INITIAL_ENVIRONMENT); - process.startDetached(); +void DesktopEntry::doExec(const QList& execString, const QString& workingDirectory) { + qs::io::process::ProcessContext ctx; + ctx.setCommand(execString); + ctx.setWorkingDirectory(workingDirectory); + QuickshellGlobal::execDetached(ctx); } void DesktopAction::execute() const { - DesktopEntry::doExec(this->mExecString, this->entry->mWorkingDirectory); + DesktopEntry::doExec(this->bCommand.value(), this->entry->bWorkingDirectory.value()); } -DesktopEntryManager::DesktopEntryManager() { - this->scanDesktopEntries(); - this->populateApplications(); +DesktopEntryScanner::DesktopEntryScanner(DesktopEntryManager* manager): manager(manager) { + this->setAutoDelete(true); } -void DesktopEntryManager::scanDesktopEntries() { - QList dataPaths; +void DesktopEntryScanner::run() { + const auto& desktopPaths = DesktopEntryManager::desktopPaths(); + auto scanResults = QList(); - if (qEnvironmentVariableIsSet("XDG_DATA_HOME")) { - dataPaths.push_back(qEnvironmentVariable("XDG_DATA_HOME")); - } else if (qEnvironmentVariableIsSet("HOME")) { - dataPaths.push_back(qEnvironmentVariable("HOME") + "/.local/share"); + for (const auto& path: desktopPaths | std::views::reverse) { + auto file = QFileInfo(path); + if (!file.isDir()) continue; + + this->scanDirectory(QDir(path), QString(), scanResults); } - if (qEnvironmentVariableIsSet("XDG_DATA_DIRS")) { - auto var = qEnvironmentVariable("XDG_DATA_DIRS"); - dataPaths += var.split(u':', Qt::SkipEmptyParts); - } else { - dataPaths.push_back("/usr/local/share"); - dataPaths.push_back("/usr/share"); - } - - qCDebug(logDesktopEntry) << "Creating desktop entry scanners"; - - for (auto& path: std::ranges::reverse_view(dataPaths)) { - auto p = QDir(path).filePath("applications"); - auto file = QFileInfo(p); - - if (!file.isDir()) { - qCDebug(logDesktopEntry) << "Not scanning path" << p << "as it is not a directory"; - continue; - } - - qCDebug(logDesktopEntry) << "Scanning path" << p; - this->scanPath(p); - } + QMetaObject::invokeMethod( + this->manager, + "onScanCompleted", + Qt::QueuedConnection, + Q_ARG(QList, scanResults) + ); } -void DesktopEntryManager::populateApplications() { - for (auto& entry: this->desktopEntries.values()) { - if (!entry->noDisplay()) this->mApplications.insertObject(entry); - } -} +void DesktopEntryScanner::scanDirectory( + const QDir& dir, + const QString& idPrefix, + QList& entries +) { + auto dirEntries = dir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); -void DesktopEntryManager::scanPath(const QDir& dir, const QString& prefix) { - auto entries = dir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot); - - for (auto& entry: entries) { - if (entry.isDir()) this->scanPath(entry.absoluteFilePath(), prefix + dir.dirName() + "-"); - else if (entry.isFile()) { + for (auto& entry: dirEntries) { + if (entry.isDir()) { + auto subdirPrefix = idPrefix.isEmpty() ? entry.fileName() : idPrefix + '-' + entry.fileName(); + this->scanDirectory(QDir(entry.absoluteFilePath()), subdirPrefix, entries); + } else if (entry.isFile()) { auto path = entry.filePath(); if (!path.endsWith(".desktop")) { qCDebug(logDesktopEntry) << "Skipping file" << path << "as it has no .desktop extension"; @@ -331,46 +393,42 @@ void DesktopEntryManager::scanPath(const QDir& dir, const QString& prefix) { continue; } - auto id = prefix + entry.fileName().sliced(0, entry.fileName().length() - 8); - auto lowerId = id.toLower(); + auto basename = QFileInfo(entry.fileName()).completeBaseName(); + auto id = idPrefix.isEmpty() ? basename : idPrefix + '-' + basename; + auto content = QString::fromUtf8(file.readAll()); - auto text = QString::fromUtf8(file.readAll()); - auto* dentry = new DesktopEntry(id, this); - dentry->parseEntry(text); - - if (!dentry->isValid()) { - qCDebug(logDesktopEntry) << "Skipping desktop entry" << path; - delete dentry; - continue; - } - - qCDebug(logDesktopEntry) << "Found desktop entry" << id << "at" << path; - - auto conflictingId = this->desktopEntries.contains(id); - - if (conflictingId) { - qCDebug(logDesktopEntry) << "Replacing old entry for" << id; - delete this->desktopEntries.value(id); - this->desktopEntries.remove(id); - this->lowercaseDesktopEntries.remove(lowerId); - } - - this->desktopEntries.insert(id, dentry); - - if (this->lowercaseDesktopEntries.contains(lowerId)) { - qCInfo(logDesktopEntry).nospace() - << "Multiple desktop entries have the same lowercased id " << lowerId - << ". This can cause ambiguity when byId requests are not made with the correct case " - "already."; - - this->lowercaseDesktopEntries.remove(lowerId); - } - - this->lowercaseDesktopEntries.insert(lowerId, dentry); + auto data = DesktopEntry::parseText(id, content); + entries.append(std::move(data)); } } } +DesktopEntryManager::DesktopEntryManager(): monitor(new DesktopEntryMonitor(this)) { + QObject::connect( + this->monitor, + &DesktopEntryMonitor::desktopEntriesChanged, + this, + &DesktopEntryManager::handleFileChanges + ); + + DesktopEntryScanner(this).run(); +} + +void DesktopEntryManager::scanDesktopEntries() { + qCDebug(logDesktopEntry) << "Starting desktop entry scan"; + + if (this->scanInProgress) { + qCDebug(logDesktopEntry) << "Scan already in progress, queuing another scan"; + this->scanQueued = true; + return; + } + + this->scanInProgress = true; + this->scanQueued = false; + auto* scanner = new DesktopEntryScanner(this); + QThreadPool::globalInstance()->start(scanner); +} + DesktopEntryManager* DesktopEntryManager::instance() { static auto* instance = new DesktopEntryManager(); // NOLINT return instance; @@ -386,14 +444,167 @@ DesktopEntry* DesktopEntryManager::byId(const QString& id) { } } +DesktopEntry* DesktopEntryManager::heuristicLookup(const QString& name) { + if (auto* entry = this->byId(name)) return entry; + + auto list = this->desktopEntries.values(); + + auto iter = std::ranges::find_if(list, [&](DesktopEntry* entry) { + return name == entry->bStartupClass.value(); + }); + + if (iter != list.end()) return *iter; + + iter = std::ranges::find_if(list, [&](DesktopEntry* entry) { + return name.toLower() == entry->bStartupClass.value().toLower(); + }); + + if (iter != list.end()) return *iter; + return nullptr; +} + ObjectModel* DesktopEntryManager::applications() { return &this->mApplications; } -DesktopEntries::DesktopEntries() { DesktopEntryManager::instance(); } +void DesktopEntryManager::handleFileChanges() { + qCDebug(logDesktopEntry) << "Directory change detected, performing full rescan"; + + if (this->scanInProgress) { + qCDebug(logDesktopEntry) << "Scan already in progress, queuing another scan"; + this->scanQueued = true; + return; + } + + this->scanInProgress = true; + this->scanQueued = false; + auto* scanner = new DesktopEntryScanner(this); + QThreadPool::globalInstance()->start(scanner); +} + +const QStringList& DesktopEntryManager::desktopPaths() { + static const auto paths = []() { + auto dataPaths = QStringList(); + + auto dataHome = qEnvironmentVariable("XDG_DATA_HOME"); + if (dataHome.isEmpty() && qEnvironmentVariableIsSet("HOME")) + dataHome = qEnvironmentVariable("HOME") + "/.local/share"; + if (!dataHome.isEmpty()) dataPaths.append(dataHome + "/applications"); + + auto dataDirs = qEnvironmentVariable("XDG_DATA_DIRS"); + if (dataDirs.isEmpty()) dataDirs = "/usr/local/share:/usr/share"; + + for (const auto& dir: dataDirs.split(':', Qt::SkipEmptyParts)) { + dataPaths.append(dir + "/applications"); + } + + return dataPaths; + }(); + + return paths; +} + +void DesktopEntryManager::onScanCompleted(const QList& scanResults) { + auto guard = qScopeGuard([this] { + this->scanInProgress = false; + if (this->scanQueued) { + this->scanQueued = false; + this->scanDesktopEntries(); + } + }); + + auto oldEntries = this->desktopEntries; + auto newEntries = QHash(); + auto newLowercaseEntries = QHash(); + + for (const auto& data: scanResults) { + auto lowerId = data.id.toLower(); + + if (data.hidden) { + if (auto* victim = newEntries.take(data.id)) victim->deleteLater(); + newLowercaseEntries.remove(lowerId); + + if (auto it = oldEntries.find(data.id); it != oldEntries.end()) { + it.value()->deleteLater(); + oldEntries.erase(it); + } + + qCDebug(logDesktopEntry) << "Masking hidden desktop entry" << data.id; + continue; + } + + DesktopEntry* dentry = nullptr; + + if (auto it = oldEntries.find(data.id); it != oldEntries.end()) { + dentry = it.value(); + oldEntries.erase(it); + dentry->updateState(data); + } else { + dentry = new DesktopEntry(data.id, this); + dentry->updateState(data); + } + + if (!dentry->isValid()) { + qCDebug(logDesktopEntry) << "Skipping desktop entry" << data.id; + if (!oldEntries.contains(data.id)) { + dentry->deleteLater(); + } + continue; + } + + qCDebug(logDesktopEntry) << "Found desktop entry" << data.id; + + auto conflictingId = newEntries.contains(data.id); + + if (conflictingId) { + qCDebug(logDesktopEntry) << "Replacing old entry for" << data.id; + if (auto* victim = newEntries.take(data.id)) victim->deleteLater(); + newLowercaseEntries.remove(lowerId); + } + + newEntries.insert(data.id, dentry); + + if (newLowercaseEntries.contains(lowerId)) { + qCInfo(logDesktopEntry).nospace() + << "Multiple desktop entries have the same lowercased id " << lowerId + << ". This can cause ambiguity when byId requests are not made with the correct case " + "already."; + + newLowercaseEntries.remove(lowerId); + } + + newLowercaseEntries.insert(lowerId, dentry); + } + + this->desktopEntries = newEntries; + this->lowercaseDesktopEntries = newLowercaseEntries; + + auto newApplications = QVector(); + for (auto* entry: this->desktopEntries.values()) + if (!entry->bNoDisplay) newApplications.append(entry); + + this->mApplications.diffUpdate(newApplications); + + emit this->applicationsChanged(); + + for (auto* e: oldEntries) e->deleteLater(); +} + +DesktopEntries::DesktopEntries() { + QObject::connect( + DesktopEntryManager::instance(), + &DesktopEntryManager::applicationsChanged, + this, + &DesktopEntries::applicationsChanged + ); +} DesktopEntry* DesktopEntries::byId(const QString& id) { return DesktopEntryManager::instance()->byId(id); } +DesktopEntry* DesktopEntries::heuristicLookup(const QString& name) { + return DesktopEntryManager::instance()->heuristicLookup(name); +} + ObjectModel* DesktopEntries::applications() { return DesktopEntryManager::instance()->applications(); } diff --git a/src/core/desktopentry.hpp b/src/core/desktopentry.hpp index 3871181..0d1eff2 100644 --- a/src/core/desktopentry.hpp +++ b/src/core/desktopentry.hpp @@ -6,36 +6,84 @@ #include #include #include +#include #include +#include #include +#include "desktopentrymonitor.hpp" #include "doc.hpp" #include "model.hpp" class DesktopAction; +class DesktopEntryMonitor; + +struct DesktopActionData { + QString id; + QString name; + QString icon; + QString execString; + QVector command; + QHash entries; +}; + +struct ParsedDesktopEntryData { + QString id; + QString name; + QString genericName; + QString startupClass; + bool noDisplay = false; + bool hidden = false; + QString comment; + QString icon; + QString execString; + QVector command; + QString workingDirectory; + bool terminal = false; + QVector categories; + QVector keywords; + QHash entries; + QVector actions; +}; /// A desktop entry. See @@DesktopEntries for details. class DesktopEntry: public QObject { Q_OBJECT; Q_PROPERTY(QString id MEMBER mId CONSTANT); /// Name of the specific application, such as "Firefox". - Q_PROPERTY(QString name MEMBER mName CONSTANT); + // clang-format off + Q_PROPERTY(QString name READ default WRITE default NOTIFY nameChanged BINDABLE bindableName); /// Short description of the application, such as "Web Browser". May be empty. - Q_PROPERTY(QString genericName MEMBER mGenericName CONSTANT); + Q_PROPERTY(QString genericName READ default WRITE default NOTIFY genericNameChanged BINDABLE bindableGenericName); + /// Initial class or app id the app intends to use. May be useful for matching running apps + /// to desktop entries. + Q_PROPERTY(QString startupClass READ default WRITE default NOTIFY startupClassChanged BINDABLE bindableStartupClass); /// If true, this application should not be displayed in menus and launchers. - Q_PROPERTY(bool noDisplay MEMBER mNoDisplay CONSTANT); + Q_PROPERTY(bool noDisplay READ default WRITE default NOTIFY noDisplayChanged BINDABLE bindableNoDisplay); /// Long description of the application, such as "View websites on the internet". May be empty. - Q_PROPERTY(QString comment MEMBER mComment CONSTANT); + Q_PROPERTY(QString comment READ default WRITE default NOTIFY commentChanged BINDABLE bindableComment); /// Name of the icon associated with this application. May be empty. - Q_PROPERTY(QString icon MEMBER mIcon CONSTANT); - /// The raw `Exec` string from the desktop entry. You probably want @@execute(). - Q_PROPERTY(QString execString MEMBER mExecString CONSTANT); + Q_PROPERTY(QString icon READ default WRITE default NOTIFY iconChanged BINDABLE bindableIcon); + /// The raw `Exec` string from the desktop entry. + /// + /// > [!WARNING] This cannot be reliably run as a command. See @@command for one you can run. + Q_PROPERTY(QString execString READ default WRITE default NOTIFY execStringChanged BINDABLE bindableExecString); + /// The parsed `Exec` command in the desktop entry. + /// + /// The entry can be run with @@execute(), or by using this command in + /// @@Quickshell.Quickshell.execDetached() or @@Quickshell.Io.Process. + /// If used in `execDetached` or a `Process`, @@workingDirectory should also be passed to + /// the invoked process. See @@execute() for details. + /// + /// > [!NOTE] The provided command does not invoke a terminal even if @@runInTerminal is true. + Q_PROPERTY(QVector command READ default WRITE default NOTIFY commandChanged BINDABLE bindableCommand); /// The working directory to execute from. - Q_PROPERTY(QString workingDirectory MEMBER mWorkingDirectory CONSTANT); + Q_PROPERTY(QString workingDirectory READ default WRITE default NOTIFY workingDirectoryChanged BINDABLE bindableWorkingDirectory); /// If the application should run in a terminal. - Q_PROPERTY(bool runInTerminal MEMBER mTerminal CONSTANT); - Q_PROPERTY(QVector categories MEMBER mCategories CONSTANT); - Q_PROPERTY(QVector keywords MEMBER mKeywords CONSTANT); + Q_PROPERTY(bool runInTerminal READ default WRITE default NOTIFY runInTerminalChanged BINDABLE bindableRunInTerminal); + Q_PROPERTY(QVector categories READ default WRITE default NOTIFY categoriesChanged BINDABLE bindableCategories); + Q_PROPERTY(QVector keywords READ default WRITE default NOTIFY keywordsChanged BINDABLE bindableKeywords); + // clang-format on Q_PROPERTY(QVector actions READ actions CONSTANT); QML_ELEMENT; QML_UNCREATABLE("DesktopEntry instances must be retrieved from DesktopEntries"); @@ -43,35 +91,83 @@ class DesktopEntry: public QObject { public: explicit DesktopEntry(QString id, QObject* parent): QObject(parent), mId(std::move(id)) {} - void parseEntry(const QString& text); + static ParsedDesktopEntryData parseText(const QString& id, const QString& text); + void updateState(const ParsedDesktopEntryData& newState); /// Run the application. Currently ignores @@runInTerminal and field codes. + /// + /// This is equivalent to calling @@Quickshell.Quickshell.execDetached() with @@command + /// and @@DesktopEntry.workingDirectory as shown below: + /// + /// ```qml + /// Quickshell.execDetached({ + /// command: desktopEntry.command, + /// workingDirectory: desktopEntry.workingDirectory, + /// }); + /// ``` Q_INVOKABLE void execute() const; [[nodiscard]] bool isValid() const; - [[nodiscard]] bool noDisplay() const; [[nodiscard]] QVector actions() const; + [[nodiscard]] QBindable bindableName() const { return &this->bName; } + [[nodiscard]] QBindable bindableGenericName() const { return &this->bGenericName; } + [[nodiscard]] QBindable bindableStartupClass() const { return &this->bStartupClass; } + [[nodiscard]] QBindable bindableNoDisplay() const { return &this->bNoDisplay; } + [[nodiscard]] QBindable bindableComment() const { return &this->bComment; } + [[nodiscard]] QBindable bindableIcon() const { return &this->bIcon; } + [[nodiscard]] QBindable bindableExecString() const { return &this->bExecString; } + [[nodiscard]] QBindable> bindableCommand() const { return &this->bCommand; } + [[nodiscard]] QBindable bindableWorkingDirectory() const { + return &this->bWorkingDirectory; + } + [[nodiscard]] QBindable bindableRunInTerminal() const { return &this->bRunInTerminal; } + [[nodiscard]] QBindable> bindableCategories() const { + return &this->bCategories; + } + [[nodiscard]] QBindable> bindableKeywords() const { return &this->bKeywords; } + // currently ignores all field codes. static QVector parseExecString(const QString& execString); - static void doExec(const QString& execString, const QString& workingDirectory); + static void doExec(const QList& execString, const QString& workingDirectory); + +signals: + void nameChanged(); + void genericNameChanged(); + void startupClassChanged(); + void noDisplayChanged(); + void commentChanged(); + void iconChanged(); + void execStringChanged(); + void commandChanged(); + void workingDirectoryChanged(); + void runInTerminalChanged(); + void categoriesChanged(); + void keywordsChanged(); public: QString mId; - QString mName; - QString mGenericName; - bool mNoDisplay = false; - QString mComment; - QString mIcon; - QString mExecString; - QString mWorkingDirectory; - bool mTerminal = false; - QVector mCategories; - QVector mKeywords; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bName, &DesktopEntry::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bGenericName, &DesktopEntry::genericNameChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bStartupClass, &DesktopEntry::startupClassChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, bool, bNoDisplay, &DesktopEntry::noDisplayChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bComment, &DesktopEntry::commentChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bIcon, &DesktopEntry::iconChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bExecString, &DesktopEntry::execStringChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QVector, bCommand, &DesktopEntry::commandChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QString, bWorkingDirectory, &DesktopEntry::workingDirectoryChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, bool, bRunInTerminal, &DesktopEntry::runInTerminalChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QVector, bCategories, &DesktopEntry::categoriesChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopEntry, QVector, bKeywords, &DesktopEntry::keywordsChanged); + // clang-format on private: - QHash mEntries; - QHash mActions; + void updateActions(const QVector& newActions); + + ParsedDesktopEntryData state; + QVector mActions; friend class DesktopAction; }; @@ -80,10 +176,23 @@ private: class DesktopAction: public QObject { Q_OBJECT; Q_PROPERTY(QString id MEMBER mId CONSTANT); - Q_PROPERTY(QString name MEMBER mName CONSTANT); - Q_PROPERTY(QString icon MEMBER mIcon CONSTANT); - /// The raw `Exec` string from the desktop entry. You probably want @@execute(). - Q_PROPERTY(QString execString MEMBER mExecString CONSTANT); + // clang-format off + Q_PROPERTY(QString name READ default WRITE default NOTIFY nameChanged BINDABLE bindableName); + Q_PROPERTY(QString icon READ default WRITE default NOTIFY iconChanged BINDABLE bindableIcon); + /// The raw `Exec` string from the action. + /// + /// > [!WARNING] This cannot be reliably run as a command. See @@command for one you can run. + Q_PROPERTY(QString execString READ default WRITE default NOTIFY execStringChanged BINDABLE bindableExecString); + /// The parsed `Exec` command in the action. + /// + /// The entry can be run with @@execute(), or by using this command in + /// @@Quickshell.Quickshell.execDetached() or @@Quickshell.Io.Process. + /// If used in `execDetached` or a `Process`, @@DesktopEntry.workingDirectory should also be passed to + /// the invoked process. + /// + /// > [!NOTE] The provided command does not invoke a terminal even if @@runInTerminal is true. + Q_PROPERTY(QVector command READ default WRITE default NOTIFY commandChanged BINDABLE bindableCommand); + // clang-format on QML_ELEMENT; QML_UNCREATABLE("DesktopAction instances must be retrieved from a DesktopEntry"); @@ -94,19 +203,52 @@ public: , mId(std::move(id)) {} /// Run the application. Currently ignores @@DesktopEntry.runInTerminal and field codes. + /// + /// This is equivalent to calling @@Quickshell.Quickshell.execDetached() with @@command + /// and @@DesktopEntry.workingDirectory. Q_INVOKABLE void execute() const; + [[nodiscard]] QBindable bindableName() const { return &this->bName; } + [[nodiscard]] QBindable bindableIcon() const { return &this->bIcon; } + [[nodiscard]] QBindable bindableExecString() const { return &this->bExecString; } + [[nodiscard]] QBindable> bindableCommand() const { return &this->bCommand; } + +signals: + void nameChanged(); + void iconChanged(); + void execStringChanged(); + void commandChanged(); + private: DesktopEntry* entry; QString mId; - QString mName; - QString mIcon; - QString mExecString; QHash mEntries; + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(DesktopAction, QString, bName, &DesktopAction::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopAction, QString, bIcon, &DesktopAction::iconChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopAction, QString, bExecString, &DesktopAction::execStringChanged); + Q_OBJECT_BINDABLE_PROPERTY(DesktopAction, QVector, bCommand, &DesktopAction::commandChanged); + // clang-format on + friend class DesktopEntry; }; +class DesktopEntryManager; + +class DesktopEntryScanner: public QRunnable { +public: + explicit DesktopEntryScanner(DesktopEntryManager* manager); + + void run() override; + // clang-format off + void scanDirectory(const QDir& dir, const QString& idPrefix, QList& entries); + // clang-format on + +private: + DesktopEntryManager* manager; +}; + class DesktopEntryManager: public QObject { Q_OBJECT; @@ -114,20 +256,32 @@ public: void scanDesktopEntries(); [[nodiscard]] DesktopEntry* byId(const QString& id); + [[nodiscard]] DesktopEntry* heuristicLookup(const QString& name); [[nodiscard]] ObjectModel* applications(); static DesktopEntryManager* instance(); + static const QStringList& desktopPaths(); + +signals: + void applicationsChanged(); + +private slots: + void handleFileChanges(); + void onScanCompleted(const QList& scanResults); + private: explicit DesktopEntryManager(); - void populateApplications(); - void scanPath(const QDir& dir, const QString& prefix = QString()); - QHash desktopEntries; QHash lowercaseDesktopEntries; ObjectModel mApplications {this}; + DesktopEntryMonitor* monitor = nullptr; + bool scanInProgress = false; + bool scanQueued = false; + + friend class DesktopEntryScanner; }; ///! Desktop entry index. @@ -149,7 +303,17 @@ public: explicit DesktopEntries(); /// Look up a desktop entry by name. Includes NoDisplay entries. May return null. + /// + /// While this function requires an exact match, @@heuristicLookup() will correctly + /// find an entry more often and is generally more useful. Q_INVOKABLE [[nodiscard]] static DesktopEntry* byId(const QString& id); + /// Look up a desktop entry by name using heuristics. Unlike @@byId(), + /// if no exact matches are found this function will try to guess - potentially incorrectly. + /// May return null. + Q_INVOKABLE [[nodiscard]] static DesktopEntry* heuristicLookup(const QString& name); [[nodiscard]] static ObjectModel* applications(); + +signals: + void applicationsChanged(); }; diff --git a/src/core/desktopentrymonitor.cpp b/src/core/desktopentrymonitor.cpp new file mode 100644 index 0000000..bed6ef1 --- /dev/null +++ b/src/core/desktopentrymonitor.cpp @@ -0,0 +1,68 @@ +#include "desktopentrymonitor.hpp" + +#include +#include +#include +#include +#include +#include + +#include "desktopentry.hpp" + +namespace { +void addPathAndParents(QFileSystemWatcher& watcher, const QString& path) { + watcher.addPath(path); + + auto p = QFileInfo(path).absolutePath(); + while (!p.isEmpty()) { + watcher.addPath(p); + const auto parent = QFileInfo(p).dir().absolutePath(); + if (parent == p) break; + p = parent; + } +} +} // namespace + +DesktopEntryMonitor::DesktopEntryMonitor(QObject* parent): QObject(parent) { + this->debounceTimer.setSingleShot(true); + this->debounceTimer.setInterval(100); + + QObject::connect( + &this->watcher, + &QFileSystemWatcher::directoryChanged, + this, + &DesktopEntryMonitor::onDirectoryChanged + ); + QObject::connect( + &this->debounceTimer, + &QTimer::timeout, + this, + &DesktopEntryMonitor::processChanges + ); + + this->startMonitoring(); +} + +void DesktopEntryMonitor::startMonitoring() { + for (const auto& path: DesktopEntryManager::desktopPaths()) { + if (!QDir(path).exists()) continue; + addPathAndParents(this->watcher, path); + this->scanAndWatch(path); + } +} + +void DesktopEntryMonitor::scanAndWatch(const QString& dirPath) { + auto dir = QDir(dirPath); + if (!dir.exists()) return; + + this->watcher.addPath(dirPath); + + auto subdirs = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks); + for (const auto& subdir: subdirs) this->watcher.addPath(subdir.absoluteFilePath()); +} + +void DesktopEntryMonitor::onDirectoryChanged(const QString& /*path*/) { + this->debounceTimer.start(); +} + +void DesktopEntryMonitor::processChanges() { emit this->desktopEntriesChanged(); } \ No newline at end of file diff --git a/src/core/desktopentrymonitor.hpp b/src/core/desktopentrymonitor.hpp new file mode 100644 index 0000000..eb3251d --- /dev/null +++ b/src/core/desktopentrymonitor.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +class DesktopEntryMonitor: public QObject { + Q_OBJECT + +public: + explicit DesktopEntryMonitor(QObject* parent = nullptr); + ~DesktopEntryMonitor() override = default; + DesktopEntryMonitor(const DesktopEntryMonitor&) = delete; + DesktopEntryMonitor& operator=(const DesktopEntryMonitor&) = delete; + DesktopEntryMonitor(DesktopEntryMonitor&&) = delete; + DesktopEntryMonitor& operator=(DesktopEntryMonitor&&) = delete; + +signals: + void desktopEntriesChanged(); + +private slots: + void onDirectoryChanged(const QString& path); + void processChanges(); + +private: + void startMonitoring(); + void scanAndWatch(const QString& dirPath); + + QFileSystemWatcher watcher; + QTimer debounceTimer; +}; diff --git a/src/core/generation.cpp b/src/core/generation.cpp index 1189ab7..21febc3 100644 --- a/src/core/generation.cpp +++ b/src/core/generation.cpp @@ -16,18 +16,20 @@ #include #include #include +#include #include #include "iconimageprovider.hpp" #include "imageprovider.hpp" #include "incubator.hpp" +#include "logcat.hpp" #include "plugin.hpp" #include "qsintercept.hpp" #include "reload.hpp" #include "scan.hpp" namespace { -Q_LOGGING_CATEGORY(logScene, "scene"); +QS_LOGGING_CATEGORY(logScene, "scene"); } static QHash g_generations; // NOLINT @@ -36,7 +38,7 @@ EngineGeneration::EngineGeneration(const QDir& rootPath, QmlScanner scanner) : rootPath(rootPath) , scanner(std::move(scanner)) , urlInterceptor(this->rootPath) - , interceptNetFactory(this->scanner.fileIntercepts) + , interceptNetFactory(this->rootPath, this->scanner.fileIntercepts) , engine(new QQmlEngine()) { g_generations.insert(this->engine, this); @@ -44,8 +46,11 @@ EngineGeneration::EngineGeneration(const QDir& rootPath, QmlScanner scanner) QObject::connect(this->engine, &QQmlEngine::warnings, this, &EngineGeneration::onEngineWarnings); this->engine->addUrlInterceptor(&this->urlInterceptor); + this->engine->addImportPath("qs:@/"); + this->engine->setNetworkAccessManagerFactory(&this->interceptNetFactory); - this->engine->setIncubationController(&this->delayedIncubationController); + this->incubationController.initLoop(); + this->engine->setIncubationController(&this->incubationController); this->engine->addImageProvider("icon", new IconImageProvider()); this->engine->addImageProvider("qsimage", new QsImageProvider()); @@ -130,7 +135,7 @@ void EngineGeneration::onReload(EngineGeneration* old) { // new generation acquires it then incubators will hang intermittently qCDebug(logIncubator) << "Locking incubation controllers of old generation" << old; old->incubationControllersLocked = true; - old->assignIncubationController(); + old->updateIncubationMode(); } QObject::connect(this->engine, &QQmlEngine::quit, this, &EngineGeneration::quit); @@ -157,8 +162,9 @@ void EngineGeneration::postReload() { if (this->engine == nullptr || this->root == nullptr) return; QsEnginePlugin::runOnReload(); - PostReloadHook::postReloadTree(this->root); - this->singletonRegistry.onPostReload(); + + emit this->firePostReload(); + QObject::disconnect(this, &EngineGeneration::firePostReload, nullptr, nullptr); } void EngineGeneration::setWatchingFiles(bool watching) { @@ -203,6 +209,8 @@ bool EngineGeneration::setExtraWatchedFiles(const QVector& files) { for (const auto& file: files) { if (!this->scanner.scannedFiles.contains(file)) { this->extraWatchedFiles.append(file); + QByteArray data; + this->scanner.readAndHashFile(file, data); } } @@ -218,6 +226,16 @@ void EngineGeneration::onFileChanged(const QString& name) { if (!this->watcher->files().contains(name)) { this->deletedWatchedFiles.push_back(name); } else { + // some editors (e.g vscode) perform file saving in two steps: truncate + write + // ignore the first event (truncate) with size 0 to prevent incorrect live reloading + auto fileInfo = QFileInfo(name); + if (fileInfo.isFile() && fileInfo.size() == 0) return; + + if (!this->scanner.hasFileContentChanged(name)) { + qCDebug(logQmlScanner) << "Ignoring file change with unchanged content:" << name; + return; + } + emit this->filesChanged(); } } @@ -226,104 +244,22 @@ void EngineGeneration::onDirectoryChanged() { // try to find any files that were just deleted from a replace operation for (auto& file: this->deletedWatchedFiles) { if (QFileInfo(file).exists()) { + if (!this->scanner.hasFileContentChanged(file)) { + qCDebug(logQmlScanner) << "Ignoring restored file with unchanged content:" << file; + continue; + } + emit this->filesChanged(); break; } } } -void EngineGeneration::registerIncubationController(QQmlIncubationController* controller) { - // We only want controllers that we can swap out if destroyed. - // This happens if the window owning the active controller dies. - if (auto* obj = dynamic_cast(controller)) { - QObject::connect( - obj, - &QObject::destroyed, - this, - &EngineGeneration::incubationControllerDestroyed - ); - } else { - qCWarning(logIncubator) << "Could not register incubation controller as it is not a QObject" - << controller; - - return; - } - - this->incubationControllers.push_back(controller); - qCDebug(logIncubator) << "Registered incubation controller" << controller << "to generation" - << this; - - // This function can run during destruction. - if (this->engine == nullptr) return; - - if (this->engine->incubationController() == &this->delayedIncubationController) { - this->assignIncubationController(); - } -} - -void EngineGeneration::deregisterIncubationController(QQmlIncubationController* controller) { - if (auto* obj = dynamic_cast(controller)) { - QObject::disconnect(obj, nullptr, this, nullptr); - } else { - qCCritical(logIncubator) << "Deregistering incubation controller which is not a QObject, " - "however only QObject controllers should be registered."; - } - - if (!this->incubationControllers.removeOne(controller)) { - qCCritical(logIncubator) << "Failed to deregister incubation controller" << controller << "from" - << this << "as it was not registered to begin with"; - qCCritical(logIncubator) << "Current registered incuabation controllers" - << this->incubationControllers; - } else { - qCDebug(logIncubator) << "Deregistered incubation controller" << controller << "from" << this; - } - - // This function can run during destruction. - if (this->engine == nullptr) return; - - if (this->engine->incubationController() == controller) { - qCDebug(logIncubator - ) << "Destroyed incubation controller was currently active, reassigning from pool"; - this->assignIncubationController(); - } -} - -void EngineGeneration::incubationControllerDestroyed() { - auto* sender = this->sender(); - auto* controller = dynamic_cast(sender); - - if (controller == nullptr) { - qCCritical(logIncubator) << "Destroyed incubation controller" << sender << "is not known to" - << this << ", this may cause memory corruption"; - qCCritical(logIncubator) << "Current registered incuabation controllers" - << this->incubationControllers; - - return; - } - - if (this->incubationControllers.removeOne(controller)) { - qCDebug(logIncubator) << "Destroyed incubation controller" << controller << "deregistered from" - << this; - } else { - qCCritical(logIncubator) << "Destroyed incubation controller" << controller - << "was not registered, but its destruction was observed by" << this; - - return; - } - - // This function can run during destruction. - if (this->engine == nullptr) return; - - if (this->engine->incubationController() == controller) { - qCDebug(logIncubator - ) << "Destroyed incubation controller was currently active, reassigning from pool"; - this->assignIncubationController(); - } -} - -void EngineGeneration::onEngineWarnings(const QList& warnings) const { +void EngineGeneration::onEngineWarnings(const QList& warnings) { for (const auto& error: warnings) { - auto rel = "**/" % this->rootPath.relativeFilePath(error.url().path()); + const auto& url = error.url(); + auto rel = url.scheme() == "qs" && url.path().startsWith("@/qs/") ? "@" % url.path().sliced(5) + : url.toString(); QString objectName; auto desc = error.description(); @@ -360,20 +296,23 @@ void EngineGeneration::exit(int code) { this->destroy(); } -void EngineGeneration::assignIncubationController() { - QQmlIncubationController* controller = nullptr; +void EngineGeneration::trackWindowIncubationController(QQuickWindow* window) { + if (this->trackedWindows.contains(window)) return; - if (this->incubationControllersLocked || this->incubationControllers.isEmpty()) { - controller = &this->delayedIncubationController; - } else { - controller = this->incubationControllers.first(); - } + QObject::connect(window, &QObject::destroyed, this, &EngineGeneration::onTrackedWindowDestroyed); + this->trackedWindows.append(window); + this->updateIncubationMode(); +} - qCDebug(logIncubator) << "Assigning incubation controller" << controller << "to generation" - << this - << "fallback:" << (controller == &this->delayedIncubationController); +void EngineGeneration::onTrackedWindowDestroyed(QObject* object) { + this->trackedWindows.removeAll(static_cast(object)); // NOLINT + this->updateIncubationMode(); +} - this->engine->setIncubationController(controller); +void EngineGeneration::updateIncubationMode() { + // If we're in a situation with only hidden but tracked windows this might be wrong, + // but it seems to at least work. + this->incubationController.setIncubationMode(!this->trackedWindows.empty()); } EngineGeneration* EngineGeneration::currentGeneration() { diff --git a/src/core/generation.hpp b/src/core/generation.hpp index df2c85a..4543408 100644 --- a/src/core/generation.hpp +++ b/src/core/generation.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include "incubator.hpp" @@ -40,8 +41,7 @@ public: void setWatchingFiles(bool watching); bool setExtraWatchedFiles(const QVector& files); - void registerIncubationController(QQmlIncubationController* controller); - void deregisterIncubationController(QQmlIncubationController* controller); + void trackWindowIncubationController(QQuickWindow* window); // takes ownership void registerExtension(const void* key, EngineGenerationExt* extension); @@ -65,7 +65,7 @@ public: QFileSystemWatcher* watcher = nullptr; QVector deletedWatchedFiles; QVector extraWatchedFiles; - DelayedQmlIncubationController delayedIncubationController; + QsIncubationController incubationController; bool reloadComplete = false; QuickshellGlobal* qsgInstance = nullptr; @@ -75,6 +75,7 @@ public: signals: void filesChanged(); void reloadFinished(); + void firePostReload(); public slots: void quit(); @@ -83,13 +84,13 @@ public slots: private slots: void onFileChanged(const QString& name); void onDirectoryChanged(); - void incubationControllerDestroyed(); - void onEngineWarnings(const QList& warnings) const; + void onTrackedWindowDestroyed(QObject* object); + static void onEngineWarnings(const QList& warnings); private: void postReload(); - void assignIncubationController(); - QVector incubationControllers; + void updateIncubationMode(); + QVector trackedWindows; bool incubationControllersLocked = false; QHash extensions; diff --git a/src/core/iconimageprovider.cpp b/src/core/iconimageprovider.cpp index 43e00fd..1dbe3e7 100644 --- a/src/core/iconimageprovider.cpp +++ b/src/core/iconimageprovider.cpp @@ -19,8 +19,7 @@ IconImageProvider::requestPixmap(const QString& id, QSize* size, const QSize& re if (splitIdx != -1) { iconName = id.sliced(0, splitIdx); path = id.sliced(splitIdx + 6); - qWarning() << "Searching custom icon paths is not yet supported. Icon path will be ignored for" - << id; + path = QString("/%1/%2").arg(path, iconName.sliced(iconName.lastIndexOf('/') + 1)); } else { splitIdx = id.indexOf("?fallback="); if (splitIdx != -1) { @@ -32,7 +31,8 @@ IconImageProvider::requestPixmap(const QString& id, QSize* size, const QSize& re } auto icon = QIcon::fromTheme(iconName); - if (icon.isNull()) icon = QIcon::fromTheme(fallbackName); + if (icon.isNull() && !fallbackName.isEmpty()) icon = QIcon::fromTheme(fallbackName); + if (icon.isNull() && !path.isEmpty()) icon = QPixmap(path); auto targetSize = requestedSize.isValid() ? requestedSize : QSize(100, 100); if (targetSize.width() == 0 || targetSize.height() == 0) targetSize = QSize(2, 2); diff --git a/src/core/iconprovider.cpp b/src/core/iconprovider.cpp index 99b423e..383f7e1 100644 --- a/src/core/iconprovider.cpp +++ b/src/core/iconprovider.cpp @@ -22,8 +22,8 @@ class PixmapCacheIconEngine: public QIconEngine { QIcon::Mode /*unused*/, QIcon::State /*unused*/ ) override { - qFatal( - ) << "Unexpected icon paint request bypassed pixmap method. Please report this as a bug."; + qFatal() + << "Unexpected icon paint request bypassed pixmap method. Please report this as a bug."; } QPixmap pixmap(const QSize& size, QIcon::Mode /*unused*/, QIcon::State /*unused*/) override { diff --git a/src/core/incubator.cpp b/src/core/incubator.cpp index c43703a..f031b11 100644 --- a/src/core/incubator.cpp +++ b/src/core/incubator.cpp @@ -1,11 +1,21 @@ #include "incubator.hpp" +#include +#include +#include #include #include +#include +#include +#include +#include #include +#include #include -Q_LOGGING_CATEGORY(logIncubator, "quickshell.incubator", QtWarningMsg); +#include "logcat.hpp" + +QS_LOGGING_CATEGORY(logIncubator, "quickshell.incubator", QtWarningMsg); void QsQmlIncubator::statusChanged(QQmlIncubator::Status status) { switch (status) { @@ -14,3 +24,112 @@ void QsQmlIncubator::statusChanged(QQmlIncubator::Status status) { default: break; } } + +void QsIncubationController::initLoop() { + auto* app = static_cast(QGuiApplication::instance()); // NOLINT + this->renderLoop = QSGRenderLoop::instance(); + + QObject::connect( + app, + &QGuiApplication::screenAdded, + this, + &QsIncubationController::updateIncubationTime + ); + + QObject::connect( + app, + &QGuiApplication::screenRemoved, + this, + &QsIncubationController::updateIncubationTime + ); + + this->updateIncubationTime(); + + QObject::connect( + this->renderLoop, + &QSGRenderLoop::timeToIncubate, + this, + &QsIncubationController::incubate + ); + + QAnimationDriver* animationDriver = this->renderLoop->animationDriver(); + if (animationDriver) { + QObject::connect( + animationDriver, + &QAnimationDriver::stopped, + this, + &QsIncubationController::animationStopped + ); + } else { + qCInfo(logIncubator) << "Render loop does not have animation driver, animationStopped cannot " + "be used to trigger incubation."; + } +} + +void QsIncubationController::setIncubationMode(bool render) { + if (render == this->followRenderloop) return; + this->followRenderloop = render; + + if (render) { + qCDebug(logIncubator) << "Incubation mode changed: render loop driven"; + } else { + qCDebug(logIncubator) << "Incubation mode changed: event loop driven"; + } + + if (!render && this->incubatingObjectCount()) this->incubateLater(); +} + +void QsIncubationController::timerEvent(QTimerEvent* /*event*/) { + this->killTimer(this->timerId); + this->timerId = 0; + this->incubate(); +} + +void QsIncubationController::incubateLater() { + if (this->followRenderloop) { + if (this->timerId != 0) { + this->killTimer(this->timerId); + this->timerId = 0; + } + + // Incubate again at the end of the event processing queue + QMetaObject::invokeMethod(this, &QsIncubationController::incubate, Qt::QueuedConnection); + } else if (this->timerId == 0) { + // Wait for a while before processing the next batch. Using a + // timer to avoid starvation of system events. + this->timerId = this->startTimer(this->incubationTime); + } +} + +void QsIncubationController::incubate() { + if ((!this->followRenderloop || this->renderLoop) && this->incubatingObjectCount()) { + if (!this->followRenderloop) { + this->incubateFor(10); + if (this->incubatingObjectCount()) this->incubateLater(); + } else if (this->renderLoop->interleaveIncubation()) { + this->incubateFor(this->incubationTime); + } else { + this->incubateFor(this->incubationTime * 2); + if (this->incubatingObjectCount()) this->incubateLater(); + } + } +} + +void QsIncubationController::animationStopped() { this->incubate(); } + +void QsIncubationController::incubatingObjectCountChanged(int count) { + if (count + && (!this->followRenderloop + || (this->renderLoop && !this->renderLoop->interleaveIncubation()))) + { + this->incubateLater(); + } +} + +void QsIncubationController::updateIncubationTime() { + auto* screen = QGuiApplication::primaryScreen(); + if (!screen) return; + + // 1/3 frame on primary screen + this->incubationTime = qMax(1, static_cast(1000 / screen->refreshRate() / 3)); +} diff --git a/src/core/incubator.hpp b/src/core/incubator.hpp index 5928ffe..15dc49a 100644 --- a/src/core/incubator.hpp +++ b/src/core/incubator.hpp @@ -1,11 +1,13 @@ #pragma once -#include #include +#include #include #include -Q_DECLARE_LOGGING_CATEGORY(logIncubator); +#include "logcat.hpp" + +QS_DECLARE_LOGGING_CATEGORY(logIncubator); class QsQmlIncubator : public QObject @@ -24,7 +26,37 @@ signals: void failed(); }; -class DelayedQmlIncubationController: public QQmlIncubationController { - // Do nothing. - // This ensures lazy loaders don't start blocking before onReload creates windows. +class QSGRenderLoop; + +class QsIncubationController + : public QObject + , public QQmlIncubationController { + Q_OBJECT + +public: + void initLoop(); + void setIncubationMode(bool render); + void incubateLater(); + +protected: + void timerEvent(QTimerEvent* event) override; + +public slots: + void incubate(); + void animationStopped(); + void updateIncubationTime(); + +protected: + void incubatingObjectCountChanged(int count) override; + +private: +// QPointer did not work with forward declarations prior to 6.7 +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + QPointer renderLoop = nullptr; +#else + QSGRenderLoop* renderLoop = nullptr; +#endif + int incubationTime = 0; + int timerId = 0; + bool followRenderloop = false; }; diff --git a/src/core/instanceinfo.cpp b/src/core/instanceinfo.cpp index 96097c7..b9b7b44 100644 --- a/src/core/instanceinfo.cpp +++ b/src/core/instanceinfo.cpp @@ -3,12 +3,14 @@ #include QDataStream& operator<<(QDataStream& stream, const InstanceInfo& info) { - stream << info.instanceId << info.configPath << info.shellId << info.launchTime; + stream << info.instanceId << info.configPath << info.shellId << info.appId << info.launchTime + << info.pid << info.display; return stream; } QDataStream& operator>>(QDataStream& stream, InstanceInfo& info) { - stream >> info.instanceId >> info.configPath >> info.shellId >> info.launchTime; + stream >> info.instanceId >> info.configPath >> info.shellId >> info.appId >> info.launchTime + >> info.pid >> info.display; return stream; } diff --git a/src/core/instanceinfo.hpp b/src/core/instanceinfo.hpp index f0fc02a..a4a7e66 100644 --- a/src/core/instanceinfo.hpp +++ b/src/core/instanceinfo.hpp @@ -3,12 +3,16 @@ #include #include #include +#include struct InstanceInfo { QString instanceId; QString configPath; QString shellId; + QString appId; QDateTime launchTime; + pid_t pid = -1; + QString display; static InstanceInfo CURRENT; // NOLINT }; @@ -32,6 +36,8 @@ namespace qs::crash { struct CrashInfo { int logFd = -1; + int traceFd = -1; + int infoFd = -1; static CrashInfo INSTANCE; // NOLINT }; diff --git a/src/core/lazyloader.hpp b/src/core/lazyloader.hpp index dbaad4b..56cc964 100644 --- a/src/core/lazyloader.hpp +++ b/src/core/lazyloader.hpp @@ -82,9 +82,6 @@ /// > Notably, @@Variants does not corrently support asynchronous /// > loading, meaning using it inside a LazyLoader will block similarly to not /// > having a loader to start with. -/// -/// > [!WARNING] LazyLoaders do not start loading before the first window is created, -/// > meaning if you create all windows inside of lazy loaders, none of them will ever load. class LazyLoader: public Reloadable { Q_OBJECT; /// The fully loaded item if the loader is @@loading or @@active, or `null` diff --git a/src/core/logcat.hpp b/src/core/logcat.hpp new file mode 100644 index 0000000..9650ddb --- /dev/null +++ b/src/core/logcat.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace qs::log { +void initLogCategoryLevel(const char* name, QtMsgType defaultLevel = QtDebugMsg); +} + +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +#define QS_DECLARE_LOGGING_CATEGORY(name) \ + namespace qslogcat { \ + Q_DECLARE_LOGGING_CATEGORY(name); \ + } \ + const QLoggingCategory& name() + +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +#define QS_LOGGING_CATEGORY(name, category, ...) \ + namespace qslogcat { \ + Q_LOGGING_CATEGORY(name, category __VA_OPT__(, __VA_ARGS__)); \ + } \ + const QLoggingCategory& name() { \ + static auto* init = []() { \ + qs::log::initLogCategoryLevel(category __VA_OPT__(, __VA_ARGS__)); \ + return &qslogcat::name; \ + }(); \ + return (init) (); \ + } diff --git a/src/core/logging.cpp b/src/core/logging.cpp index 6fe80ca..1b19fab 100644 --- a/src/core/logging.cpp +++ b/src/core/logging.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -27,20 +28,78 @@ #include #include #include +#ifdef __linux__ #include +#include +#endif +#ifdef __FreeBSD__ +#include +#endif #include "instanceinfo.hpp" +#include "logcat.hpp" #include "logging_p.hpp" #include "logging_qtprivate.cpp" // NOLINT #include "paths.hpp" #include "ringbuf.hpp" -Q_LOGGING_CATEGORY(logBare, "quickshell.bare"); +QS_LOGGING_CATEGORY(logBare, "quickshell.bare"); namespace qs::log { using namespace qt_logging_registry; -Q_LOGGING_CATEGORY(logLogging, "quickshell.logging", QtWarningMsg); +QS_LOGGING_CATEGORY(logLogging, "quickshell.logging", QtWarningMsg); + +namespace { +bool copyFileData(int sourceFd, int destFd, qint64 size) { + auto usize = static_cast(size); + +#ifdef __linux__ + off_t offset = 0; + auto remaining = usize; + + while (remaining > 0) { + auto r = sendfile(destFd, sourceFd, &offset, remaining); + if (r == -1) { + if (errno == EINTR) continue; + return false; + } + if (r == 0) break; + remaining -= static_cast(r); + } + + return true; +#else + std::array buffer = {}; + auto remaining = usize; + + while (remaining > 0) { + auto chunk = std::min(remaining, buffer.size()); + auto r = ::read(sourceFd, buffer.data(), chunk); + if (r == -1) { + if (errno == EINTR) continue; + return false; + } + if (r == 0) break; + + auto readBytes = static_cast(r); + size_t written = 0; + while (written < readBytes) { + auto w = ::write(destFd, buffer.data() + written, readBytes - written); + if (w == -1) { + if (errno == EINTR) continue; + return false; + } + written += static_cast(w); + } + + remaining -= readBytes; + } + + return true; +#endif +} +} // namespace bool LogMessage::operator==(const LogMessage& other) const { // note: not including time @@ -162,6 +221,7 @@ void LogManager::messageHandler( } if (display) { + auto locker = QMutexLocker(&self->stdoutMutex); LogMessage::formatMessage( self->stdoutStream, message, @@ -182,17 +242,24 @@ void LogManager::filterCategory(QLoggingCategory* category) { auto categoryName = QLatin1StringView(category->categoryName()); auto isQs = categoryName.startsWith(QLatin1StringView("quickshell.")); - if (instance->lastCategoryFilter) { - instance->lastCategoryFilter(category); - } - - auto filter = CategoryFilter(category); + CategoryFilter filter; + // We don't respect log filters for qs logs because some distros like to ship + // default configs that hide everything. QT_LOGGING_RULES is considered via the filter list. if (isQs) { - filter.debug = filter.debug || instance->mDefaultLevel == QtDebugMsg; - filter.info = filter.debug || instance->mDefaultLevel == QtInfoMsg; - filter.warn = filter.info || instance->mDefaultLevel == QtWarningMsg; - filter.critical = filter.warn || instance->mDefaultLevel == QtCriticalMsg; + // QtDebugMsg == 0, so default + auto defaultLevel = instance->defaultLevels.value(categoryName); + + filter = CategoryFilter(); + // clang-format off + filter.debug = instance->mDefaultLevel == QtDebugMsg || defaultLevel == QtDebugMsg; + filter.info = filter.debug || instance->mDefaultLevel == QtInfoMsg || defaultLevel == QtInfoMsg; + filter.warn = filter.info || instance->mDefaultLevel == QtWarningMsg || defaultLevel == QtWarningMsg; + filter.critical = filter.warn || instance->mDefaultLevel == QtCriticalMsg || defaultLevel == QtCriticalMsg; + // clang-format on + } else if (instance->lastCategoryFilter) { + instance->lastCategoryFilter(category); + filter = CategoryFilter(category); } for (const auto& rule: *instance->rules) { @@ -235,14 +302,23 @@ void LogManager::init( { QLoggingSettingsParser parser; - parser.setContent(rules); + // Load QT_LOGGING_RULES because we ignore the last category filter for QS messages + // due to disk config files. + parser.setContent(qEnvironmentVariable("QT_LOGGING_RULES")); instance->rules = new QList(parser.rules()); + parser.setContent(rules); + instance->rules->append(parser.rules()); + } + + instance->lastCategoryFilter = QLoggingCategory::installFilter(&LogManager::filterCategory); + + if (instance->lastCategoryFilter == &LogManager::filterCategory) { + qCFatal(logLogging) << "Quickshell's log filter has been installed twice. This is a bug."; + instance->lastCategoryFilter = nullptr; } qInstallMessageHandler(&LogManager::messageHandler); - instance->lastCategoryFilter = QLoggingCategory::installFilter(&LogManager::filterCategory); - qCDebug(logLogging) << "Creating offthread logger..."; auto* thread = new QThread(); instance->threadProxy.moveToThread(thread); @@ -257,6 +333,10 @@ void LogManager::init( qCDebug(logLogging) << "Logger initialized."; } +void initLogCategoryLevel(const char* name, QtMsgType defaultLevel) { + LogManager::instance()->defaultLevels.insert(QLatin1StringView(name), defaultLevel); +} + void LogManager::initFs() { QMetaObject::invokeMethod( &LogManager::instance()->threadProxy, @@ -297,8 +377,12 @@ void ThreadLogging::init() { if (logMfd != -1) { this->file = new QFile(); - this->file->open(logMfd, QFile::ReadWrite, QFile::AutoCloseHandle); - this->fileStream.setDevice(this->file); + + if (this->file->open(logMfd, QFile::ReadWrite, QFile::AutoCloseHandle)) { + this->fileStream.setDevice(this->file); + } else { + qCCritical(logLogging) << "Failed to open early logging memfd."; + } } if (dlogMfd != -1) { @@ -306,14 +390,19 @@ void ThreadLogging::init() { this->detailedFile = new QFile(); // buffered by WriteBuffer - this->detailedFile->open(dlogMfd, QFile::ReadWrite | QFile::Unbuffered, QFile::AutoCloseHandle); - this->detailedWriter.setDevice(this->detailedFile); + if (this->detailedFile + ->open(dlogMfd, QFile::ReadWrite | QFile::Unbuffered, QFile::AutoCloseHandle)) + { + this->detailedWriter.setDevice(this->detailedFile); - if (!this->detailedWriter.writeHeader()) { - qCCritical(logLogging) << "Could not write header for detailed logs."; - this->detailedWriter.setDevice(nullptr); - delete this->detailedFile; - this->detailedFile = nullptr; + if (!this->detailedWriter.writeHeader()) { + qCCritical(logLogging) << "Could not write header for detailed logs."; + this->detailedWriter.setDevice(nullptr); + delete this->detailedFile; + this->detailedFile = nullptr; + } + } else { + qCCritical(logLogging) << "Failed to open early detailed logging memfd."; } } @@ -336,7 +425,8 @@ void ThreadLogging::initFs() { auto* runDir = QsPaths::instance()->instanceRunDir(); if (!runDir) { - qCCritical(logLogging + qCCritical( + logLogging ) << "Could not start filesystem logging as the runtime directory could not be created."; return; } @@ -347,7 +437,8 @@ void ThreadLogging::initFs() { auto* detailedFile = new QFile(detailedPath); if (!file->open(QFile::ReadWrite | QFile::Truncate)) { - qCCritical(logLogging + qCCritical( + logLogging ) << "Could not start filesystem logger as the log file could not be created:" << path; delete file; @@ -358,13 +449,14 @@ void ThreadLogging::initFs() { // buffered by WriteBuffer if (!detailedFile->open(QFile::ReadWrite | QFile::Truncate | QFile::Unbuffered)) { - qCCritical(logLogging + qCCritical( + logLogging ) << "Could not start detailed filesystem logger as the log file could not be created:" << detailedPath; delete detailedFile; detailedFile = nullptr; } else { - auto lock = flock { + struct flock lock = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, @@ -386,7 +478,11 @@ void ThreadLogging::initFs() { auto* oldFile = this->file; if (oldFile) { oldFile->seek(0); - sendfile(file->handle(), oldFile->handle(), nullptr, oldFile->size()); + + if (!copyFileData(oldFile->handle(), file->handle(), oldFile->size())) { + qCritical(logLogging) << "Failed to copy log from memfd with error code " << errno + << qt_error_string(errno); + } } this->file = file; @@ -398,7 +494,10 @@ void ThreadLogging::initFs() { auto* oldFile = this->detailedFile; if (oldFile) { oldFile->seek(0); - sendfile(detailedFile->handle(), oldFile->handle(), nullptr, oldFile->size()); + if (!copyFileData(oldFile->handle(), detailedFile->handle(), oldFile->size())) { + qCritical(logLogging) << "Failed to copy detailed log from memfd with error code " << errno + << qt_error_string(errno); + } } crash::CrashInfo::INSTANCE.logFd = detailedFile->handle(); @@ -441,10 +540,14 @@ void ThreadLogging::onMessage(const LogMessage& msg, bool showInSparse) { this->fileStream << Qt::endl; } - if (this->detailedWriter.write(msg)) { - this->detailedFile->flush(); - } else if (this->detailedFile != nullptr) { - qCCritical(logLogging) << "Detailed logger failed to write. Ending detailed logs."; + if (!this->detailedWriter.write(msg) || (this->detailedFile && !this->detailedFile->flush())) { + this->detailedWriter.setDevice(nullptr); + + if (this->detailedFile) { + this->detailedFile->close(); + this->detailedFile = nullptr; + qCCritical(logLogging) << "Detailed logger failed to write. Ending detailed logs."; + } } } @@ -717,11 +820,11 @@ bool EncodedLogReader::readVarInt(quint32* slot) { if (!this->reader.skip(1)) return false; *slot = qFromLittleEndian(n); } else if ((bytes[1] != 0xff || bytes[2] != 0xff) && readLength >= 3) { - auto n = *reinterpret_cast(bytes.data() + 1); + auto n = *reinterpret_cast(bytes.data() + 1); // NOLINT if (!this->reader.skip(3)) return false; *slot = qFromLittleEndian(n); } else if (readLength == 7) { - auto n = *reinterpret_cast(bytes.data() + 3); + auto n = *reinterpret_cast(bytes.data() + 3); // NOLINT if (!this->reader.skip(7)) return false; *slot = qFromLittleEndian(n); } else return false; @@ -857,7 +960,7 @@ bool LogReader::continueReading() { } void LogFollower::FcntlWaitThread::run() { - auto lock = flock { + struct flock lock = { .l_type = F_RDLCK, // won't block other read locks when we take it .l_whence = SEEK_SET, .l_start = 0, diff --git a/src/core/logging.hpp b/src/core/logging.hpp index 7ff1b5e..7b6a758 100644 --- a/src/core/logging.hpp +++ b/src/core/logging.hpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -9,10 +10,13 @@ #include #include #include +#include #include #include -Q_DECLARE_LOGGING_CATEGORY(logBare); +#include "logcat.hpp" + +QS_DECLARE_LOGGING_CATEGORY(logBare); namespace qs::log { @@ -127,11 +131,15 @@ private: QString mRulesString; QList* rules = nullptr; QtMsgType mDefaultLevel = QtWarningMsg; + QHash defaultLevels; QHash sparseFilters; QHash allFilters; QTextStream stdoutStream; + QMutex stdoutMutex; LoggingThreadProxy threadProxy; + + friend void initLogCategoryLevel(const char* name, QtMsgType defaultLevel); }; bool readEncodedLogs( diff --git a/src/core/logging_qtprivate.cpp b/src/core/logging_qtprivate.cpp index 5078eeb..48f74de 100644 --- a/src/core/logging_qtprivate.cpp +++ b/src/core/logging_qtprivate.cpp @@ -16,10 +16,11 @@ #include #include +#include "logcat.hpp" #include "logging_qtprivate.hpp" namespace qs::log { -Q_DECLARE_LOGGING_CATEGORY(logLogging); +QS_DECLARE_LOGGING_CATEGORY(logLogging); namespace qt_logging_registry { diff --git a/src/core/logging_qtprivate.hpp b/src/core/logging_qtprivate.hpp index 83c8258..61d3a7c 100644 --- a/src/core/logging_qtprivate.hpp +++ b/src/core/logging_qtprivate.hpp @@ -12,8 +12,10 @@ #include #include +#include "logcat.hpp" + namespace qs::log { -Q_DECLARE_LOGGING_CATEGORY(logLogging); +QS_DECLARE_LOGGING_CATEGORY(logLogging); namespace qt_logging_registry { diff --git a/src/core/model.cpp b/src/core/model.cpp index 165c606..47ef060 100644 --- a/src/core/model.cpp +++ b/src/core/model.cpp @@ -1,81 +1,14 @@ #include "model.hpp" -#include +#include #include #include -#include -#include -#include -#include -#include - -qint32 UntypedObjectModel::rowCount(const QModelIndex& parent) const { - if (parent != QModelIndex()) return 0; - return static_cast(this->valuesList.length()); -} - -QVariant UntypedObjectModel::data(const QModelIndex& index, qint32 role) const { - if (role != Qt::UserRole) return QVariant(); - return QVariant::fromValue(this->valuesList.at(index.row())); -} QHash UntypedObjectModel::roleNames() const { return {{Qt::UserRole, "modelData"}}; } -void UntypedObjectModel::insertObject(QObject* object, qsizetype index) { - auto iindex = index == -1 ? this->valuesList.length() : index; - emit this->objectInsertedPre(object, iindex); - - auto intIndex = static_cast(iindex); - this->beginInsertRows(QModelIndex(), intIndex, intIndex); - this->valuesList.insert(iindex, object); - this->endInsertRows(); - - emit this->valuesChanged(); - emit this->objectInsertedPost(object, iindex); -} - -void UntypedObjectModel::removeAt(qsizetype index) { - auto* object = this->valuesList.at(index); - emit this->objectRemovedPre(object, index); - - auto intIndex = static_cast(index); - this->beginRemoveRows(QModelIndex(), intIndex, intIndex); - this->valuesList.removeAt(index); - this->endRemoveRows(); - - emit this->valuesChanged(); - emit this->objectRemovedPost(object, index); -} - -bool UntypedObjectModel::removeObject(const QObject* object) { - auto index = this->valuesList.indexOf(object); - if (index == -1) return false; - - this->removeAt(index); - return true; -} - -void UntypedObjectModel::diffUpdate(const QVector& newValues) { - for (qsizetype i = 0; i < this->valuesList.length();) { - if (newValues.contains(this->valuesList.at(i))) i++; - else this->removeAt(i); - } - - qsizetype oi = 0; - for (auto* object: newValues) { - if (this->valuesList.length() == oi || this->valuesList.at(oi) != object) { - this->insertObject(object, oi); - } - - oi++; - } -} - -qsizetype UntypedObjectModel::indexOf(QObject* object) { return this->valuesList.indexOf(object); } - UntypedObjectModel* UntypedObjectModel::emptyInstance() { - static auto* instance = new UntypedObjectModel(nullptr); // NOLINT + static auto* instance = new ObjectModel(nullptr); return instance; } diff --git a/src/core/model.hpp b/src/core/model.hpp index 6346c96..0e88025 100644 --- a/src/core/model.hpp +++ b/src/core/model.hpp @@ -2,7 +2,7 @@ #include -#include +#include #include #include #include @@ -49,14 +49,11 @@ class UntypedObjectModel: public QAbstractListModel { public: explicit UntypedObjectModel(QObject* parent): QAbstractListModel(parent) {} - [[nodiscard]] qint32 rowCount(const QModelIndex& parent) const override; - [[nodiscard]] QVariant data(const QModelIndex& index, qint32 role) const override; [[nodiscard]] QHash roleNames() const override; - [[nodiscard]] QList values() const { return this->valuesList; }; - void removeAt(qsizetype index); + [[nodiscard]] virtual QList values() = 0; - Q_INVOKABLE qsizetype indexOf(QObject* object); + Q_INVOKABLE virtual qsizetype indexOf(QObject* object) const = 0; static UntypedObjectModel* emptyInstance(); @@ -71,15 +68,6 @@ signals: /// Sent immediately after an object is removed from the list. void objectRemovedPost(QObject* object, qsizetype index); -protected: - void insertObject(QObject* object, qsizetype index = -1); - bool removeObject(const QObject* object); - - // Assumes only one instance of a specific value - void diffUpdate(const QVector& newValues); - - QVector valuesList; - private: static qsizetype valuesCount(QQmlListProperty* property); static QObject* valueAt(QQmlListProperty* property, qsizetype index); @@ -90,14 +78,20 @@ class ObjectModel: public UntypedObjectModel { public: explicit ObjectModel(QObject* parent): UntypedObjectModel(parent) {} - [[nodiscard]] QVector& valueList() { return *std::bit_cast*>(&this->valuesList); } - - [[nodiscard]] const QVector& valueList() const { - return *std::bit_cast*>(&this->valuesList); - } + [[nodiscard]] const QList& valueList() const { return this->mValuesList; } + [[nodiscard]] QList& valueList() { return this->mValuesList; } void insertObject(T* object, qsizetype index = -1) { - this->UntypedObjectModel::insertObject(object, index); + auto iindex = index == -1 ? this->mValuesList.length() : index; + emit this->objectInsertedPre(object, iindex); + + auto intIndex = static_cast(iindex); + this->beginInsertRows(QModelIndex(), intIndex, intIndex); + this->mValuesList.insert(iindex, object); + this->endInsertRows(); + + emit this->valuesChanged(); + emit this->objectInsertedPost(object, iindex); } void insertObjectSorted(T* object, const std::function& compare) { @@ -110,17 +104,71 @@ public: } auto idx = iter - list.begin(); - this->UntypedObjectModel::insertObject(object, idx); + this->insertObject(object, idx); } - void removeObject(const T* object) { this->UntypedObjectModel::removeObject(object); } + bool removeObject(const T* object) { + auto index = this->mValuesList.indexOf(object); + if (index == -1) return false; + + this->removeAt(index); + return true; + } + + void removeAt(qsizetype index) { + auto* object = this->mValuesList.at(index); + emit this->objectRemovedPre(object, index); + + auto intIndex = static_cast(index); + this->beginRemoveRows(QModelIndex(), intIndex, intIndex); + this->mValuesList.removeAt(index); + this->endRemoveRows(); + + emit this->valuesChanged(); + emit this->objectRemovedPost(object, index); + } // Assumes only one instance of a specific value - void diffUpdate(const QVector& newValues) { - this->UntypedObjectModel::diffUpdate(*std::bit_cast*>(&newValues)); + void diffUpdate(const QList& newValues) { + for (qsizetype i = 0; i < this->mValuesList.length();) { + if (newValues.contains(this->mValuesList.at(i))) i++; + else this->removeAt(i); + } + + qsizetype oi = 0; + for (auto* object: newValues) { + if (this->mValuesList.length() == oi || this->mValuesList.at(oi) != object) { + this->insertObject(object, oi); + } + + oi++; + } } static ObjectModel* emptyInstance() { return static_cast*>(UntypedObjectModel::emptyInstance()); } + + [[nodiscard]] qint32 rowCount(const QModelIndex& parent) const override { + if (parent != QModelIndex()) return 0; + return static_cast(this->mValuesList.length()); + } + + [[nodiscard]] QVariant data(const QModelIndex& index, qint32 role) const override { + if (role != Qt::UserRole) return QVariant(); + // Values must be QObject derived, but we can't assert that here without breaking forward decls, + // so no static_cast. + return QVariant::fromValue(reinterpret_cast(this->mValuesList.at(index.row()))); + } + + qsizetype indexOf(QObject* object) const override { + return this->mValuesList.indexOf(reinterpret_cast(object)); + } + + [[nodiscard]] QList values() override { + return *reinterpret_cast*>(&this->mValuesList); + } + +private: + QList mValuesList; }; diff --git a/src/core/module.md b/src/core/module.md index b9404ea..41f065d 100644 --- a/src/core/module.md +++ b/src/core/module.md @@ -21,7 +21,6 @@ headers = [ "model.hpp", "elapsedtimer.hpp", "desktopentry.hpp", - "objectrepeater.hpp", "qsmenu.hpp", "retainable.hpp", "popupanchor.hpp", diff --git a/src/core/objectrepeater.cpp b/src/core/objectrepeater.cpp deleted file mode 100644 index 7971952..0000000 --- a/src/core/objectrepeater.cpp +++ /dev/null @@ -1,190 +0,0 @@ -#include "objectrepeater.hpp" -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -QVariant ObjectRepeater::model() const { return this->mModel; } - -void ObjectRepeater::setModel(QVariant model) { - if (model == this->mModel) return; - - if (this->itemModel != nullptr) { - QObject::disconnect(this->itemModel, nullptr, this, nullptr); - } - - this->mModel = std::move(model); - emit this->modelChanged(); - this->reloadElements(); -} - -void ObjectRepeater::onModelDestroyed() { - this->mModel.clear(); - this->itemModel = nullptr; - emit this->modelChanged(); - this->reloadElements(); -} - -QQmlComponent* ObjectRepeater::delegate() const { return this->mDelegate; } - -void ObjectRepeater::setDelegate(QQmlComponent* delegate) { - if (delegate == this->mDelegate) return; - - if (this->mDelegate != nullptr) { - QObject::disconnect(this->mDelegate, nullptr, this, nullptr); - } - - this->mDelegate = delegate; - - if (delegate != nullptr) { - QObject::connect( - this->mDelegate, - &QObject::destroyed, - this, - &ObjectRepeater::onDelegateDestroyed - ); - } - - emit this->delegateChanged(); - this->reloadElements(); -} - -void ObjectRepeater::onDelegateDestroyed() { - this->mDelegate = nullptr; - emit this->delegateChanged(); - this->reloadElements(); -} - -void ObjectRepeater::reloadElements() { - for (auto i = this->valuesList.length() - 1; i >= 0; i--) { - this->removeComponent(i); - } - - if (this->mDelegate == nullptr || !this->mModel.isValid()) return; - - if (this->mModel.canConvert()) { - auto* model = this->mModel.value(); - this->itemModel = model; - - this->insertModelElements(model, 0, model->rowCount() - 1); // -1 is fine - - // clang-format off - QObject::connect(model, &QObject::destroyed, this, &ObjectRepeater::onModelDestroyed); - QObject::connect(model, &QAbstractItemModel::rowsInserted, this, &ObjectRepeater::onModelRowsInserted); - QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &ObjectRepeater::onModelRowsRemoved); - QObject::connect(model, &QAbstractItemModel::rowsMoved, this, &ObjectRepeater::onModelRowsMoved); - QObject::connect(model, &QAbstractItemModel::modelAboutToBeReset, this, &ObjectRepeater::onModelAboutToBeReset); - // clang-format on - } else if (this->mModel.canConvert()) { - auto values = this->mModel.value(); - auto len = values.count(); - - for (auto i = 0; i != len; i++) { - this->insertComponent(i, {{"modelData", QVariant::fromValue(values.at(i))}}); - } - } else if (this->mModel.canConvert>()) { - auto values = this->mModel.value>(); - - for (auto& value: values) { - this->insertComponent(this->valuesList.length(), {{"modelData", value}}); - } - } else { - qCritical() << this - << "Cannot create components as the model is not compatible:" << this->mModel; - } -} - -void ObjectRepeater::insertModelElements(QAbstractItemModel* model, int first, int last) { - auto roles = model->roleNames(); - auto roleDataVec = QVector(); - for (auto id: roles.keys()) { - roleDataVec.push_back(QModelRoleData(id)); - } - - auto values = QModelRoleDataSpan(roleDataVec); - auto props = QVariantMap(); - - for (auto i = first; i != last + 1; i++) { - auto index = model->index(i, 0); - model->multiData(index, values); - - for (auto [id, name]: roles.asKeyValueRange()) { - props.insert(name, *values.dataForRole(id)); - } - - this->insertComponent(i, props); - - props.clear(); - } -} - -void ObjectRepeater::onModelRowsInserted(const QModelIndex& parent, int first, int last) { - if (parent != QModelIndex()) return; - - this->insertModelElements(this->itemModel, first, last); -} - -void ObjectRepeater::onModelRowsRemoved(const QModelIndex& parent, int first, int last) { - if (parent != QModelIndex()) return; - - for (auto i = last; i != first - 1; i--) { - this->removeComponent(i); - } -} - -void ObjectRepeater::onModelRowsMoved( - const QModelIndex& sourceParent, - int sourceStart, - int sourceEnd, - const QModelIndex& destParent, - int destStart -) { - auto hasSource = sourceParent != QModelIndex(); - auto hasDest = destParent != QModelIndex(); - - if (!hasSource && !hasDest) return; - - if (hasSource) { - this->onModelRowsRemoved(sourceParent, sourceStart, sourceEnd); - } - - if (hasDest) { - this->onModelRowsInserted(destParent, destStart, destStart + (sourceEnd - sourceStart)); - } -} - -void ObjectRepeater::onModelAboutToBeReset() { - auto last = static_cast(this->valuesList.length() - 1); - this->onModelRowsRemoved(QModelIndex(), 0, last); // -1 is fine -} - -void ObjectRepeater::insertComponent(qsizetype index, const QVariantMap& properties) { - auto* context = QQmlEngine::contextForObject(this); - auto* instance = this->mDelegate->createWithInitialProperties(properties, context); - - if (instance == nullptr) { - qWarning().noquote() << this->mDelegate->errorString(); - qWarning() << this << "failed to create object for model data" << properties; - } else { - QQmlEngine::setObjectOwnership(instance, QQmlEngine::CppOwnership); - instance->setParent(this); - } - - this->insertObject(instance, index); -} - -void ObjectRepeater::removeComponent(qsizetype index) { - auto* instance = this->valuesList.at(index); - this->removeAt(index); - delete instance; -} diff --git a/src/core/objectrepeater.hpp b/src/core/objectrepeater.hpp deleted file mode 100644 index 409b12d..0000000 --- a/src/core/objectrepeater.hpp +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include "model.hpp" - -///! A Repeater / for loop / map for non Item derived objects. -/// > [!ERROR] Removed in favor of @@QtQml.Models.Instantiator -/// -/// The ObjectRepeater creates instances of the provided delegate for every entry in the -/// given model, similarly to a @@QtQuick.Repeater but for non visual types. -class ObjectRepeater: public ObjectModel { - Q_OBJECT; - /// The model providing data to the ObjectRepeater. - /// - /// Currently accepted model types are `list` lists, javascript arrays, - /// and [QAbstractListModel] derived models, though only one column will be repeated - /// from the latter. - /// - /// Note: @@ObjectModel is a [QAbstractListModel] with a single column. - /// - /// [QAbstractListModel]: https://doc.qt.io/qt-6/qabstractlistmodel.html - Q_PROPERTY(QVariant model READ model WRITE setModel NOTIFY modelChanged); - /// The delegate component to repeat. - /// - /// The delegate is given the same properties as in a Repeater, except `index` which - /// is not currently implemented. - /// - /// If the model is a `list` or javascript array, a `modelData` property will be - /// exposed containing the entry from the model. If the model is a [QAbstractListModel], - /// the roles from the model will be exposed. - /// - /// Note: @@ObjectModel has a single role named `modelData` for compatibility with normal lists. - /// - /// [QAbstractListModel]: https://doc.qt.io/qt-6/qabstractlistmodel.html - Q_PROPERTY(QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY delegateChanged); - Q_CLASSINFO("DefaultProperty", "delegate"); - QML_ELEMENT; - QML_UNCREATABLE("ObjectRepeater has been removed in favor of QtQml.Models.Instantiator."); - -public: - explicit ObjectRepeater(QObject* parent = nullptr): ObjectModel(parent) {} - - [[nodiscard]] QVariant model() const; - void setModel(QVariant model); - - [[nodiscard]] QQmlComponent* delegate() const; - void setDelegate(QQmlComponent* delegate); - -signals: - void modelChanged(); - void delegateChanged(); - -private slots: - void onDelegateDestroyed(); - void onModelDestroyed(); - void onModelRowsInserted(const QModelIndex& parent, int first, int last); - void onModelRowsRemoved(const QModelIndex& parent, int first, int last); - - void onModelRowsMoved( - const QModelIndex& sourceParent, - int sourceStart, - int sourceEnd, - const QModelIndex& destParent, - int destStart - ); - - void onModelAboutToBeReset(); - -private: - void reloadElements(); - void insertModelElements(QAbstractItemModel* model, int first, int last); - void insertComponent(qsizetype index, const QVariantMap& properties); - void removeComponent(qsizetype index); - - QVariant mModel; - QAbstractItemModel* itemModel = nullptr; - QQmlComponent* mDelegate = nullptr; -}; diff --git a/src/core/paths.cpp b/src/core/paths.cpp index 689d99e..d361e3d 100644 --- a/src/core/paths.cpp +++ b/src/core/paths.cpp @@ -9,15 +9,17 @@ #include #include #include +#include #include #include #include #include #include "instanceinfo.hpp" +#include "logcat.hpp" namespace { -Q_LOGGING_CATEGORY(logPaths, "quickshell.paths", QtWarningMsg); +QS_LOGGING_CATEGORY(logPaths, "quickshell.paths", QtWarningMsg); } QsPaths* QsPaths::instance() { @@ -25,12 +27,19 @@ QsPaths* QsPaths::instance() { return instance; } -void QsPaths::init(QString shellId, QString pathId, QString dataOverride, QString stateOverride) { +void QsPaths::init( + QString shellId, + QString pathId, + QString dataOverride, + QString stateOverride, + QString cacheOverride +) { auto* instance = QsPaths::instance(); instance->shellId = std::move(shellId); instance->pathId = std::move(pathId); instance->shellDataOverride = std::move(dataOverride); instance->shellStateOverride = std::move(stateOverride); + instance->shellCacheOverride = std::move(cacheOverride); } QDir QsPaths::crashDir(const QString& id) { @@ -55,7 +64,7 @@ QDir* QsPaths::baseRunDir() { if (this->baseRunState == DirState::Unknown) { auto runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); if (runtimeDir.isEmpty()) { - runtimeDir = QString("/run/user/$1").arg(getuid()); + runtimeDir = QString("/run/user/%1").arg(getuid()); qCInfo(logPaths) << "XDG_RUNTIME_DIR was not set, defaulting to" << runtimeDir; } @@ -133,13 +142,41 @@ QDir* QsPaths::instanceRunDir() { else return &this->mInstanceRunDir; } +QDir* QsPaths::shellVfsDir() { + if (this->shellVfsState == DirState::Unknown) { + if (auto* baseRunDir = this->baseRunDir()) { + this->mShellVfsDir = QDir(baseRunDir->filePath("vfs")); + this->mShellVfsDir = QDir(this->mShellVfsDir.filePath(this->shellId)); + + qCDebug(logPaths) << "Initialized runtime vfs path:" << this->mShellVfsDir.path(); + + if (!this->mShellVfsDir.mkpath(".")) { + qCCritical(logPaths) << "Could not create runtime vfs directory at" + << this->mShellVfsDir.path(); + this->shellVfsState = DirState::Failed; + } else { + this->shellVfsState = DirState::Ready; + } + } else { + qCCritical(logPaths) << "Could not create shell runtime vfs path as it was not possible to " + "create the base runtime path."; + + this->shellVfsState = DirState::Failed; + } + } + + if (this->shellVfsState == DirState::Failed) return nullptr; + else return &this->mShellVfsDir; +} + void QsPaths::linkRunDir() { if (auto* runDir = this->instanceRunDir()) { auto pidDir = QDir(this->baseRunDir()->filePath("by-pid")); auto* shellDir = this->shellRunDir(); if (!shellDir) { - qCCritical(logPaths + qCCritical( + logPaths ) << "Could not create by-id symlink as the shell runtime path could not be created."; } else { auto shellPath = shellDir->filePath(runDir->dirName()); @@ -287,9 +324,16 @@ QDir QsPaths::shellStateDir() { QDir QsPaths::shellCacheDir() { if (this->shellCacheState == DirState::Unknown) { - auto dir = QDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); - dir = QDir(dir.filePath("by-shell")); - dir = QDir(dir.filePath(this->shellId)); + QDir dir; + if (this->shellCacheOverride.isEmpty()) { + dir = QDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); + dir = QDir(dir.filePath("by-shell")); + dir = QDir(dir.filePath(this->shellId)); + } else { + auto basedir = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation); + dir = QDir(this->shellCacheOverride.replace("$BASE", basedir)); + } + this->mShellCacheDir = dir; qCDebug(logPaths) << "Initialized cache path:" << dir.path(); @@ -317,7 +361,7 @@ void QsPaths::createLock() { return; } - auto lock = flock { + struct flock lock = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, @@ -335,7 +379,8 @@ void QsPaths::createLock() { qCDebug(logPaths) << "Created instance lock at" << path; } } else { - qCCritical(logPaths + qCCritical( + logPaths ) << "Could not create instance lock, as the instance runtime directory could not be created."; } } @@ -344,7 +389,7 @@ bool QsPaths::checkLock(const QString& path, InstanceLockInfo* info, bool allowD auto file = QFile(QDir(path).filePath("instance.lock")); if (!file.open(QFile::ReadOnly)) return false; - auto lock = flock { + struct flock lock = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, @@ -367,29 +412,35 @@ bool QsPaths::checkLock(const QString& path, InstanceLockInfo* info, bool allowD return true; } -QVector QsPaths::collectInstances(const QString& path, bool fallbackDead) { +QPair, QVector> +QsPaths::collectInstances(const QString& path, const QString& display) { qCDebug(logPaths) << "Collecting instances from" << path; - auto instances = QVector(); + auto liveInstances = QVector(); + auto deadInstances = QVector(); auto dir = QDir(path); InstanceLockInfo info; for (auto& entry: dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { auto path = dir.filePath(entry); - if (QsPaths::checkLock(path, &info, fallbackDead)) { - if (fallbackDead && info.pid != -1) { - fallbackDead = false; - instances.clear(); - } - + if (QsPaths::checkLock(path, &info, true)) { qCDebug(logPaths).nospace() << "Found instance " << info.instance.instanceId << " (pid " << info.pid << ") at " << path; - instances.push_back(info); + if (!display.isEmpty() && info.instance.display != display) { + qCDebug(logPaths) << "Skipped instance with mismatched display at" << path; + continue; + } + + if (info.pid == -1) { + deadInstances.push_back(info); + } else { + liveInstances.push_back(info); + } } else { qCDebug(logPaths) << "Skipped potential instance at" << path; } } - return instances; + return qMakePair(liveInstances, deadInstances); } diff --git a/src/core/paths.hpp b/src/core/paths.hpp index baaf9b2..c2500ed 100644 --- a/src/core/paths.hpp +++ b/src/core/paths.hpp @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include #include "instanceinfo.hpp" @@ -16,16 +17,24 @@ QDataStream& operator>>(QDataStream& stream, InstanceLockInfo& info); class QsPaths { public: static QsPaths* instance(); - static void init(QString shellId, QString pathId, QString dataOverride, QString stateOverride); + static void init( + QString shellId, + QString pathId, + QString dataOverride, + QString stateOverride, + QString cacheOverride + ); static QDir crashDir(const QString& id); static QString basePath(const QString& id); static QString ipcPath(const QString& id); static bool checkLock(const QString& path, InstanceLockInfo* info = nullptr, bool allowDead = false); - static QVector collectInstances(const QString& path, bool fallbackDead = false); + static QPair, QVector> + collectInstances(const QString& path, const QString& display); QDir* baseRunDir(); QDir* shellRunDir(); + QDir* shellVfsDir(); QDir* instanceRunDir(); void linkRunDir(); void linkPathDir(); @@ -46,9 +55,11 @@ private: QString pathId; QDir mBaseRunDir; QDir mShellRunDir; + QDir mShellVfsDir; QDir mInstanceRunDir; DirState baseRunState = DirState::Unknown; DirState shellRunState = DirState::Unknown; + DirState shellVfsState = DirState::Unknown; DirState instanceRunState = DirState::Unknown; QDir mShellDataDir; @@ -60,4 +71,5 @@ private: QString shellDataOverride; QString shellStateOverride; + QString shellCacheOverride; }; diff --git a/src/core/platformmenu.cpp b/src/core/platformmenu.cpp index 427dde0..d8901e2 100644 --- a/src/core/platformmenu.cpp +++ b/src/core/platformmenu.cpp @@ -18,7 +18,6 @@ #include #include "../window/proxywindow.hpp" -#include "../window/windowinterface.hpp" #include "iconprovider.hpp" #include "model.hpp" #include "platformmenu_p.hpp" @@ -91,10 +90,8 @@ bool PlatformMenuEntry::display(QObject* parentWindow, int relativeX, int relati } else if (parentWindow == nullptr) { qCritical() << "Cannot display PlatformMenuEntry with null parent window."; return false; - } else if (auto* proxy = qobject_cast(parentWindow)) { + } else if (auto* proxy = ProxyWindowBase::forObject(parentWindow)) { window = proxy->backingWindow(); - } else if (auto* interface = qobject_cast(parentWindow)) { - window = interface->proxyWindow()->backingWindow(); } else { qCritical() << "PlatformMenuEntry.display() must be called with a window."; return false; diff --git a/src/core/plugin.cpp b/src/core/plugin.cpp index 0eb9a06..e6cd1bb 100644 --- a/src/core/plugin.cpp +++ b/src/core/plugin.cpp @@ -9,6 +9,18 @@ static QVector plugins; // NOLINT void QsEnginePlugin::registerPlugin(QsEnginePlugin& plugin) { plugins.push_back(&plugin); } +void QsEnginePlugin::preinitPluginsOnly() { + plugins.removeIf([](QsEnginePlugin* plugin) { return !plugin->applies(); }); + + std::ranges::sort(plugins, [](QsEnginePlugin* a, QsEnginePlugin* b) { + return b->dependencies().contains(a->name()); + }); + + for (QsEnginePlugin* plugin: plugins) { + plugin->preinit(); + } +} + void QsEnginePlugin::initPlugins() { plugins.removeIf([](QsEnginePlugin* plugin) { return !plugin->applies(); }); @@ -16,6 +28,10 @@ void QsEnginePlugin::initPlugins() { return b->dependencies().contains(a->name()); }); + for (QsEnginePlugin* plugin: plugins) { + plugin->preinit(); + } + for (QsEnginePlugin* plugin: plugins) { plugin->init(); } diff --git a/src/core/plugin.hpp b/src/core/plugin.hpp index f0c14dc..f692e91 100644 --- a/src/core/plugin.hpp +++ b/src/core/plugin.hpp @@ -18,12 +18,14 @@ public: virtual QString name() { return QString(); } virtual QList dependencies() { return {}; } virtual bool applies() { return true; } + virtual void preinit() {} virtual void init() {} virtual void registerTypes() {} virtual void constructGeneration(EngineGeneration& /*unused*/) {} // NOLINT virtual void onReload() {} static void registerPlugin(QsEnginePlugin& plugin); + static void preinitPluginsOnly(); static void initPlugins(); static void runConstructGeneration(EngineGeneration& generation); static void runOnReload(); diff --git a/src/core/popupanchor.cpp b/src/core/popupanchor.cpp index a1ada03..ca817c9 100644 --- a/src/core/popupanchor.cpp +++ b/src/core/popupanchor.cpp @@ -11,7 +11,6 @@ #include #include "../window/proxywindow.hpp" -#include "../window/windowinterface.hpp" #include "types.hpp" bool PopupAnchorState::operator==(const PopupAnchorState& other) const { @@ -28,7 +27,7 @@ void PopupAnchor::markClean() { this->lastState = this->state; } void PopupAnchor::markDirty() { this->lastState.reset(); } QWindow* PopupAnchor::backingWindow() const { - return this->mProxyWindow ? this->mProxyWindow->backingWindow() : nullptr; + return this->bProxyWindow ? this->bProxyWindow->backingWindow() : nullptr; } void PopupAnchor::setWindowInternal(QObject* window) { @@ -36,14 +35,12 @@ void PopupAnchor::setWindowInternal(QObject* window) { if (this->mWindow) { QObject::disconnect(this->mWindow, nullptr, this, nullptr); - QObject::disconnect(this->mProxyWindow, nullptr, this, nullptr); + QObject::disconnect(this->bProxyWindow, nullptr, this, nullptr); } if (window) { - if (auto* proxy = qobject_cast(window)) { - this->mProxyWindow = proxy; - } else if (auto* interface = qobject_cast(window)) { - this->mProxyWindow = interface->proxyWindow(); + if (auto* proxy = ProxyWindowBase::forObject(window)) { + this->bProxyWindow = proxy; } else { qWarning() << "Tried to set popup anchor window to" << window << "which is not a quickshell window."; @@ -55,7 +52,7 @@ void PopupAnchor::setWindowInternal(QObject* window) { QObject::connect(this->mWindow, &QObject::destroyed, this, &PopupAnchor::onWindowDestroyed); QObject::connect( - this->mProxyWindow, + this->bProxyWindow, &ProxyWindowBase::backerVisibilityChanged, this, &PopupAnchor::backingWindowVisibilityChanged @@ -70,7 +67,7 @@ void PopupAnchor::setWindowInternal(QObject* window) { setnull: if (this->mWindow) { this->mWindow = nullptr; - this->mProxyWindow = nullptr; + this->bProxyWindow = nullptr; emit this->windowChanged(); emit this->backingWindowVisibilityChanged(); @@ -100,7 +97,7 @@ void PopupAnchor::setItem(QQuickItem* item) { void PopupAnchor::onWindowDestroyed() { this->mWindow = nullptr; - this->mProxyWindow = nullptr; + this->bProxyWindow = nullptr; emit this->windowChanged(); emit this->backingWindowVisibilityChanged(); } @@ -186,14 +183,18 @@ void PopupAnchor::updatePlacement(const QPoint& anchorpoint, const QSize& size) } void PopupAnchor::updateAnchor() { - if (this->mItem && this->mProxyWindow) { + if (this->mItem && this->bProxyWindow) { auto baseRect = this->mUserRect.isEmpty() ? this->mItem->boundingRect() : this->mUserRect.qrect(); - auto rect = this->mProxyWindow->contentItem()->mapFromItem( + + auto rect = this->bProxyWindow->contentItem()->mapFromItem( this->mItem, baseRect.marginsRemoved(this->mMargins.qmargins()) ); + if (rect.width() < 1) rect.setWidth(1); + if (rect.height() < 1) rect.setHeight(1); + this->setWindowRect(rect.toRect()); } diff --git a/src/core/popupanchor.hpp b/src/core/popupanchor.hpp index a9b121e..9f08512 100644 --- a/src/core/popupanchor.hpp +++ b/src/core/popupanchor.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -139,7 +140,9 @@ public: void markDirty(); [[nodiscard]] QObject* window() const { return this->mWindow; } - [[nodiscard]] ProxyWindowBase* proxyWindow() const { return this->mProxyWindow; } + [[nodiscard]] QBindable bindableProxyWindow() const { + return &this->bProxyWindow; + } [[nodiscard]] QWindow* backingWindow() const; void setWindowInternal(QObject* window); void setWindow(QObject* window); @@ -193,11 +196,12 @@ private slots: private: QObject* mWindow = nullptr; QQuickItem* mItem = nullptr; - ProxyWindowBase* mProxyWindow = nullptr; PopupAnchorState state; Box mUserRect; Margins mMargins; std::optional lastState; + + Q_OBJECT_BINDABLE_PROPERTY(PopupAnchor, ProxyWindowBase*, bProxyWindow); }; class PopupPositioner { diff --git a/src/core/qmlglobal.cpp b/src/core/qmlglobal.cpp index 71626be..6cac3aa 100644 --- a/src/core/qmlglobal.cpp +++ b/src/core/qmlglobal.cpp @@ -8,8 +8,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -21,11 +23,14 @@ #include #include +#include "../io/processcore.hpp" #include "generation.hpp" #include "iconimageprovider.hpp" +#include "instanceinfo.hpp" #include "paths.hpp" #include "qmlscreen.hpp" #include "rootwrapper.hpp" +#include "scanenv.hpp" QuickshellSettings::QuickshellSettings() { QObject::connect( @@ -56,7 +61,9 @@ void QuickshellSettings::setWorkingDirectory(QString workingDirectory) { // NOLI emit this->workingDirectoryChanged(); } -bool QuickshellSettings::watchFiles() const { return this->mWatchFiles; } +bool QuickshellSettings::watchFiles() const { + return this->mWatchFiles && qEnvironmentVariableIsEmpty("QS_DISABLE_FILE_WATCHER"); +} void QuickshellSettings::setWatchFiles(bool watchFiles) { if (watchFiles == this->mWatchFiles) return; @@ -147,6 +154,22 @@ qint32 QuickshellGlobal::processId() const { // NOLINT return getpid(); } +QString QuickshellGlobal::instanceId() const { // NOLINT + return InstanceInfo::CURRENT.instanceId; +} + +QString QuickshellGlobal::shellId() const { // NOLINT + return InstanceInfo::CURRENT.shellId; +} + +QString QuickshellGlobal::appId() const { // NOLINT + return InstanceInfo::CURRENT.appId; +} + +QDateTime QuickshellGlobal::launchTime() const { // NOLINT + return InstanceInfo::CURRENT.launchTime; +} + qsizetype QuickshellGlobal::screensCount(QQmlListProperty* /*unused*/) { return QuickshellTracked::instance()->screens.size(); } @@ -177,12 +200,6 @@ void QuickshellGlobal::reload(bool hard) { root->reloadGraph(hard); } -QString QuickshellGlobal::shellRoot() const { - auto* generation = EngineGeneration::findObjectGeneration(this); - // already canonical - return generation->rootPath.path(); -} - QString QuickshellGlobal::workingDirectory() const { // NOLINT return QuickshellSettings::instance()->workingDirectory(); } @@ -213,6 +230,22 @@ void QuickshellGlobal::onClipboardChanged(QClipboard::Mode mode) { if (mode == QClipboard::Clipboard) emit this->clipboardTextChanged(); } +QString QuickshellGlobal::shellDir() const { + return EngineGeneration::findObjectGeneration(this)->rootPath.path(); +} + +QString QuickshellGlobal::configDir() const { + qWarning() << "Quickshell.configDir is deprecated and may be removed in a future release. Use " + "Quickshell.shellDir."; + return this->shellDir(); +} + +QString QuickshellGlobal::shellRoot() const { + qWarning() << "Quickshell.shellRoot is deprecated and may be removed in a future release. Use " + "Quickshell.shellDir."; + return this->shellDir(); +} + QString QuickshellGlobal::dataDir() const { // NOLINT return QsPaths::instance()->shellDataDir().path(); } @@ -225,6 +258,16 @@ QString QuickshellGlobal::cacheDir() const { // NOLINT return QsPaths::instance()->shellCacheDir().path(); } +QString QuickshellGlobal::shellPath(const QString& path) const { + return this->shellDir() % '/' % path; +} + +QString QuickshellGlobal::configPath(const QString& path) const { + qWarning() << "Quickshell.configPath() is deprecated and may be removed in a future release. Use " + "Quickshell.shellPath()."; + return this->shellPath(path); +} + QString QuickshellGlobal::dataPath(const QString& path) const { return this->dataDir() % '/' % path; } @@ -244,6 +287,39 @@ QVariant QuickshellGlobal::env(const QString& variable) { // NOLINT return qEnvironmentVariable(vstr.data()); } +void QuickshellGlobal::execDetached(QList command) { + QuickshellGlobal::execDetached(qs::io::process::ProcessContext(std::move(command))); +} + +void QuickshellGlobal::execDetached(const qs::io::process::ProcessContext& context) { + if (context.command.isEmpty()) { + qWarning() << "Cannot start process as command is empty."; + return; + } + + const auto& cmd = context.command.first(); + auto args = context.command.sliced(1); + + QProcess process; + qs::io::process::setupProcessEnvironment(&process, context.clearEnvironment, context.environment); + + if (!context.workingDirectory.isEmpty()) { + process.setWorkingDirectory(context.workingDirectory); + } + + process.setProgram(cmd); + process.setArguments(args); + + process.setStandardInputFile(QProcess::nullDevice()); + + if (context.unbindStdout) { + process.setStandardOutputFile(QProcess::nullDevice()); + process.setStandardErrorFile(QProcess::nullDevice()); + } + + process.startDetached(); +} + QString QuickshellGlobal::iconPath(const QString& icon) { return IconImageProvider::requestString(icon); } @@ -257,6 +333,16 @@ QString QuickshellGlobal::iconPath(const QString& icon, const QString& fallback) return IconImageProvider::requestString(icon, "", fallback); } +bool QuickshellGlobal::hasThemeIcon(const QString& icon) { return QIcon::hasThemeIcon(icon); } + +bool QuickshellGlobal::hasVersion(qint32 major, qint32 minor, const QStringList& features) { + return qs::scan::env::PreprocEnv::hasVersion(major, minor, features); +} + +bool QuickshellGlobal::hasVersion(qint32 major, qint32 minor) { + return QuickshellGlobal::hasVersion(major, minor, QStringList()); +} + QuickshellGlobal* QuickshellGlobal::create(QQmlEngine* engine, QJSEngine* /*unused*/) { auto* qsg = new QuickshellGlobal(); auto* generation = EngineGeneration::findEngineGeneration(engine); diff --git a/src/core/qmlglobal.hpp b/src/core/qmlglobal.hpp index 83a9718..72055df 100644 --- a/src/core/qmlglobal.hpp +++ b/src/core/qmlglobal.hpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include #include @@ -13,6 +15,9 @@ #include #include +#include "../io/processcore.hpp" +#include "doc.hpp" +#include "instanceinfo.hpp" #include "qmlscreen.hpp" ///! Accessor for some options under the Quickshell type. @@ -79,6 +84,21 @@ class QuickshellGlobal: public QObject { // clang-format off /// Quickshell's process id. Q_PROPERTY(qint32 processId READ processId CONSTANT); + /// A unique identifier for this Quickshell instance + Q_PROPERTY(QString instanceId READ instanceId CONSTANT) + /// The shell ID, used to differentiate between different shell configurations. + /// + /// Defaults to a stable value derived from the config path. + /// Can be overridden with `//@ pragma ShellId ` in the root qml file. + Q_PROPERTY(QString shellId READ shellId CONSTANT) + /// The desktop application ID. + /// + /// Defaults to `org.quickshell`. + /// Can be overridden with `//@ pragma AppId ` in the root qml file + /// or the `QS_APP_ID` environment variable. + Q_PROPERTY(QString appId READ appId CONSTANT) + /// The time at which this Quickshell instance was launched. + Q_PROPERTY(QDateTime launchTime READ launchTime CONSTANT) /// All currently connected screens. /// /// This property updates as connected screens change. @@ -104,6 +124,10 @@ class QuickshellGlobal: public QObject { /// /// The root directory is the folder containing the entrypoint to your shell, often referred /// to as `shell.qml`. + Q_PROPERTY(QString shellDir READ shellDir CONSTANT); + /// > [!WARNING] Deprecated: Renamed to @@shellDir for clarity. + Q_PROPERTY(QString configDir READ configDir CONSTANT); + /// > [!WARNING] Deprecated: Renamed to @@shellDir for consistency. Q_PROPERTY(QString shellRoot READ shellRoot CONSTANT); /// Quickshell's working directory. Defaults to whereever quickshell was launched from. Q_PROPERTY(QString workingDirectory READ workingDirectory WRITE setWorkingDirectory NOTIFY workingDirectoryChanged); @@ -119,18 +143,21 @@ class QuickshellGlobal: public QObject { /// Usually `~/.local/share/quickshell/by-shell/` /// /// Can be overridden using `//@ pragma DataDir $BASE/path` in the root qml file, where `$BASE` - /// corrosponds to `$XDG_DATA_HOME` (usually `~/.local/share`). + /// corresponds to `$XDG_DATA_HOME` (usually `~/.local/share`). Q_PROPERTY(QString dataDir READ dataDir CONSTANT); /// The per-shell state directory. /// /// Usually `~/.local/state/quickshell/by-shell/` /// /// Can be overridden using `//@ pragma StateDir $BASE/path` in the root qml file, where `$BASE` - /// corrosponds to `$XDG_STATE_HOME` (usually `~/.local/state`). + /// corresponds to `$XDG_STATE_HOME` (usually `~/.local/state`). Q_PROPERTY(QString stateDir READ stateDir CONSTANT); /// The per-shell cache directory. /// /// Usually `~/.cache/quickshell/by-shell/` + /// + /// Can be overridden using `//@ pragma CacheDir $BASE/path` in the root qml file, where `$BASE` + /// corresponds to `$XDG_CACHE_HOME` (usually `~/.cache`). Q_PROPERTY(QString cacheDir READ cacheDir CONSTANT); // clang-format on QML_SINGLETON; @@ -138,6 +165,10 @@ class QuickshellGlobal: public QObject { public: [[nodiscard]] qint32 processId() const; + [[nodiscard]] QString instanceId() const; + [[nodiscard]] QString shellId() const; + [[nodiscard]] QString appId() const; + [[nodiscard]] QDateTime launchTime() const; QQmlListProperty screens(); @@ -152,6 +183,30 @@ public: /// Returns the string value of an environment variable or null if it is not set. Q_INVOKABLE QVariant env(const QString& variable); + // MUST be before execDetached(ctx) or the other will be called with a default constructed obj. + QSDOC_HIDE Q_INVOKABLE static void execDetached(QList command); + /// Launch a process detached from Quickshell. + /// + /// The context parameter can either be a list of command arguments or a JS object with the following fields: + /// - `command`: A list containing the command and all its arguments. See @@Quickshell.Io.Process.command. + /// - `environment`: Changes to make to the process environment. See @@Quickshell.Io.Process.environment. + /// - `clearEnvironment`: Removes all variables from the environment if true. + /// - `workingDirectory`: The working directory the command should run in. + /// + /// > [!WARNING] This does not run command in a shell. All arguments to the command + /// > must be in separate values in the list, e.g. `["echo", "hello"]` + /// > and not `["echo hello"]`. + /// > + /// > Additionally, shell scripts must be run by your shell, + /// > e.g. `["sh", "script.sh"]` instead of `["script.sh"]` unless the script + /// > has a shebang. + /// + /// > [!INFO] You can use `["sh", "-c", ]` to execute your command with + /// > the system shell. + /// + /// This function is equivalent to @@Quickshell.Io.Process.startDetached(). + Q_INVOKABLE static void execDetached(const qs::io::process::ProcessContext& context); + /// Returns a string usable for a @@QtQuick.Image.source for a given system icon. /// /// > [!INFO] By default, icons are loaded from the theme selected by the qt platform theme, @@ -167,6 +222,12 @@ public: /// Setting the `fallback` parameter of `iconPath` will attempt to load the fallback /// icon if the requested one could not be loaded. Q_INVOKABLE static QString iconPath(const QString& icon, const QString& fallback); + /// Check if specified icon has an available icon in your icon theme + Q_INVOKABLE static bool hasThemeIcon(const QString& icon); + /// Equivalent to `${Quickshell.configDir}/${path}` + Q_INVOKABLE [[nodiscard]] QString shellPath(const QString& path) const; + /// > [!WARNING] Deprecated: Renamed to @@shellPath() for clarity. + Q_INVOKABLE [[nodiscard]] QString configPath(const QString& path) const; /// Equivalent to `${Quickshell.dataDir}/${path}` Q_INVOKABLE [[nodiscard]] QString dataPath(const QString& path) const; /// Equivalent to `${Quickshell.stateDir}/${path}` @@ -178,10 +239,27 @@ public: /// /// The popup can also be blocked by setting `QS_NO_RELOAD_POPUP=1`. Q_INVOKABLE void inhibitReloadPopup() { this->mInhibitReloadPopup = true; } + /// Check if Quickshell's version is at least `major.minor` and the listed + /// unreleased features are available. If Quickshell is newer than the given version + /// it is assumed that all unreleased features are present. The unreleased feature list + /// may be omitted. + /// + /// > [!NOTE] You can feature gate code blocks using Quickshell's preprocessor which + /// > has the same function available. + /// > + /// > ```qml + /// > //@ if hasVersion(0, 3, ["feature"]) + /// > ... + /// > //@ endif + /// > ``` + Q_INVOKABLE static bool hasVersion(qint32 major, qint32 minor, const QStringList& features); + Q_INVOKABLE static bool hasVersion(qint32 major, qint32 minor); void clearReloadPopupInhibit() { this->mInhibitReloadPopup = false; } [[nodiscard]] bool isReloadPopupInhibited() const { return this->mInhibitReloadPopup; } + [[nodiscard]] QString shellDir() const; + [[nodiscard]] QString configDir() const; [[nodiscard]] QString shellRoot() const; [[nodiscard]] QString workingDirectory() const; diff --git a/src/core/qsintercept.cpp b/src/core/qsintercept.cpp index 12ca118..6687681 100644 --- a/src/core/qsintercept.cpp +++ b/src/core/qsintercept.cpp @@ -1,6 +1,7 @@ #include "qsintercept.hpp" #include +#include #include #include #include @@ -14,7 +15,9 @@ #include #include -Q_LOGGING_CATEGORY(logQsIntercept, "quickshell.interceptor", QtWarningMsg); +#include "logcat.hpp" + +QS_LOGGING_CATEGORY(logQsIntercept, "quickshell.interceptor", QtWarningMsg); QUrl QsUrlInterceptor::intercept( const QUrl& originalUrl, @@ -23,27 +26,44 @@ QUrl QsUrlInterceptor::intercept( auto url = originalUrl; if (url.scheme() == "root") { - url.setScheme("qsintercept"); + url.setScheme("qs"); auto path = url.path(); if (path.startsWith('/')) path = path.sliced(1); - url.setPath(this->configRoot.filePath(path)); + url.setPath("@/qs/" % path); qCDebug(logQsIntercept) << "Rewrote root intercept" << originalUrl << "to" << url; } - // Some types such as Image take into account where they are loading from, and force - // asynchronous loading over a network. qsintercept is considered to be over a network. - if (type == QQmlAbstractUrlInterceptor::DataType::UrlString && url.scheme() == "qsintercept") { - // Qt.resolvedUrl and context->resolvedUrl can use this on qml files, in which - // case we want to keep the intercept, otherwise objects created from those paths - // will not be able to use singletons. - if (url.path().endsWith(".qml")) return url; + if (url.scheme() == "qs") { + auto path = url.path(); - auto newUrl = url; - newUrl.setScheme("file"); - qCDebug(logQsIntercept) << "Rewrote intercept" << url << "to" << newUrl; - return newUrl; + // Our import path is on "qs:@/". + // We want to blackhole any import resolution outside of the config folder as it breaks Qt + // but NOT file lookups that might be on "qs:/" due to a missing "file:/" prefix. + if (path.startsWith("@/qs/")) { + path = this->configRoot.filePath(path.sliced(5)); + } else if (!path.startsWith("/")) { + qCDebug(logQsIntercept) << "Blackholed import URL" << url; + return QUrl("qrc:/qs-blackhole"); + } + + // Some types such as Image take into account where they are loading from, and force + // asynchronous loading over a network. qs: is considered to be over a network. + // In those cases we want to return a file:// url so asynchronous loading is not forced. + if (type == QQmlAbstractUrlInterceptor::DataType::UrlString) { + // Qt.resolvedUrl and context->resolvedUrl can use this on qml files, in which + // case we want to keep the intercept, otherwise objects created from those paths + // will not be able to use singletons. + if (path.endsWith(".qml")) return url; + + auto newUrl = url; + newUrl.setScheme("file"); + // above check asserts path starts with /qs/ + newUrl.setPath(path); + qCDebug(logQsIntercept) << "Rewrote intercept" << url << "to" << newUrl; + return newUrl; + } } return url; @@ -65,10 +85,12 @@ qint64 QsInterceptDataReply::readData(char* data, qint64 maxSize) { } QsInterceptNetworkAccessManager::QsInterceptNetworkAccessManager( + const QDir& configRoot, const QHash& fileIntercepts, QObject* parent ) : QNetworkAccessManager(parent) + , configRoot(configRoot) , fileIntercepts(fileIntercepts) {} QNetworkReply* QsInterceptNetworkAccessManager::createRequest( @@ -77,19 +99,26 @@ QNetworkReply* QsInterceptNetworkAccessManager::createRequest( QIODevice* outgoingData ) { auto url = req.url(); - if (url.scheme() == "qsintercept") { + + if (url.scheme() == "qs") { auto path = url.path(); + + if (path.startsWith("@/qs/")) path = this->configRoot.filePath(path.sliced(5)); + // otherwise pass through to fs + qCDebug(logQsIntercept) << "Got intercept for" << path << "contains" << this->fileIntercepts.value(path); - auto data = this->fileIntercepts.value(path); - if (data != nullptr) { + + if (auto data = this->fileIntercepts.value(path); !data.isEmpty()) { return new QsInterceptDataReply(data, this); } auto fileReq = req; auto fileUrl = req.url(); fileUrl.setScheme("file"); + fileUrl.setPath(path); qCDebug(logQsIntercept) << "Passing through intercept" << url << "to" << fileUrl; + fileReq.setUrl(fileUrl); return this->QNetworkAccessManager::createRequest(op, fileReq, outgoingData); } @@ -98,5 +127,5 @@ QNetworkReply* QsInterceptNetworkAccessManager::createRequest( } QNetworkAccessManager* QsInterceptNetworkAccessManagerFactory::create(QObject* parent) { - return new QsInterceptNetworkAccessManager(this->fileIntercepts, parent); + return new QsInterceptNetworkAccessManager(this->configRoot, this->fileIntercepts, parent); } diff --git a/src/core/qsintercept.hpp b/src/core/qsintercept.hpp index 8113749..c3d8b55 100644 --- a/src/core/qsintercept.hpp +++ b/src/core/qsintercept.hpp @@ -10,7 +10,9 @@ #include #include -Q_DECLARE_LOGGING_CATEGORY(logQsIntercept); +#include "logcat.hpp" + +QS_DECLARE_LOGGING_CATEGORY(logQsIntercept); class QsUrlInterceptor: public QQmlAbstractUrlInterceptor { public: @@ -43,6 +45,7 @@ class QsInterceptNetworkAccessManager: public QNetworkAccessManager { public: QsInterceptNetworkAccessManager( + const QDir& configRoot, const QHash& fileIntercepts, QObject* parent = nullptr ); @@ -55,15 +58,21 @@ protected: ) override; private: + QDir configRoot; const QHash& fileIntercepts; }; class QsInterceptNetworkAccessManagerFactory: public QQmlNetworkAccessManagerFactory { public: - QsInterceptNetworkAccessManagerFactory(const QHash& fileIntercepts) - : fileIntercepts(fileIntercepts) {} + QsInterceptNetworkAccessManagerFactory( + const QDir& configRoot, + const QHash& fileIntercepts + ) + : configRoot(configRoot) + , fileIntercepts(fileIntercepts) {} QNetworkAccessManager* create(QObject* parent) override; private: + QDir configRoot; const QHash& fileIntercepts; }; diff --git a/src/core/qsmenu.hpp b/src/core/qsmenu.hpp index 6684c68..90df8b9 100644 --- a/src/core/qsmenu.hpp +++ b/src/core/qsmenu.hpp @@ -46,8 +46,8 @@ class QsMenuHandle: public QObject { public: explicit QsMenuHandle(QObject* parent): QObject(parent) {} - virtual void refHandle() {}; - virtual void unrefHandle() {}; + virtual void refHandle() {} + virtual void unrefHandle() {} [[nodiscard]] virtual QsMenuEntry* menu() = 0; diff --git a/src/core/region.cpp b/src/core/region.cpp index 439cfbd..82cc2e7 100644 --- a/src/core/region.cpp +++ b/src/core/region.cpp @@ -1,4 +1,5 @@ #include "region.hpp" +#include #include #include @@ -18,10 +19,17 @@ PendingRegion::PendingRegion(QObject* parent): QObject(parent) { QObject::connect(this, &PendingRegion::yChanged, this, &PendingRegion::changed); QObject::connect(this, &PendingRegion::widthChanged, this, &PendingRegion::changed); QObject::connect(this, &PendingRegion::heightChanged, this, &PendingRegion::changed); + QObject::connect(this, &PendingRegion::radiusChanged, this, &PendingRegion::changed); + QObject::connect(this, &PendingRegion::topLeftRadiusChanged, this, &PendingRegion::changed); + QObject::connect(this, &PendingRegion::topRightRadiusChanged, this, &PendingRegion::changed); + QObject::connect(this, &PendingRegion::bottomLeftRadiusChanged, this, &PendingRegion::changed); + QObject::connect(this, &PendingRegion::bottomRightRadiusChanged, this, &PendingRegion::changed); QObject::connect(this, &PendingRegion::childrenChanged, this, &PendingRegion::changed); } void PendingRegion::setItem(QQuickItem* item) { + if (item == this->mItem) return; + if (this->mItem != nullptr) { QObject::disconnect(this->mItem, nullptr, this, nullptr); } @@ -43,6 +51,79 @@ void PendingRegion::onItemDestroyed() { this->mItem = nullptr; } void PendingRegion::onChildDestroyed() { this->mRegions.removeAll(this->sender()); } +qint32 PendingRegion::radius() const { return this->mRadius; } + +void PendingRegion::setRadius(qint32 radius) { + if (radius == this->mRadius) return; + this->mRadius = radius; + emit this->radiusChanged(); + + if (!(this->mCornerOverrides & TopLeft)) emit this->topLeftRadiusChanged(); + if (!(this->mCornerOverrides & TopRight)) emit this->topRightRadiusChanged(); + if (!(this->mCornerOverrides & BottomLeft)) emit this->bottomLeftRadiusChanged(); + if (!(this->mCornerOverrides & BottomRight)) emit this->bottomRightRadiusChanged(); +} + +qint32 PendingRegion::topLeftRadius() const { + return (this->mCornerOverrides & TopLeft) ? this->mTopLeftRadius : this->mRadius; +} + +void PendingRegion::setTopLeftRadius(qint32 radius) { + this->mTopLeftRadius = radius; + this->mCornerOverrides |= TopLeft; + emit this->topLeftRadiusChanged(); +} + +void PendingRegion::resetTopLeftRadius() { + this->mCornerOverrides &= ~TopLeft; + emit this->topLeftRadiusChanged(); +} + +qint32 PendingRegion::topRightRadius() const { + return (this->mCornerOverrides & TopRight) ? this->mTopRightRadius : this->mRadius; +} + +void PendingRegion::setTopRightRadius(qint32 radius) { + this->mTopRightRadius = radius; + this->mCornerOverrides |= TopRight; + emit this->topRightRadiusChanged(); +} + +void PendingRegion::resetTopRightRadius() { + this->mCornerOverrides &= ~TopRight; + emit this->topRightRadiusChanged(); +} + +qint32 PendingRegion::bottomLeftRadius() const { + return (this->mCornerOverrides & BottomLeft) ? this->mBottomLeftRadius : this->mRadius; +} + +void PendingRegion::setBottomLeftRadius(qint32 radius) { + this->mBottomLeftRadius = radius; + this->mCornerOverrides |= BottomLeft; + emit this->bottomLeftRadiusChanged(); +} + +void PendingRegion::resetBottomLeftRadius() { + this->mCornerOverrides &= ~BottomLeft; + emit this->bottomLeftRadiusChanged(); +} + +qint32 PendingRegion::bottomRightRadius() const { + return (this->mCornerOverrides & BottomRight) ? this->mBottomRightRadius : this->mRadius; +} + +void PendingRegion::setBottomRightRadius(qint32 radius) { + this->mBottomRightRadius = radius; + this->mCornerOverrides |= BottomRight; + emit this->bottomRightRadiusChanged(); +} + +void PendingRegion::resetBottomRightRadius() { + this->mCornerOverrides &= ~BottomRight; + emit this->bottomRightRadiusChanged(); +} + QQmlListProperty PendingRegion::regions() { return QQmlListProperty( this, @@ -88,6 +169,60 @@ QRegion PendingRegion::build() const { region = QRegion(this->mX, this->mY, this->mWidth, this->mHeight, type); } + if (this->mShape == RegionShape::Rect && !region.isEmpty()) { + auto tl = std::max(this->topLeftRadius(), 0); + auto tr = std::max(this->topRightRadius(), 0); + auto bl = std::max(this->bottomLeftRadius(), 0); + auto br = std::max(this->bottomRightRadius(), 0); + + if (tl > 0 || tr > 0 || bl > 0 || br > 0) { + auto rect = region.boundingRect(); + auto x = rect.x(); + auto y = rect.y(); + auto w = rect.width(); + auto h = rect.height(); + + // Normalize so adjacent corners don't exceed their shared edge. + // Each corner is scaled by the tightest constraint of its two edges. + auto topScale = tl + tr > w ? static_cast(w) / (tl + tr) : 1.0; + auto bottomScale = bl + br > w ? static_cast(w) / (bl + br) : 1.0; + auto leftScale = tl + bl > h ? static_cast(h) / (tl + bl) : 1.0; + auto rightScale = tr + br > h ? static_cast(h) / (tr + br) : 1.0; + + tl = static_cast(tl * std::min(topScale, leftScale)); + tr = static_cast(tr * std::min(topScale, rightScale)); + bl = static_cast(bl * std::min(bottomScale, leftScale)); + br = static_cast(br * std::min(bottomScale, rightScale)); + + // Unlock each corner: subtract (cornerBox - quarterEllipse) from the + // full rect. Each corner only modifies pixels inside its own box, + // so no diagonal overlap is possible. + if (tl > 0) { + auto box = QRegion(x, y, tl, tl); + auto ellipse = QRegion(x, y, tl * 2, tl * 2, QRegion::Ellipse); + region -= box - (ellipse & box); + } + + if (tr > 0) { + auto box = QRegion(x + w - tr, y, tr, tr); + auto ellipse = QRegion(x + w - tr * 2, y, tr * 2, tr * 2, QRegion::Ellipse); + region -= box - (ellipse & box); + } + + if (bl > 0) { + auto box = QRegion(x, y + h - bl, bl, bl); + auto ellipse = QRegion(x, y + h - bl * 2, bl * 2, bl * 2, QRegion::Ellipse); + region -= box - (ellipse & box); + } + + if (br > 0) { + auto box = QRegion(x + w - br, y + h - br, br, br); + auto ellipse = QRegion(x + w - br * 2, y + h - br * 2, br * 2, br * 2, QRegion::Ellipse); + region -= box - (ellipse & box); + } + } + } + for (const auto& childRegion: this->mRegions) { region = childRegion->applyTo(region); } diff --git a/src/core/region.hpp b/src/core/region.hpp index 6637d7b..dfd1566 100644 --- a/src/core/region.hpp +++ b/src/core/region.hpp @@ -66,6 +66,29 @@ class PendingRegion: public QObject { Q_PROPERTY(qint32 width MEMBER mWidth NOTIFY widthChanged); /// Defaults to 0. Does nothing if @@item is set. Q_PROPERTY(qint32 height MEMBER mHeight NOTIFY heightChanged); + // clang-format off + /// Corner radius for rounded rectangles. Only applies when @@shape is `Rect`. Defaults to 0. + /// + /// Acts as the default for @@topLeftRadius, @@topRightRadius, @@bottomLeftRadius, + /// and @@bottomRightRadius. + Q_PROPERTY(qint32 radius READ radius WRITE setRadius NOTIFY radiusChanged); + /// Top-left corner radius. Only applies when @@shape is `Rect`. + /// + /// Defaults to @@radius, and may be reset by assigning `undefined`. + Q_PROPERTY(qint32 topLeftRadius READ topLeftRadius WRITE setTopLeftRadius RESET resetTopLeftRadius NOTIFY topLeftRadiusChanged); + /// Top-right corner radius. Only applies when @@shape is `Rect`. + /// + /// Defaults to @@radius, and may be reset by assigning `undefined`. + Q_PROPERTY(qint32 topRightRadius READ topRightRadius WRITE setTopRightRadius RESET resetTopRightRadius NOTIFY topRightRadiusChanged); + /// Bottom-left corner radius. Only applies when @@shape is `Rect`. + /// + /// Defaults to @@radius, and may be reset by assigning `undefined`. + Q_PROPERTY(qint32 bottomLeftRadius READ bottomLeftRadius WRITE setBottomLeftRadius RESET resetBottomLeftRadius NOTIFY bottomLeftRadiusChanged); + /// Bottom-right corner radius. Only applies when @@shape is `Rect`. + /// + /// Defaults to @@radius, and may be reset by assigning `undefined`. + Q_PROPERTY(qint32 bottomRightRadius READ bottomRightRadius WRITE setBottomRightRadius RESET resetBottomRightRadius NOTIFY bottomRightRadiusChanged); + // clang-format on /// Regions to apply on top of this region. /// @@ -91,6 +114,25 @@ public: void setItem(QQuickItem* item); + [[nodiscard]] qint32 radius() const; + void setRadius(qint32 radius); + + [[nodiscard]] qint32 topLeftRadius() const; + void setTopLeftRadius(qint32 radius); + void resetTopLeftRadius(); + + [[nodiscard]] qint32 topRightRadius() const; + void setTopRightRadius(qint32 radius); + void resetTopRightRadius(); + + [[nodiscard]] qint32 bottomLeftRadius() const; + void setBottomLeftRadius(qint32 radius); + void resetBottomLeftRadius(); + + [[nodiscard]] qint32 bottomRightRadius() const; + void setBottomRightRadius(qint32 radius); + void resetBottomRightRadius(); + QQmlListProperty regions(); [[nodiscard]] bool empty() const; @@ -109,6 +151,11 @@ signals: void yChanged(); void widthChanged(); void heightChanged(); + void radiusChanged(); + void topLeftRadiusChanged(); + void topRightRadiusChanged(); + void bottomLeftRadiusChanged(); + void bottomRightRadiusChanged(); void childrenChanged(); /// Triggered when the region's geometry changes. @@ -130,12 +177,25 @@ private: static void regionsReplace(QQmlListProperty* prop, qsizetype i, PendingRegion* region); + enum CornerOverride : quint8 { + TopLeft = 0b1, + TopRight = 0b10, + BottomLeft = 0b100, + BottomRight = 0b1000, + }; + QQuickItem* mItem = nullptr; qint32 mX = 0; qint32 mY = 0; qint32 mWidth = 0; qint32 mHeight = 0; + qint32 mRadius = 0; + qint32 mTopLeftRadius = 0; + qint32 mTopRightRadius = 0; + qint32 mBottomLeftRadius = 0; + qint32 mBottomRightRadius = 0; + quint8 mCornerOverrides = 0; QList mRegions; }; diff --git a/src/core/reload.cpp b/src/core/reload.cpp index 25ab33f..ea2abbf 100644 --- a/src/core/reload.cpp +++ b/src/core/reload.cpp @@ -126,12 +126,21 @@ QObject* Reloadable::getChildByReloadId(QObject* parent, const QString& reloadId return nullptr; } -void PostReloadHook::postReloadTree(QObject* root) { - for (auto* child: root->children()) { - PostReloadHook::postReloadTree(child); - } - - if (auto* self = dynamic_cast(root)) { - self->onPostReload(); +void PostReloadHook::componentComplete() { + auto* engineGeneration = EngineGeneration::findObjectGeneration(this); + if (!engineGeneration || engineGeneration->reloadComplete) this->postReload(); + else { + // disconnected by EngineGeneration::postReload + QObject::connect( + engineGeneration, + &EngineGeneration::firePostReload, + this, + &PostReloadHook::postReload + ); } } + +void PostReloadHook::postReload() { + this->isPostReload = true; + this->onPostReload(); +} diff --git a/src/core/reload.hpp b/src/core/reload.hpp index 560c8bd..ae5d7c9 100644 --- a/src/core/reload.hpp +++ b/src/core/reload.hpp @@ -57,7 +57,7 @@ public: void reload(QObject* oldInstance = nullptr); - void classBegin() override {}; + void classBegin() override {} void componentComplete() override; // Reload objects in the parent->child graph recursively. @@ -119,16 +119,23 @@ private: }; /// Hook that runs after the old widget tree is dropped during a reload. -class PostReloadHook { +class PostReloadHook + : public QObject + , public QQmlParserStatus { + Q_OBJECT; + QML_ANONYMOUS; + Q_INTERFACES(QQmlParserStatus); + public: - PostReloadHook() = default; - virtual ~PostReloadHook() = default; - PostReloadHook(PostReloadHook&&) = default; - PostReloadHook(const PostReloadHook&) = default; - PostReloadHook& operator=(PostReloadHook&&) = default; - PostReloadHook& operator=(const PostReloadHook&) = default; + PostReloadHook(QObject* parent = nullptr): QObject(parent) {} + void classBegin() override {} + void componentComplete() override; virtual void onPostReload() = 0; - static void postReloadTree(QObject* root); +public slots: + void postReload(); + +protected: + bool isPostReload = false; }; diff --git a/src/core/rootwrapper.cpp b/src/core/rootwrapper.cpp index b51b403..1e75819 100644 --- a/src/core/rootwrapper.cpp +++ b/src/core/rootwrapper.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -18,15 +19,26 @@ #include "instanceinfo.hpp" #include "qmlglobal.hpp" #include "scan.hpp" +#include "toolsupport.hpp" RootWrapper::RootWrapper(QString rootPath, QString shellId) : QObject(nullptr) , rootPath(std::move(rootPath)) , shellId(std::move(shellId)) , originalWorkingDirectory(QDir::current().absolutePath()) { - // clang-format off - QObject::connect(QuickshellSettings::instance(), &QuickshellSettings::watchFilesChanged, this, &RootWrapper::onWatchFilesChanged); - // clang-format on + QObject::connect( + QuickshellSettings::instance(), + &QuickshellSettings::watchFilesChanged, + this, + &RootWrapper::onWatchFilesChanged + ); + + QObject::connect( + &this->configDirWatcher, + &QFileSystemWatcher::directoryChanged, + this, + &RootWrapper::updateTooling + ); this->reloadGraph(true); @@ -43,12 +55,13 @@ RootWrapper::~RootWrapper() { } void RootWrapper::reloadGraph(bool hard) { - auto rootPath = QFileInfo(this->rootPath).dir(); + auto rootFile = QFileInfo(this->rootPath); + auto rootPath = rootFile.dir(); auto scanner = QmlScanner(rootPath); - scanner.scanQmlFile(this->rootPath); + scanner.scanQmlRoot(this->rootPath); - auto* generation = new EngineGeneration(rootPath, std::move(scanner)); - generation->wrapper = this; + qs::core::QmlToolingSupport::updateTooling(rootPath, scanner); + this->configDirWatcher.addPath(rootPath.path()); // todo: move into EngineGeneration if (this->generation != nullptr) { @@ -58,9 +71,36 @@ void RootWrapper::reloadGraph(bool hard) { QDir::setCurrent(this->originalWorkingDirectory); - auto url = QUrl::fromLocalFile(this->rootPath); - // unless the original file comes from the qsintercept scheme - url.setScheme("qsintercept"); + if (!scanner.scanErrors.isEmpty()) { + qCritical() << "Failed to load configuration"; + QString errorString = "Failed to load configuration"; + for (auto& error: scanner.scanErrors) { + const auto& file = error.file; + QString rel; + if (file.startsWith(rootPath.path() % '/')) { + rel = '@' % file.sliced(rootPath.path().length() + 1); + } else { + rel = file; + } + + auto msg = " error in " % rel % '[' % QString::number(error.line) % ":0]: " % error.message; + errorString += '\n' % msg; + qCritical().noquote() << msg; + } + + if (this->generation != nullptr && this->generation->qsgInstance != nullptr) { + emit this->generation->qsgInstance->reloadFailed(errorString); + } + + return; + } + + auto* generation = new EngineGeneration(rootPath, std::move(scanner)); + generation->wrapper = this; + + QUrl url; + url.setScheme("qs"); + url.setPath("@/qs/" % rootFile.fileName()); auto component = QQmlComponent(generation->engine, url); if (!component.isReady()) { @@ -69,7 +109,9 @@ void RootWrapper::reloadGraph(bool hard) { auto errors = component.errors(); for (auto& error: errors) { - auto rel = "**/" % rootPath.relativeFilePath(error.url().path()); + const auto& url = error.url(); + auto rel = url.scheme() == "qs" && url.path().startsWith("@/qs/") ? "@" % url.path().sliced(5) + : url.toString(); auto msg = " caused by " % rel % '[' % QString::number(error.line()) % ':' % QString::number(error.column()) % "]: " % error.description(); errorString += '\n' % msg; @@ -165,3 +207,9 @@ void RootWrapper::onWatchFilesChanged() { } void RootWrapper::onWatchedFilesChanged() { this->reloadGraph(false); } + +void RootWrapper::updateTooling() { + if (!this->generation) return; + auto configDir = QFileInfo(this->rootPath).dir(); + qs::core::QmlToolingSupport::updateTooling(configDir, this->generation->scanner); +} diff --git a/src/core/rootwrapper.hpp b/src/core/rootwrapper.hpp index 02d7a14..1425d17 100644 --- a/src/core/rootwrapper.hpp +++ b/src/core/rootwrapper.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -22,10 +23,12 @@ private slots: void generationDestroyed(); void onWatchFilesChanged(); void onWatchedFilesChanged(); + void updateTooling(); private: QString rootPath; QString shellId; EngineGeneration* generation = nullptr; QString originalWorkingDirectory; + QFileSystemWatcher configDirWatcher; }; diff --git a/src/core/scan.cpp b/src/core/scan.cpp index 8d6362e..3605c52 100644 --- a/src/core/scan.cpp +++ b/src/core/scan.cpp @@ -1,9 +1,12 @@ #include "scan.hpp" #include +#include #include +#include #include #include +#include #include #include #include @@ -12,54 +15,107 @@ #include #include #include -#include #include -Q_LOGGING_CATEGORY(logQmlScanner, "quickshell.qmlscanner", QtWarningMsg); +#include "logcat.hpp" +#include "scanenv.hpp" -void QmlScanner::scanDir(const QString& path) { - if (this->scannedDirs.contains(path)) return; - this->scannedDirs.push_back(path); +QS_LOGGING_CATEGORY(logQmlScanner, "quickshell.qmlscanner", QtWarningMsg); + +bool QmlScanner::readAndHashFile(const QString& path, QByteArray& data) { + auto file = QFile(path); + if (!file.open(QFile::ReadOnly)) return false; + data = file.readAll(); + this->fileHashes.insert(path, QCryptographicHash::hash(data, QCryptographicHash::Md5)); + return true; +} + +bool QmlScanner::hasFileContentChanged(const QString& path) const { + auto it = this->fileHashes.constFind(path); + if (it == this->fileHashes.constEnd()) return true; + + auto file = QFile(path); + if (!file.open(QFile::ReadOnly)) return true; + + auto newHash = QCryptographicHash::hash(file.readAll(), QCryptographicHash::Md5); + return newHash != it.value(); +} + +void QmlScanner::scanDir(const QDir& dir) { + if (this->scannedDirs.contains(dir)) return; + this->scannedDirs.push_back(dir); + + const auto& path = dir.path(); qCDebug(logQmlScanner) << "Scanning directory" << path; - auto dir = QDir(path); + + struct Entry { + QString name; + bool singleton = false; + bool internal = false; + }; bool seenQmldir = false; - auto singletons = QVector(); - auto entries = QVector(); - for (auto& entry: dir.entryList(QDir::Files | QDir::NoDotAndDotDot)) { - if (entry == "qmldir") { - qCDebug(logQmlScanner + auto entries = QVector(); + + for (auto& name: dir.entryList(QDir::Files | QDir::NoDotAndDotDot)) { + if (name == "qmldir") { + qCDebug( + logQmlScanner ) << "Found qmldir file, qmldir synthesization will be disabled for directory" << path; seenQmldir = true; - } else if (entry.at(0).isUpper() && entry.endsWith(".qml")) { - if (this->scanQmlFile(dir.filePath(entry))) { - singletons.push_back(entry); + } else if (name.at(0).isUpper() && name.endsWith(".qml")) { + auto& entry = entries.emplaceBack(); + + if (this->scanQmlFile(dir.filePath(name), entry.singleton, entry.internal)) { + entry.name = name; } else { - entries.push_back(entry); + entries.pop_back(); + } + } else if (name.at(0).isUpper() && name.endsWith(".qml.json")) { + if (this->scanQmlJson(dir.filePath(name))) { + entries.push_back({ + .name = name.first(name.length() - 5), + .singleton = true, + }); } - } else if (entry.at(0).isUpper() && entry.endsWith(".qml.json")) { - this->scanQmlJson(dir.filePath(entry)); - singletons.push_back(entry.first(entry.length() - 5)); } } - // Due to the qsintercept:// protocol a qmldir is always required, even without singletons. if (!seenQmldir) { - qCDebug(logQmlScanner) << "Synthesizing qmldir for directory" << path << "singletons" - << singletons; + qCDebug(logQmlScanner) << "Synthesizing qmldir for directory" << path; QString qmldir; auto stream = QTextStream(&qmldir); - for (auto& singleton: singletons) { - stream << "singleton " << singleton.sliced(0, singleton.length() - 4) << " 1.0 " << singleton - << "\n"; + // cant derive a module name if not in shell path + if (path.startsWith(this->rootPath.path())) { + auto end = path.sliced(this->rootPath.path().length()); + + // verify we have a valid module name. + for (auto& c: end) { + if (c == '/') c = '.'; + else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_') + { + } else { + qCWarning(logQmlScanner) << "Module path contains invalid characters for a module name: " + << path.sliced(this->rootPath.path().length()); + goto skipadd; + } + } + + stream << "module qs" << end << '\n'; + skipadd:; + } else { + qCWarning(logQmlScanner) << "Module path" << path << "is outside of the config folder."; } - for (auto& entry: entries) { - stream << entry.sliced(0, entry.length() - 4) << " 1.0 " << entry << "\n"; + for (const auto& entry: entries) { + if (entry.internal) stream << "internal "; + if (entry.singleton) stream << "singleton "; + stream << entry.name.sliced(0, entry.name.length() - 4) << " 1.0 " << entry.name << '\n'; } qCDebug(logQmlScanner) << "Synthesized qmldir for" << path << qPrintable("\n" + qmldir); @@ -67,50 +123,131 @@ void QmlScanner::scanDir(const QString& path) { } } -bool QmlScanner::scanQmlFile(const QString& path) { +bool QmlScanner::scanQmlFile(const QString& path, bool& singleton, bool& internal) { if (this->scannedFiles.contains(path)) return false; this->scannedFiles.push_back(path); qCDebug(logQmlScanner) << "Scanning qml file" << path; - auto file = QFile(path); - if (!file.open(QFile::ReadOnly | QFile::Text)) { + QByteArray fileData; + if (!this->readAndHashFile(path, fileData)) { qCWarning(logQmlScanner) << "Failed to open file" << path; return false; } - auto stream = QTextStream(&file); + auto stream = QTextStream(&fileData); auto imports = QVector(); - bool singleton = false; + bool inHeader = true; + auto ifScopes = QVector(); + bool sourceMasked = false; + int lineNum = 0; + QString overrideText; + bool isOverridden = false; + + auto& pragmaEngine = *QmlScanner::preprocEngine(); + + auto postError = [&, this](QString error) { + this->scanErrors.append({.file = path, .message = std::move(error), .line = lineNum}); + }; while (!stream.atEnd()) { - auto line = stream.readLine().trimmed(); - if (!singleton && line == "pragma Singleton") { - qCDebug(logQmlScanner) << "Discovered singleton" << path; - singleton = true; - } else if (line.startsWith("import")) { + ++lineNum; + bool hideMask = false; + auto rawLine = stream.readLine(); + auto line = rawLine.trimmed(); + if (!sourceMasked && inHeader) { + if (!singleton && line == "pragma Singleton") { + singleton = true; + } else if (line.startsWith("import")) { + // we dont care about "import qs" as we always load the root folder + if (auto importCursor = line.indexOf(" qs."); importCursor != -1) { + importCursor += 4; + QString path; - auto startQuot = line.indexOf('"'); - if (startQuot == -1 || line.length() < startQuot + 3) continue; - auto endQuot = line.indexOf('"', startQuot + 1); - if (endQuot == -1) continue; + while (importCursor != line.length()) { + auto c = line.at(importCursor); + if (c == '.') c = '/'; + else if (c == ' ') break; + else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_') + { + } else { + qCWarning(logQmlScanner) << "Import line contains invalid characters: " << line; + goto next; + } - auto name = line.sliced(startQuot + 1, endQuot - startQuot - 1); - imports.push_back(name); - } else if (line.contains('{')) break; + path.append(c); + importCursor += 1; + } + + imports.append(this->rootPath.filePath(path)); + } else if (auto startQuot = line.indexOf('"'); + startQuot != -1 && line.length() >= startQuot + 3) + { + auto endQuot = line.indexOf('"', startQuot + 1); + if (endQuot == -1) continue; + + auto name = line.sliced(startQuot + 1, endQuot - startQuot - 1); + imports.push_back(name); + } + } else if (!internal && line == "//@ pragma Internal") { + internal = true; + } else if (line.contains('{')) { + inHeader = false; + } + } + + if (line.startsWith("//@ if ")) { + auto code = line.sliced(7); + auto value = pragmaEngine.evaluate(code, path, 1234); + bool mask = true; + + if (value.isError()) { + postError(QString("Evaluating if: %0").arg(value.toString())); + } else if (!value.isBool()) { + postError(QString("If expression \"%0\" is not a boolean").arg(value.toString())); + } else if (value.toBool()) { + mask = false; + } + if (!sourceMasked && mask) hideMask = true; + mask = sourceMasked || mask; // cant unmask if a nested if passes + ifScopes.append(mask); + if (mask) isOverridden = true; + sourceMasked = mask; + } else if (line.startsWith("//@ endif")) { + if (ifScopes.isEmpty()) { + postError("endif without matching if"); + } else { + ifScopes.pop_back(); + + if (ifScopes.isEmpty()) sourceMasked = false; + else sourceMasked = ifScopes.last(); + } + } + + if (!hideMask && sourceMasked) overrideText.append("// MASKED: " % rawLine % '\n'); + else overrideText.append(rawLine % '\n'); + + next:; } - file.close(); + if (!ifScopes.isEmpty()) { + postError("unclosed preprocessor if block"); + } + + if (isOverridden) { + this->fileIntercepts.insert(path, overrideText); + } if (logQmlScanner().isDebugEnabled() && !imports.isEmpty()) { qCDebug(logQmlScanner) << "Found imports" << imports; } - auto currentdir = QDir(QFileInfo(path).canonicalPath()); + auto currentdir = QDir(QFileInfo(path).absolutePath()); // the root can never be a singleton so it dosent matter if we skip it - this->scanDir(currentdir.path()); + this->scanDir(currentdir); for (auto& import: imports) { QString ipath; @@ -122,31 +259,44 @@ bool QmlScanner::scanQmlFile(const QString& path) { ipath = currentdir.filePath(import); } - auto cpath = QFileInfo(ipath).canonicalFilePath(); + auto pathInfo = QFileInfo(ipath); + auto cpath = pathInfo.absoluteFilePath(); - if (cpath.isEmpty()) { + if (!pathInfo.exists()) { qCWarning(logQmlScanner) << "Ignoring unresolvable import" << ipath << "from" << path; continue; } - if (import.endsWith(".js")) this->scannedFiles.push_back(cpath); - else this->scanDir(cpath); + if (!pathInfo.isDir()) { + qCDebug(logQmlScanner) << "Ignoring non-directory import" << ipath << "from" << path; + continue; + } + + if (import.endsWith(".js")) { + this->scannedFiles.push_back(cpath); + QByteArray jsData; + this->readAndHashFile(cpath, jsData); + } else this->scanDir(cpath); } - return singleton; + return true; } -void QmlScanner::scanQmlJson(const QString& path) { +void QmlScanner::scanQmlRoot(const QString& path) { + bool singleton = false; + bool internal = false; + this->scanQmlFile(path, singleton, internal); +} + +bool QmlScanner::scanQmlJson(const QString& path) { qCDebug(logQmlScanner) << "Scanning qml.json file" << path; - auto file = QFile(path); - if (!file.open(QFile::ReadOnly | QFile::Text)) { + QByteArray data; + if (!this->readAndHashFile(path, data)) { qCWarning(logQmlScanner) << "Failed to open file" << path; - return; + return false; } - auto data = file.readAll(); - // Importing this makes CI builds fail for some reason. QJsonParseError error; // NOLINT (misc-include-cleaner) auto json = QJsonDocument::fromJson(data, &error); @@ -154,7 +304,7 @@ void QmlScanner::scanQmlJson(const QString& path) { if (error.error != QJsonParseError::NoError) { qCCritical(logQmlScanner).nospace() << "Failed to parse qml.json file at " << path << ": " << error.errorString(); - return; + return false; } const QString body = @@ -164,6 +314,7 @@ void QmlScanner::scanQmlJson(const QString& path) { this->fileIntercepts.insert(path.first(path.length() - 5), body); this->scannedFiles.push_back(path); + return true; } QPair QmlScanner::jsonToQml(const QJsonValue& value, int indent) { @@ -216,3 +367,13 @@ QPair QmlScanner::jsonToQml(const QJsonValue& value, int inden return qMakePair(QStringLiteral("var"), "null"); } } + +QJSEngine* QmlScanner::preprocEngine() { + static auto* engine = [] { + auto* engine = new QJSEngine(); + engine->globalObject().setPrototype(engine->newQObject(new qs::scan::env::PreprocEnv())); + return engine; + }(); + + return engine; +} diff --git a/src/core/scan.hpp b/src/core/scan.hpp index d8fb500..7d807e1 100644 --- a/src/core/scan.hpp +++ b/src/core/scan.hpp @@ -1,12 +1,16 @@ #pragma once +#include #include #include #include +#include #include #include -Q_DECLARE_LOGGING_CATEGORY(logQmlScanner); +#include "logcat.hpp" + +QS_DECLARE_LOGGING_CATEGORY(logQmlScanner); // expects canonical paths class QmlScanner { @@ -14,17 +18,31 @@ public: QmlScanner() = default; QmlScanner(const QDir& rootPath): rootPath(rootPath) {} - void scanDir(const QString& path); - // returns if the file has a singleton - bool scanQmlFile(const QString& path); + void scanDir(const QDir& dir); + void scanQmlRoot(const QString& path); - QVector scannedDirs; + QVector scannedDirs; QVector scannedFiles; + QHash fileHashes; QHash fileIntercepts; + struct ScanError { + QString file; + QString message; + int line; + }; + + QVector scanErrors; + + bool readAndHashFile(const QString& path, QByteArray& data); + [[nodiscard]] bool hasFileContentChanged(const QString& path) const; + private: QDir rootPath; - void scanQmlJson(const QString& path); + bool scanQmlFile(const QString& path, bool& singleton, bool& internal); + bool scanQmlJson(const QString& path); [[nodiscard]] static QPair jsonToQml(const QJsonValue& value, int indent = 0); + + static QJSEngine* preprocEngine(); }; diff --git a/src/core/scanenv.cpp b/src/core/scanenv.cpp new file mode 100644 index 0000000..047f472 --- /dev/null +++ b/src/core/scanenv.cpp @@ -0,0 +1,31 @@ +#include "scanenv.hpp" + +#include +#include + +#include "build.hpp" + +namespace qs::scan::env { + +bool PreprocEnv::hasVersion(int major, int minor, const QStringList& features) { + if (QS_VERSION_MAJOR > major) return true; + if (QS_VERSION_MAJOR == major && QS_VERSION_MINOR > minor) return true; + + auto availFeatures = QString(QS_UNRELEASED_FEATURES).split(';'); + + for (const auto& feature: features) { + if (!availFeatures.contains(feature)) return false; + } + + return QS_VERSION_MAJOR == major && QS_VERSION_MINOR == minor; +} + +QString PreprocEnv::env(const QString& variable) { + return qEnvironmentVariable(variable.toStdString().c_str()); +} + +bool PreprocEnv::isEnvSet(const QString& variable) { + return qEnvironmentVariableIsSet(variable.toStdString().c_str()); +} + +} // namespace qs::scan::env diff --git a/src/core/scanenv.hpp b/src/core/scanenv.hpp new file mode 100644 index 0000000..c1c6814 --- /dev/null +++ b/src/core/scanenv.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +namespace qs::scan::env { + +class PreprocEnv: public QObject { + Q_OBJECT; + +public: + Q_INVOKABLE static bool + hasVersion(int major, int minor, const QStringList& features = QStringList()); + + Q_INVOKABLE static QString env(const QString& variable); + Q_INVOKABLE static bool isEnvSet(const QString& variable); +}; + +} // namespace qs::scan::env diff --git a/src/core/scriptmodel.cpp b/src/core/scriptmodel.cpp index 6837c4a..5407e2b 100644 --- a/src/core/scriptmodel.cpp +++ b/src/core/scriptmodel.cpp @@ -19,7 +19,7 @@ void ScriptModel::updateValuesUnique(const QVariantList& newValues) { auto newIter = newValues.begin(); // TODO: cache this - auto getCmpKey = [&](const QVariant& v) { + auto getCmpKey = [this](const QVariant& v) { if (v.canConvert()) { auto vMap = v.value(); if (vMap.contains(this->cmpKey)) { @@ -30,7 +30,7 @@ void ScriptModel::updateValuesUnique(const QVariantList& newValues) { return v; }; - auto variantCmp = [&](const QVariant& a, const QVariant& b) { + auto variantCmp = [&, this](const QVariant& a, const QVariant& b) { if (!this->cmpKey.isEmpty()) return getCmpKey(a) == getCmpKey(b); else return a == b; }; @@ -72,8 +72,8 @@ void ScriptModel::updateValuesUnique(const QVariantList& newValues) { do { ++iter; } while (iter != this->mValues.end() - && std::find_if(newIter, newValues.end(), eqPredicate(*iter)) == newValues.end() - ); + && std::find_if(newIter, newValues.end(), eqPredicate(*iter)) + == newValues.end()); auto index = static_cast(std::distance(this->mValues.begin(), iter)); auto startIndex = static_cast(std::distance(this->mValues.begin(), startIter)); diff --git a/src/core/singleton.cpp b/src/core/singleton.cpp index 61ac992..15668c9 100644 --- a/src/core/singleton.cpp +++ b/src/core/singleton.cpp @@ -51,9 +51,3 @@ void SingletonRegistry::onReload(SingletonRegistry* old) { singleton->reload(old == nullptr ? nullptr : old->registry.value(url)); } } - -void SingletonRegistry::onPostReload() { - for (auto* singleton: this->registry.values()) { - PostReloadHook::postReloadTree(singleton); - } -} diff --git a/src/core/singleton.hpp b/src/core/singleton.hpp index e63ab12..200c97f 100644 --- a/src/core/singleton.hpp +++ b/src/core/singleton.hpp @@ -26,7 +26,6 @@ public: void registerSingleton(const QUrl& url, Singleton* singleton); void onReload(SingletonRegistry* old); - void onPostReload(); private: QHash registry; diff --git a/src/core/streamreader.cpp b/src/core/streamreader.cpp new file mode 100644 index 0000000..1f66e29 --- /dev/null +++ b/src/core/streamreader.cpp @@ -0,0 +1,98 @@ +#include "streamreader.hpp" +#include + +#include +#include +#include + +void StreamReader::setDevice(QIODevice* device) { + this->reset(); + this->device = device; +} + +void StreamReader::startTransaction() { + this->cursor = 0; + this->failed = false; +} + +bool StreamReader::fill() { + auto available = this->device->bytesAvailable(); + if (available <= 0) return false; + auto oldSize = this->buffer.size(); + this->buffer.resize(oldSize + available); + auto bytesRead = this->device->read(this->buffer.data() + oldSize, available); // NOLINT + + if (bytesRead <= 0) { + this->buffer.resize(oldSize); + return false; + } + + this->buffer.resize(oldSize + bytesRead); + return true; +} + +QByteArray StreamReader::readBytes(qsizetype count) { + if (this->failed) return {}; + + auto needed = this->cursor + count; + + while (this->buffer.size() < needed) { + if (!this->fill()) { + this->failed = true; + return {}; + } + } + + auto result = this->buffer.mid(this->cursor, count); + this->cursor += count; + return result; +} + +QByteArray StreamReader::readUntil(char terminator) { + if (this->failed) return {}; + + auto searchFrom = this->cursor; + auto idx = this->buffer.indexOf(terminator, searchFrom); + + while (idx == -1) { + searchFrom = this->buffer.size(); + if (!this->fill()) { + this->failed = true; + return {}; + } + + idx = this->buffer.indexOf(terminator, searchFrom); + } + + auto length = idx - this->cursor + 1; + auto result = this->buffer.mid(this->cursor, length); + this->cursor += length; + return result; +} + +void StreamReader::readInto(char* ptr, qsizetype count) { + auto data = this->readBytes(count); + if (!data.isEmpty()) memcpy(ptr, data.data(), count); +} + +qint32 StreamReader::readI32() { + qint32 value = 0; + this->readInto(reinterpret_cast(&value), sizeof(qint32)); + return value; +} + +bool StreamReader::commitTransaction() { + if (this->failed) { + this->cursor = 0; + return false; + } + + this->buffer.remove(0, this->cursor); + this->cursor = 0; + return true; +} + +void StreamReader::reset() { + this->buffer.clear(); + this->cursor = 0; +} diff --git a/src/core/streamreader.hpp b/src/core/streamreader.hpp new file mode 100644 index 0000000..abf14ef --- /dev/null +++ b/src/core/streamreader.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +class StreamReader { +public: + void setDevice(QIODevice* device); + + void startTransaction(); + QByteArray readBytes(qsizetype count); + QByteArray readUntil(char terminator); + void readInto(char* ptr, qsizetype count); + qint32 readI32(); + bool commitTransaction(); + void reset(); + +private: + bool fill(); + + QIODevice* device = nullptr; + QByteArray buffer; + qsizetype cursor = 0; + bool failed = false; +}; diff --git a/src/core/test/CMakeLists.txt b/src/core/test/CMakeLists.txt index c4005c8..4e66c62 100644 --- a/src/core/test/CMakeLists.txt +++ b/src/core/test/CMakeLists.txt @@ -1,6 +1,6 @@ function (qs_test name) add_executable(${name} ${ARGN}) - target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-core quickshell-window quickshell-ui) + target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-core quickshell-window quickshell-ui quickshell-io) add_test(NAME ${name} WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" COMMAND $) endfunction() diff --git a/src/core/test/scriptmodel.cpp b/src/core/test/scriptmodel.cpp index 6674683..0abfdbf 100644 --- a/src/core/test/scriptmodel.cpp +++ b/src/core/test/scriptmodel.cpp @@ -115,7 +115,7 @@ void TestScriptModel::unique_data() { void TestScriptModel::unique() { QFETCH(const QString, oldstr); QFETCH(const QString, newstr); - QFETCH(OpList, operations); + QFETCH(const OpList, operations); auto strToVariantList = [](const QString& str) -> QVariantList { QVariantList list; diff --git a/src/core/toolsupport.cpp b/src/core/toolsupport.cpp new file mode 100644 index 0000000..585656e --- /dev/null +++ b/src/core/toolsupport.cpp @@ -0,0 +1,245 @@ +#include "toolsupport.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "logcat.hpp" +#include "paths.hpp" +#include "scan.hpp" + +namespace qs::core { + +namespace { +QS_LOGGING_CATEGORY(logTooling, "quickshell.tooling", QtWarningMsg); +} + +bool QmlToolingSupport::updateTooling(const QDir& configRoot, QmlScanner& scanner) { + auto* vfs = QsPaths::instance()->shellVfsDir(); + + if (!vfs) { + qCCritical(logTooling) << "Tooling dir could not be created"; + return false; + } + + if (!QmlToolingSupport::lockTooling()) { + return false; + } + + if (!QmlToolingSupport::updateQmllsConfig(configRoot, false)) { + QDir(vfs->filePath("qs")).removeRecursively(); + return false; + } + + QmlToolingSupport::updateToolingFs(scanner, configRoot, vfs->filePath("qs")); + return true; +} + +bool QmlToolingSupport::lockTooling() { + if (QmlToolingSupport::toolingLock) return true; + + auto lockPath = QsPaths::instance()->shellVfsDir()->filePath("tooling.lock"); + auto* file = new QFile(lockPath); + + if (!file->open(QFile::WriteOnly)) { + qCCritical(logTooling) << "Could not open tooling lock for write"; + return false; + } + + struct flock lock = { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, // NOLINT (fcntl.h??) + .l_start = 0, + .l_len = 0, + .l_pid = 0, + }; + + if (fcntl(file->handle(), F_SETLK, &lock) == 0) { + qCInfo(logTooling) << "Acquired tooling support lock"; + QmlToolingSupport::toolingLock = file; + return true; + } else if (errno == EACCES || errno == EAGAIN) { + qCInfo(logTooling) << "Tooling support locked by another instance"; + return false; + } else { + qCCritical(logTooling).nospace() << "Could not create tooling lock at " << lockPath + << " with error code " << errno << ": " << qt_error_string(); + return false; + } +} + +QString QmlToolingSupport::getQmllsConfig() { + static auto config = []() { + // We can't replicate the algorithm used to create the import path list as it can have distro + // specific patches, e.g. nixos. + auto importPaths = QQmlEngine().importPathList(); + importPaths.removeIf([](const QString& path) { return path.startsWith("qrc:"); }); + + auto vfsPath = QsPaths::instance()->shellVfsDir()->path(); + auto importPathsStr = importPaths.join(u':'); + + QString qmllsConfig; + auto print = QDebug(&qmllsConfig).nospace(); + print << "[General]\nno-cmake-calls=true\nbuildDir=" << vfsPath + << "\nimportPaths=" << importPathsStr << '\n'; + + return qmllsConfig; + }(); + + return config; +} + +bool QmlToolingSupport::updateQmllsConfig(const QDir& configRoot, bool create) { + auto shellConfigPath = configRoot.filePath(".qmlls.ini"); + auto vfsConfigPath = QsPaths::instance()->shellVfsDir()->filePath(".qmlls.ini"); + + auto shellFileInfo = QFileInfo(shellConfigPath); + if (!create && !shellFileInfo.exists() && !shellFileInfo.isSymLink()) { + if (QmlToolingSupport::toolingEnabled) { + qInfo() << "QML tooling support disabled"; + QmlToolingSupport::toolingEnabled = false; + } else { + qCInfo(logTooling) << "Not enabling QML tooling support, qmlls.ini is missing at path" + << shellConfigPath; + } + + QFile::remove(vfsConfigPath); + return false; + } + + auto vfsFile = QFile(vfsConfigPath); + + if (!vfsFile.open(QFile::ReadWrite | QFile::Text)) { + qCCritical(logTooling) << "Failed to create qmlls config in vfs"; + return false; + } + + auto config = QmlToolingSupport::getQmllsConfig(); + + if (vfsFile.readAll() != config) { + if (!vfsFile.resize(0) || !vfsFile.write(config.toUtf8())) { + qCCritical(logTooling) << "Failed to write qmlls config in vfs"; + return false; + } + + qCDebug(logTooling) << "Wrote qmlls config in vfs"; + } + + if (!shellFileInfo.isSymLink() || shellFileInfo.symLinkTarget() != vfsConfigPath) { + QFile::remove(shellConfigPath); + + if (!QFile::link(vfsConfigPath, shellConfigPath)) { + qCCritical(logTooling) << "Failed to create qmlls config symlink"; + return false; + } + + qCDebug(logTooling) << "Created qmlls config symlink"; + } + + if (!QmlToolingSupport::toolingEnabled) { + qInfo() << "QML tooling support enabled"; + QmlToolingSupport::toolingEnabled = true; + } + + return true; +} + +void QmlToolingSupport::updateToolingFs( + QmlScanner& scanner, + const QDir& scanDir, + const QDir& linkDir +) { + QList files; + QSet subdirs; + + auto scanPath = scanDir.path(); + + linkDir.mkpath("."); + + for (auto& path: scanner.scannedFiles) { + if (path.length() < scanPath.length() + 1 || !path.startsWith(scanPath)) continue; + auto name = path.sliced(scanPath.length() + 1); + + if (name.contains('/')) { + auto dirname = name.first(name.indexOf('/')); + subdirs.insert(dirname); + continue; + } + + auto fileInfo = QFileInfo(path); + if (!fileInfo.isFile()) continue; + + if (scanner.fileIntercepts.contains(path)) continue; + + auto spath = linkDir.filePath(name); + auto sFileInfo = QFileInfo(spath); + + if (!sFileInfo.isSymLink() || sFileInfo.symLinkTarget() != path) { + QFile::remove(spath); + + if (QFile::link(path, spath)) { + qCDebug(logTooling) << "Created symlink to" << path << "at" << spath; + files.append(spath); + } else { + qCCritical(logTooling) << "Could not create symlink to" << path << "at" << spath; + } + } else { + files.append(spath); + } + } + + for (auto [path, text]: scanner.fileIntercepts.asKeyValueRange()) { + if (path.length() < scanPath.length() + 1 || !path.startsWith(scanPath)) continue; + auto name = path.sliced(scanPath.length() + 1); + + if (name.contains('/')) { + auto dirname = name.first(name.indexOf('/')); + subdirs.insert(dirname); + continue; + } + + auto spath = linkDir.filePath(name); + QFile::remove(spath); + + auto file = QFile(spath); + if (!file.open(QFile::ReadWrite | QFile::Text | QFile::NewOnly)) { + qCCritical(logTooling) << "Failed to open injected file" << spath; + continue; + } + + if (file.readAll() == text) { + files.append(spath); + continue; + } + + if (file.resize(0) && file.write(text.toUtf8())) { + files.append(spath); + qCDebug(logTooling) << "Wrote injected file" << spath; + } else { + qCCritical(logTooling) << "Failed to write injected file" << spath; + } + } + + for (auto& name: linkDir.entryList(QDir::Files | QDir::System)) { // System = broken symlinks + auto path = linkDir.filePath(name); + + if (!files.contains(path)) { + if (QFile::remove(path)) qCDebug(logTooling) << "Removed old file at" << path; + else qCWarning(logTooling) << "Failed to remove old file at" << path; + } + } + + for (const auto& subdir: subdirs) { + QmlToolingSupport::updateToolingFs(scanner, scanDir.filePath(subdir), linkDir.filePath(subdir)); + } +} + +} // namespace qs::core diff --git a/src/core/toolsupport.hpp b/src/core/toolsupport.hpp new file mode 100644 index 0000000..9fb7921 --- /dev/null +++ b/src/core/toolsupport.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "scan.hpp" + +namespace qs::core { + +class QmlToolingSupport { +public: + static bool updateTooling(const QDir& configRoot, QmlScanner& scanner); + +private: + static QString getQmllsConfig(); + static bool lockTooling(); + static bool updateQmllsConfig(const QDir& configRoot, bool create); + static void updateToolingFs(QmlScanner& scanner, const QDir& scanDir, const QDir& linkDir); + static inline bool toolingEnabled = false; + static inline QFile* toolingLock = nullptr; +}; + +} // namespace qs::core diff --git a/src/core/util.hpp b/src/core/util.hpp index 88583d0..bb8dd85 100644 --- a/src/core/util.hpp +++ b/src/core/util.hpp @@ -29,7 +29,7 @@ struct StringLiteral16 { } [[nodiscard]] constexpr const QChar* qCharPtr() const noexcept { - return std::bit_cast(&this->value); + return std::bit_cast(&this->value); // NOLINT } [[nodiscard]] Q_ALWAYS_INLINE operator QString() const noexcept { @@ -251,37 +251,6 @@ public: GuardedEmitBlocker block() { return GuardedEmitBlocker(&this->blocked); } }; -template -class SimpleObjectHandleOps { - using Traits = MemberPointerTraits; - -public: - static bool setObject(Traits::Class* parent, Traits::Type value) { - if (value == parent->*member) return false; - - if (parent->*member != nullptr) { - QObject::disconnect(parent->*member, &QObject::destroyed, parent, destroyedSlot); - } - - parent->*member = value; - - if (value != nullptr) { - QObject::connect(parent->*member, &QObject::destroyed, parent, destroyedSlot); - } - - if constexpr (changedSignal != nullptr) { - emit(parent->*changedSignal)(); - } - - return true; - } -}; - -template -bool setSimpleObjectHandle(auto* parent, auto* value) { - return SimpleObjectHandleOps::setObject(parent, value); -} - template class MethodFunctor { using PtrMeta = MemberPointerTraits; diff --git a/src/crash/CMakeLists.txt b/src/crash/CMakeLists.txt index 7fdd830..a891ee9 100644 --- a/src/crash/CMakeLists.txt +++ b/src/crash/CMakeLists.txt @@ -6,12 +6,51 @@ qt_add_library(quickshell-crash STATIC qs_pch(quickshell-crash SET large) -find_package(PkgConfig REQUIRED) -pkg_check_modules(breakpad REQUIRED IMPORTED_TARGET breakpad) -# only need client?? take only includes from pkg config todo -target_link_libraries(quickshell-crash PRIVATE PkgConfig::breakpad -lbreakpad_client) +if (VENDOR_CPPTRACE) + message(STATUS "Vendoring cpptrace...") + include(FetchContent) + + # For use without internet access see: https://cmake.org/cmake/help/latest/module/FetchContent.html#variable:FETCHCONTENT_SOURCE_DIR_%3CuppercaseName%3E + FetchContent_Declare( + cpptrace + GIT_REPOSITORY https://github.com/jeremy-rifkin/cpptrace.git + GIT_TAG v1.0.4 + ) + + set(CPPTRACE_UNWIND_WITH_LIBUNWIND TRUE) + FetchContent_MakeAvailable(cpptrace) +else () + find_package(cpptrace REQUIRED) + + # useful for cross after you have already checked cpptrace is built correctly + if (NOT DO_NOT_CHECK_CPPTRACE_USABILITY) + try_run(CPPTRACE_SIGNAL_SAFE_UNWIND CPPTRACE_SIGNAL_SAFE_UNWIND_COMP + SOURCE_FROM_CONTENT check.cxx " + #include + int main() { + return cpptrace::can_signal_safe_unwind() ? 0 : 1; + } + " + LOG_DESCRIPTION "Checking ${CPPTRACE_SIGNAL_SAFE_UNWIND}" + LINK_LIBRARIES cpptrace::cpptrace + COMPILE_OUTPUT_VARIABLE CPPTRACE_SIGNAL_SAFE_UNWIND_LOG + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON + ) + + if (NOT CPPTRACE_SIGNAL_SAFE_UNWIND_COMP) + message(STATUS "${CPPTRACE_SIGNAL_SAFE_UNWIND_LOG}") + message(FATAL_ERROR "Failed to compile cpptrace signal safe unwind tester.") + endif() + + if (NOT CPPTRACE_SIGNAL_SAFE_UNWIND EQUAL 0) + message(STATUS "Cpptrace signal safe unwind test exited with: ${CPPTRACE_SIGNAL_SAFE_UNWIND}") + message(FATAL_ERROR "Cpptrace was built without CPPTRACE_UNWIND_WITH_LIBUNWIND set to true. Enable libunwind support in the package or set VENDOR_CPPTRACE to true when building Quickshell.") + endif() + endif () +endif () # quick linked for pch compat -target_link_libraries(quickshell-crash PRIVATE quickshell-build Qt::Quick Qt::Widgets) +target_link_libraries(quickshell-crash PRIVATE quickshell-build Qt::Quick Qt::Widgets cpptrace::cpptrace) target_link_libraries(quickshell PRIVATE quickshell-crash) diff --git a/src/crash/handler.cpp b/src/crash/handler.cpp index 8d9a8a7..33506a6 100644 --- a/src/crash/handler.cpp +++ b/src/crash/handler.cpp @@ -1,12 +1,15 @@ #include "handler.hpp" +#include #include +#include +#include #include #include +#include -#include -#include -#include -#include +#include +#include +#include #include #include #include @@ -15,94 +18,81 @@ #include #include "../core/instanceinfo.hpp" +#include "../core/logcat.hpp" extern char** environ; // NOLINT -using namespace google_breakpad; - namespace qs::crash { namespace { -Q_LOGGING_CATEGORY(logCrashHandler, "quickshell.crashhandler", QtWarningMsg); -} -struct CrashHandlerPrivate { - ExceptionHandler* exceptionHandler = nullptr; - int minidumpFd = -1; - int infoFd = -1; +QS_LOGGING_CATEGORY(logCrashHandler, "quickshell.crashhandler", QtWarningMsg); - static bool minidumpCallback(const MinidumpDescriptor& descriptor, void* context, bool succeeded); -}; +void writeEnvInt(char* buf, const char* name, int value) { + // NOLINTBEGIN (cppcoreguidelines-pro-bounds-pointer-arithmetic) + while (*name != '\0') *buf++ = *name++; + *buf++ = '='; -CrashHandler::CrashHandler(): d(new CrashHandlerPrivate()) {} - -void CrashHandler::init() { - // MinidumpDescriptor has no move constructor and the copy constructor breaks fds. - auto createHandler = [this](const MinidumpDescriptor& desc) { - this->d->exceptionHandler = new ExceptionHandler( - desc, - nullptr, - &CrashHandlerPrivate::minidumpCallback, - this->d, - true, - -1 - ); - }; - - qCDebug(logCrashHandler) << "Starting crash handler..."; - - this->d->minidumpFd = memfd_create("quickshell:minidump", MFD_CLOEXEC); - - if (this->d->minidumpFd == -1) { - qCCritical(logCrashHandler - ) << "Failed to allocate minidump memfd, minidumps will be saved in the working directory."; - createHandler(MinidumpDescriptor(".")); - } else { - qCDebug(logCrashHandler) << "Created memfd" << this->d->minidumpFd - << "for holding possible minidumps."; - createHandler(MinidumpDescriptor(this->d->minidumpFd)); + if (value < 0) { + *buf++ = '-'; + value = -value; } - qCInfo(logCrashHandler) << "Crash handler initialized."; -} - -void CrashHandler::setRelaunchInfo(const RelaunchInfo& info) { - this->d->infoFd = memfd_create("quickshell:instance_info", MFD_CLOEXEC); - - if (this->d->infoFd == -1) { - qCCritical(logCrashHandler - ) << "Failed to allocate instance info memfd, crash recovery will not work."; + if (value == 0) { + *buf++ = '0'; + *buf = '\0'; return; } - QFile file; - file.open(this->d->infoFd, QFile::ReadWrite); - - QDataStream ds(&file); - ds << info; - file.flush(); - - qCDebug(logCrashHandler) << "Stored instance info in memfd" << this->d->infoFd; -} - -CrashHandler::~CrashHandler() { - delete this->d->exceptionHandler; - delete this->d; -} - -bool CrashHandlerPrivate::minidumpCallback( - const MinidumpDescriptor& /*descriptor*/, - void* context, - bool /*success*/ -) { - // A fork that just dies to ensure the coredump is caught by the system. - auto coredumpPid = fork(); - - if (coredumpPid == 0) { - return false; + auto* start = buf; + while (value > 0) { + *buf++ = static_cast('0' + (value % 10)); + value /= 10; } - auto* self = static_cast(context); + *buf = '\0'; + std::reverse(start, buf); + // NOLINTEND +} + +void signalHandler( + int sig, + siginfo_t* /*info*/, // NOLINT (misc-include-cleaner) + void* /*context*/ +) { + // NOLINTBEGIN (misc-include-cleaner) + sigset_t set; + sigfillset(&set); + sigprocmask(SIG_UNBLOCK, &set, nullptr); + // NOLINTEND + + if (CrashInfo::INSTANCE.traceFd != -1) { + auto traceBuffer = std::array(); + auto frameCount = cpptrace::safe_generate_raw_trace(traceBuffer.data(), traceBuffer.size(), 1); + + for (size_t i = 0; i < static_cast(frameCount); i++) { + auto frame = cpptrace::safe_object_frame(); + cpptrace::get_safe_object_frame(traceBuffer[i], &frame); + + auto* wptr = reinterpret_cast(&frame); + auto* end = wptr + sizeof(cpptrace::safe_object_frame); // NOLINT + while (wptr != end) { + auto r = write(CrashInfo::INSTANCE.traceFd, &frame, sizeof(cpptrace::safe_object_frame)); + if (r < 0 && errno == EINTR) continue; + if (r <= 0) goto fail; + wptr += r; // NOLINT + } + } + + fail:; + } + + // TODO: coredump fork and crash reporter remain as zombies, fix + auto coredumpPid = fork(); + if (coredumpPid == 0) { + raise(sig); + _exit(-1); + } auto exe = std::array(); if (readlink("/proc/self/exe", exe.data(), exe.size() - 1) == -1) { @@ -115,17 +105,19 @@ bool CrashHandlerPrivate::minidumpCallback( auto env = std::array(); auto envi = 0; - auto infoFd = dup(self->infoFd); - auto infoFdStr = std::array(); - memcpy(infoFdStr.data(), "__QUICKSHELL_CRASH_INFO_FD=-1" /*\0*/, 30); - if (infoFd != -1) my_uitos(&infoFdStr[27], infoFd, 10); + // dup to remove CLOEXEC + auto infoFdStr = std::array(); + writeEnvInt(infoFdStr.data(), "__QUICKSHELL_CRASH_INFO_FD", dup(CrashInfo::INSTANCE.infoFd)); env[envi++] = infoFdStr.data(); - auto corePidStr = std::array(); - memcpy(corePidStr.data(), "__QUICKSHELL_CRASH_DUMP_PID=-1" /*\0*/, 31); - if (coredumpPid != -1) my_uitos(&corePidStr[28], coredumpPid, 10); + auto corePidStr = std::array(); + writeEnvInt(corePidStr.data(), "__QUICKSHELL_CRASH_DUMP_PID", coredumpPid); env[envi++] = corePidStr.data(); + auto sigStr = std::array(); + writeEnvInt(sigStr.data(), "__QUICKSHELL_CRASH_SIGNAL", sig); + env[envi++] = sigStr.data(); + auto populateEnv = [&]() { auto senvi = 0; while (envi != 4095) { @@ -137,30 +129,17 @@ bool CrashHandlerPrivate::minidumpCallback( env[envi] = nullptr; }; - sigset_t sigset; - sigemptyset(&sigset); // NOLINT (include) - sigprocmask(SIG_SETMASK, &sigset, nullptr); // NOLINT - auto pid = fork(); if (pid == -1) { perror("Failed to fork and launch crash reporter.\n"); - return false; + _exit(-1); } else if (pid == 0) { // dup to remove CLOEXEC - // if already -1 will return -1 - auto dumpFd = dup(self->minidumpFd); - auto logFd = dup(CrashInfo::INSTANCE.logFd); - - // allow up to 10 digits, which should never happen - auto dumpFdStr = std::array(); - auto logFdStr = std::array(); - - memcpy(dumpFdStr.data(), "__QUICKSHELL_CRASH_DUMP_FD=-1" /*\0*/, 30); - memcpy(logFdStr.data(), "__QUICKSHELL_CRASH_LOG_FD=-1" /*\0*/, 29); - - if (dumpFd != -1) my_uitos(&dumpFdStr[27], dumpFd, 10); - if (logFd != -1) my_uitos(&logFdStr[26], logFd, 10); + auto dumpFdStr = std::array(); + auto logFdStr = std::array(); + writeEnvInt(dumpFdStr.data(), "__QUICKSHELL_CRASH_DUMP_FD", dup(CrashInfo::INSTANCE.traceFd)); + writeEnvInt(logFdStr.data(), "__QUICKSHELL_CRASH_LOG_FD", dup(CrashInfo::INSTANCE.logFd)); env[envi++] = dumpFdStr.data(); env[envi++] = logFdStr.data(); @@ -177,8 +156,99 @@ bool CrashHandlerPrivate::minidumpCallback( perror("Failed to relaunch quickshell.\n"); _exit(-1); } +} - return false; // should make sure it hits the system coredump handler +void handleCppTerminate() { + if (auto ptr = std::current_exception()) { + try { + std::rethrow_exception(ptr); + } catch (std::exception& e) { + qFatal().nospace() << "Terminate called with C++ exception (" + << cpptrace::demangle(typeid(e).name()).data() << "): " << e.what(); + } catch (...) { + qFatal() << "Terminate called with non exception object"; + } + } + + qFatal() << "Terminate called without active C++ exception"; +} + +} // namespace + +void CrashHandler::init() { + qCDebug(logCrashHandler) << "Starting crash handler..."; + + CrashInfo::INSTANCE.traceFd = memfd_create("quickshell:trace", MFD_CLOEXEC); + + if (CrashInfo::INSTANCE.traceFd == -1) { + qCCritical(logCrashHandler) << "Failed to allocate trace memfd, stack traces will not be " + "available in crash reports."; + } else { + qCDebug(logCrashHandler) << "Created memfd" << CrashInfo::INSTANCE.traceFd + << "for holding possible stack traces."; + } + + { + // Preload anything dynamically linked to avoid malloc etc in the dynamic loader. + // See cpptrace documentation for more information. + auto buffer = std::array(); + cpptrace::safe_generate_raw_trace(buffer.data(), buffer.size()); + auto frame = cpptrace::safe_object_frame(); + cpptrace::get_safe_object_frame(buffer[0], &frame); + } + + // NOLINTBEGIN (misc-include-cleaner) + + // Set up alternate signal stack for stack overflow handling + auto ss = stack_t(); + ss.ss_sp = new char[SIGSTKSZ]; + ss.ss_size = SIGSTKSZ; + ss.ss_flags = 0; + sigaltstack(&ss, nullptr); + + // Install signal handlers + struct sigaction sa {}; + sa.sa_sigaction = &signalHandler; + sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESETHAND; + sigemptyset(&sa.sa_mask); + + sigaction(SIGSEGV, &sa, nullptr); + sigaction(SIGABRT, &sa, nullptr); + sigaction(SIGFPE, &sa, nullptr); + sigaction(SIGILL, &sa, nullptr); + sigaction(SIGBUS, &sa, nullptr); + sigaction(SIGTRAP, &sa, nullptr); + + // NOLINTEND (misc-include-cleaner) + + std::set_terminate(&handleCppTerminate); + + qCInfo(logCrashHandler) << "Crash handler initialized."; +} + +void CrashHandler::setRelaunchInfo(const RelaunchInfo& info) { + CrashInfo::INSTANCE.infoFd = memfd_create("quickshell:instance_info", MFD_CLOEXEC); + + if (CrashInfo::INSTANCE.infoFd == -1) { + qCCritical( + logCrashHandler + ) << "Failed to allocate instance info memfd, crash recovery will not work."; + return; + } + + QFile file; + + if (!file.open(CrashInfo::INSTANCE.infoFd, QFile::ReadWrite)) { + qCCritical( + logCrashHandler + ) << "Failed to open instance info memfd, crash recovery will not work."; + } + + QDataStream ds(&file); + ds << info; + file.flush(); + + qCDebug(logCrashHandler) << "Stored instance info in memfd" << CrashInfo::INSTANCE.infoFd; } } // namespace qs::crash diff --git a/src/crash/handler.hpp b/src/crash/handler.hpp index 2a1d86f..9488d71 100644 --- a/src/crash/handler.hpp +++ b/src/crash/handler.hpp @@ -5,19 +5,10 @@ #include "../core/instanceinfo.hpp" namespace qs::crash { -struct CrashHandlerPrivate; - class CrashHandler { public: - explicit CrashHandler(); - ~CrashHandler(); - Q_DISABLE_COPY_MOVE(CrashHandler); - - void init(); - void setRelaunchInfo(const RelaunchInfo& info); - -private: - CrashHandlerPrivate* d; + static void init(); + static void setRelaunchInfo(const RelaunchInfo& info); }; } // namespace qs::crash diff --git a/src/crash/interface.cpp b/src/crash/interface.cpp index c633440..6a370ce 100644 --- a/src/crash/interface.cpp +++ b/src/crash/interface.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -12,11 +13,22 @@ #include #include #include +#include #include #include #include "build.hpp" +namespace { +QString crashreportUrl() { + if (auto url = qEnvironmentVariable("QS_CRASHREPORT_URL"); !url.isEmpty()) { + return url; + } + + return CRASHREPORT_URL; +} +} // namespace + class ReportLabel: public QWidget { public: ReportLabel(const QString& label, const QString& content, QWidget* parent): QWidget(parent) { @@ -66,22 +78,17 @@ CrashReporterGui::CrashReporterGui(QString reportFolder, int pid) mainLayout->addSpacing(textHeight); if (qtVersionMatches) { - mainLayout->addWidget(new QLabel("Please open a bug report for this issue via github or email.") + mainLayout->addWidget( + new QLabel("Please open a bug report for this issue on the issue tracker.") ); } else { mainLayout->addWidget(new QLabel( "Please rebuild Quickshell against the current Qt version.\n" - "If this does not solve the problem, please open a bug report via github or email." + "If this does not solve the problem, please open a bug report on the issue tracker." )); } - mainLayout->addWidget(new ReportLabel( - "Github:", - "https://github.com/quickshell-mirror/quickshell/issues/new?template=crash.yml", - this - )); - - mainLayout->addWidget(new ReportLabel("Email:", "quickshell-bugs@outfoxxed.me", this)); + mainLayout->addWidget(new ReportLabel("Tracker:", crashreportUrl(), this)); auto* buttons = new QWidget(this); buttons->setMinimumWidth(900); @@ -111,10 +118,5 @@ void CrashReporterGui::openFolder() { QDesktopServices::openUrl(QUrl::fromLocalFile(this->reportFolder)); } -void CrashReporterGui::openReportUrl() { - QDesktopServices::openUrl( - QUrl("https://github.com/outfoxxed/quickshell/issues/new?template=crash.yml") - ); -} - +void CrashReporterGui::openReportUrl() { QDesktopServices::openUrl(QUrl(crashreportUrl())); } void CrashReporterGui::cancel() { QApplication::quit(); } diff --git a/src/crash/main.cpp b/src/crash/main.cpp index 7c3bad7..6533b43 100644 --- a/src/crash/main.cpp +++ b/src/crash/main.cpp @@ -1,9 +1,11 @@ #include "main.hpp" #include #include +#include +#include +#include #include -#include #include #include #include @@ -12,19 +14,24 @@ #include #include #include -#include +#include #include #include +#include +#include "../core/debuginfo.hpp" #include "../core/instanceinfo.hpp" +#include "../core/logcat.hpp" #include "../core/logging.hpp" +#include "../core/logging_p.hpp" #include "../core/paths.hpp" -#include "build.hpp" +#include "../core/plugin.hpp" +#include "../core/ringbuf.hpp" #include "interface.hpp" namespace { -Q_LOGGING_CATEGORY(logCrashReporter, "quickshell.crashreporter", QtWarningMsg); +QS_LOGGING_CATEGORY(logCrashReporter, "quickshell.crashreporter", QtWarningMsg); int tryDup(int fd, const QString& path) { QFile sourceFile; @@ -60,6 +67,76 @@ int tryDup(int fd, const QString& path) { return 0; } +QString readRecentLogs(int logFd, int maxLines, qint64 maxAgeSecs) { + QFile file; + if (!file.open(logFd, QFile::ReadOnly, QFile::AutoCloseHandle)) { + return QStringLiteral("(failed to open log fd)\n"); + } + + file.seek(0); + + qs::log::EncodedLogReader reader; + reader.setDevice(&file); + + bool readable = false; + quint8 logVersion = 0; + quint8 readerVersion = 0; + if (!reader.readHeader(&readable, &logVersion, &readerVersion) || !readable) { + return QStringLiteral("(failed to read log header)\n"); + } + + // Read all messages, keeping last maxLines in a ring buffer + auto tail = RingBuffer(maxLines); + qs::log::LogMessage message; + while (reader.read(&message)) { + tail.emplace(message); + } + + if (tail.size() == 0) { + return QStringLiteral("(no logs)\n"); + } + + // Filter to only messages within maxAgeSecs of the newest message + auto cutoff = tail.at(0).time.addSecs(-maxAgeSecs); + + QString result; + auto stream = QTextStream(&result); + for (auto i = tail.size() - 1; i != -1; i--) { + if (tail.at(i).time < cutoff) continue; + qs::log::LogMessage::formatMessage(stream, tail.at(i), false, true); + stream << '\n'; + } + + if (result.isEmpty()) { + return QStringLiteral("(no recent logs)\n"); + } + + return result; +} + +cpptrace::stacktrace resolveStacktrace(int dumpFd) { + QFile sourceFile; + if (!sourceFile.open(dumpFd, QFile::ReadOnly, QFile::AutoCloseHandle)) { + qCCritical(logCrashReporter) << "Failed to open trace memfd."; + return {}; + } + + sourceFile.seek(0); + auto data = sourceFile.readAll(); + + auto frameCount = static_cast(data.size()) / sizeof(cpptrace::safe_object_frame); + if (frameCount == 0) return {}; + + const auto* frames = reinterpret_cast(data.constData()); + + cpptrace::object_trace objectTrace; + for (size_t i = 0; i < frameCount; i++) { + objectTrace.frames.push_back(frames[i].resolve()); // NOLINT + } + + return objectTrace.resolve(); +} + void recordCrashInfo(const QDir& crashDir, const InstanceInfo& instance) { qCDebug(logCrashReporter) << "Recording crash information at" << crashDir.path(); @@ -70,74 +147,49 @@ void recordCrashInfo(const QDir& crashDir, const InstanceInfo& instance) { } auto crashProc = qEnvironmentVariable("__QUICKSHELL_CRASH_DUMP_PID").toInt(); + auto crashSignal = qEnvironmentVariable("__QUICKSHELL_CRASH_SIGNAL").toInt(); auto dumpFd = qEnvironmentVariable("__QUICKSHELL_CRASH_DUMP_FD").toInt(); auto logFd = qEnvironmentVariable("__QUICKSHELL_CRASH_LOG_FD").toInt(); - qCDebug(logCrashReporter) << "Saving minidump from fd" << dumpFd; - auto dumpDupStatus = tryDup(dumpFd, crashDir.filePath("minidump.dmp.log")); - if (dumpDupStatus != 0) { - qCCritical(logCrashReporter) << "Failed to write minidump:" << dumpDupStatus; - } + qCDebug(logCrashReporter) << "Resolving stacktrace from fd" << dumpFd; + auto stacktrace = resolveStacktrace(dumpFd); - qCDebug(logCrashReporter) << "Saving log from fd" << logFd; - auto logDupStatus = tryDup(logFd, crashDir.filePath("log.qslog.log")); + qCDebug(logCrashReporter) << "Reading recent log lines from fd" << logFd; + auto logDupFd = dup(logFd); + auto recentLogs = readRecentLogs(logFd, 100, 10); + + qCDebug(logCrashReporter) << "Saving log from fd" << logDupFd; + auto logDupStatus = tryDup(logDupFd, crashDir.filePath("log.qslog.log")); if (logDupStatus != 0) { qCCritical(logCrashReporter) << "Failed to save log:" << logDupStatus; } - auto copyBinStatus = 0; - if (!DISTRIBUTOR_DEBUGINFO_AVAILABLE) { - qCDebug(logCrashReporter) << "Copying binary to crash folder"; - if (!QFile(QCoreApplication::applicationFilePath()).copy(crashDir.filePath("executable.txt"))) { - copyBinStatus = 1; - qCCritical(logCrashReporter) << "Failed to copy binary."; - } - } - { - auto extraInfoFile = QFile(crashDir.filePath("info.txt")); + auto extraInfoFile = QFile(crashDir.filePath("report.txt")); if (!extraInfoFile.open(QFile::WriteOnly)) { qCCritical(logCrashReporter) << "Failed to open crash info file for writing."; } else { auto stream = QTextStream(&extraInfoFile); - stream << "===== Build Information =====\n"; - stream << "Git Revision: " << GIT_REVISION << '\n'; - stream << "Buildtime Qt Version: " << QT_VERSION_STR << "\n"; - stream << "Build Type: " << BUILD_TYPE << '\n'; - stream << "Compiler: " << COMPILER << '\n'; - stream << "Complie Flags: " << COMPILE_FLAGS << "\n\n"; - stream << "Build configuration:\n" << BUILD_CONFIGURATION << "\n"; + stream << qs::debuginfo::combinedInfo(); - stream << "\n===== Runtime Information =====\n"; - stream << "Runtime Qt Version: " << qVersion() << '\n'; + stream << "\n===== Instance Information =====\n"; + stream << "Signal: " << strsignal(crashSignal) << " (" << crashSignal << ")\n"; // NOLINT stream << "Crashed process ID: " << crashProc << '\n'; stream << "Run ID: " << instance.instanceId << '\n'; stream << "Shell ID: " << instance.shellId << '\n'; stream << "Config Path: " << instance.configPath << '\n'; - stream << "\n===== Report Integrity =====\n"; - stream << "Minidump save status: " << dumpDupStatus << '\n'; - stream << "Log save status: " << logDupStatus << '\n'; - stream << "Binary copy status: " << copyBinStatus << '\n'; - - stream << "\n===== System Information =====\n\n"; - stream << "/etc/os-release:"; - auto osReleaseFile = QFile("/etc/os-release"); - if (osReleaseFile.open(QFile::ReadOnly)) { - stream << '\n' << osReleaseFile.readAll() << '\n'; - osReleaseFile.close(); + stream << "\n===== Stacktrace =====\n"; + if (stacktrace.empty()) { + stream << "(no trace available)\n"; } else { - stream << "FAILED TO OPEN\n"; + auto formatter = cpptrace::formatter().header(std::string()); + auto traceStr = formatter.format(stacktrace); + stream << QString::fromStdString(traceStr) << '\n'; } - stream << "/etc/lsb-release:"; - auto lsbReleaseFile = QFile("/etc/lsb-release"); - if (lsbReleaseFile.open(QFile::ReadOnly)) { - stream << '\n' << lsbReleaseFile.readAll(); - lsbReleaseFile.close(); - } else { - stream << "FAILED TO OPEN\n"; - } + stream << "\n===== Log Tail =====\n"; + stream << recentLogs; extraInfoFile.close(); } @@ -151,7 +203,6 @@ void recordCrashInfo(const QDir& crashDir, const InstanceInfo& instance) { void qsCheckCrash(int argc, char** argv) { auto fd = qEnvironmentVariable("__QUICKSHELL_CRASH_DUMP_FD"); if (fd.isEmpty()) return; - auto app = QApplication(argc, argv); RelaunchInfo info; @@ -161,7 +212,10 @@ void qsCheckCrash(int argc, char** argv) { auto infoFd = qEnvironmentVariable("__QUICKSHELL_CRASH_INFO_FD").toInt(); QFile file; - file.open(infoFd, QFile::ReadOnly, QFile::AutoCloseHandle); + if (!file.open(infoFd, QFile::ReadOnly, QFile::AutoCloseHandle)) { + qFatal() << "Failed to open instance info fd."; + } + file.seek(0); auto ds = QDataStream(&file); @@ -176,10 +230,18 @@ void qsCheckCrash(int argc, char** argv) { info.logRules ); + auto app = QApplication(argc, argv); + auto desktopId = + info.instance.appId.isEmpty() ? QStringLiteral("org.quickshell") : info.instance.appId; + QApplication::setDesktopFileName(desktopId); + auto crashDir = QsPaths::crashDir(info.instance.instanceId); qCInfo(logCrashReporter) << "Starting crash reporter..."; + // Required platform compatibility hooks + QsEnginePlugin::preinitPluginsOnly(); + recordCrashInfo(crashDir, info.instance); auto gui = CrashReporterGui(crashDir.path(), crashProc); diff --git a/src/dbus/CMakeLists.txt b/src/dbus/CMakeLists.txt index 9948ea7..fc004f3 100644 --- a/src/dbus/CMakeLists.txt +++ b/src/dbus/CMakeLists.txt @@ -2,13 +2,24 @@ set_source_files_properties(org.freedesktop.DBus.Properties.xml PROPERTIES CLASSNAME DBusPropertiesInterface ) +set_source_files_properties(org.freedesktop.DBus.ObjectManager.xml PROPERTIES + CLASSNAME DBusObjectManagerInterface + INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/dbus_objectmanager_types.hpp +) + qt_add_dbus_interface(DBUS_INTERFACES org.freedesktop.DBus.Properties.xml dbus_properties ) +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.DBus.ObjectManager.xml + dbus_objectmanager +) + qt_add_library(quickshell-dbus STATIC properties.cpp + objectmanager.cpp bus.cpp ${DBUS_INTERFACES} ) diff --git a/src/dbus/bus.cpp b/src/dbus/bus.cpp index dc6d21b..d53c4c6 100644 --- a/src/dbus/bus.cpp +++ b/src/dbus/bus.cpp @@ -12,10 +12,12 @@ #include #include +#include "../core/logcat.hpp" + namespace qs::dbus { namespace { -Q_LOGGING_CATEGORY(logDbus, "quickshell.dbus", QtWarningMsg); +QS_LOGGING_CATEGORY(logDbus, "quickshell.dbus", QtWarningMsg); } void tryLaunchService( diff --git a/src/dbus/dbus_objectmanager_types.hpp b/src/dbus/dbus_objectmanager_types.hpp new file mode 100644 index 0000000..5e0869c --- /dev/null +++ b/src/dbus/dbus_objectmanager_types.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include +#include +#include +#include +#include + +using DBusObjectManagerInterfaces = QHash; +using DBusObjectManagerObjects = QHash; diff --git a/src/dbus/dbusmenu/dbusmenu.cpp b/src/dbus/dbusmenu/dbusmenu.cpp index 2b633b7..bcb354d 100644 --- a/src/dbus/dbusmenu/dbusmenu.cpp +++ b/src/dbus/dbusmenu/dbusmenu.cpp @@ -21,13 +21,14 @@ #include #include "../../core/iconimageprovider.hpp" +#include "../../core/logcat.hpp" #include "../../core/model.hpp" #include "../../core/qsmenu.hpp" #include "../../dbus/properties.hpp" #include "dbus_menu.h" #include "dbus_menu_types.hpp" -Q_LOGGING_CATEGORY(logDbusMenu, "quickshell.dbus.dbusmenu", QtWarningMsg); +QS_LOGGING_CATEGORY(logDbusMenu, "quickshell.dbus.dbusmenu", QtWarningMsg); using namespace qs::menu; @@ -182,7 +183,7 @@ void DBusMenuItem::updateProperties(const QVariantMap& properties, const QString } } else if (removed.isEmpty() || removed.contains("icon-data")) { imageChanged = this->image.hasData(); - image.data.clear(); + this->image.data.clear(); } auto type = properties.value("type"); @@ -311,8 +312,8 @@ void DBusMenu::prepareToShow(qint32 item, qint32 depth) { auto responseCallback = [this, item, depth](QDBusPendingCallWatcher* call) { const QDBusPendingReply reply = *call; if (reply.isError()) { - qCWarning(logDbusMenu) << "Error in AboutToShow, but showing anyway for menu" << item << "of" - << this << reply.error(); + qCDebug(logDbusMenu) << "Error in AboutToShow, but showing anyway for menu" << item << "of" + << this << reply.error(); } this->updateLayout(item, depth); diff --git a/src/dbus/dbusmenu/dbusmenu.hpp b/src/dbus/dbusmenu/dbusmenu.hpp index 1a8b399..06cbc34 100644 --- a/src/dbus/dbusmenu/dbusmenu.hpp +++ b/src/dbus/dbusmenu/dbusmenu.hpp @@ -20,7 +20,7 @@ #include "../properties.hpp" #include "dbus_menu_types.hpp" -Q_DECLARE_LOGGING_CATEGORY(logDbusMenu); +QS_DECLARE_LOGGING_CATEGORY(logDbusMenu); class DBusMenuInterface; @@ -36,7 +36,7 @@ class DBusMenuPngImage: public QsIndexedImageHandle { public: explicit DBusMenuPngImage(): QsIndexedImageHandle(QQuickImageProvider::Image) {} - [[nodiscard]] bool hasData() const { return !data.isEmpty(); } + [[nodiscard]] bool hasData() const { return !this->data.isEmpty(); } QImage requestImage(const QString& id, QSize* size, const QSize& requestedSize) override; QByteArray data; diff --git a/src/dbus/objectmanager.cpp b/src/dbus/objectmanager.cpp new file mode 100644 index 0000000..258f6fe --- /dev/null +++ b/src/dbus/objectmanager.cpp @@ -0,0 +1,87 @@ +#include "objectmanager.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "dbus_objectmanager.h" +#include "dbus_objectmanager_types.hpp" + +namespace { +QS_LOGGING_CATEGORY(logDbusObjectManager, "quickshell.dbus.objectmanager", QtWarningMsg); +} + +namespace qs::dbus { + +DBusObjectManager::DBusObjectManager(QObject* parent): QObject(parent) { + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); +} + +bool DBusObjectManager::setInterface( + const QString& service, + const QString& path, + const QDBusConnection& connection +) { + delete this->mInterface; + this->mInterface = new DBusObjectManagerInterface(service, path, connection, this); + + if (!this->mInterface->isValid()) { + qCWarning(logDbusObjectManager) << "Failed to create DBusObjectManagerInterface for" << service + << path << ":" << this->mInterface->lastError(); + delete this->mInterface; + this->mInterface = nullptr; + return false; + } + + QObject::connect( + this->mInterface, + &DBusObjectManagerInterface::InterfacesAdded, + this, + &DBusObjectManager::interfacesAdded + ); + + QObject::connect( + this->mInterface, + &DBusObjectManagerInterface::InterfacesRemoved, + this, + &DBusObjectManager::interfacesRemoved + ); + + this->fetchInitialObjects(); + return true; +} + +void DBusObjectManager::fetchInitialObjects() { + if (!this->mInterface) return; + + auto reply = this->mInterface->GetManagedObjects(); + auto* watcher = new QDBusPendingCallWatcher(reply, this); + + QObject::connect( + watcher, + &QDBusPendingCallWatcher::finished, + this, + [this](QDBusPendingCallWatcher* watcher) { + const QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + + if (reply.isError()) { + qCWarning(logDbusObjectManager) << "Failed to get managed objects:" << reply.error(); + return; + } + + for (const auto& [path, interfaces]: reply.value().asKeyValueRange()) { + emit this->interfacesAdded(path, interfaces); + } + } + ); +} + +} // namespace qs::dbus diff --git a/src/dbus/objectmanager.hpp b/src/dbus/objectmanager.hpp new file mode 100644 index 0000000..4246ea2 --- /dev/null +++ b/src/dbus/objectmanager.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#include "dbus_objectmanager_types.hpp" + +class DBusObjectManagerInterface; + +namespace qs::dbus { + +class DBusObjectManager: public QObject { + Q_OBJECT; + +public: + explicit DBusObjectManager(QObject* parent = nullptr); + + bool setInterface( + const QString& service, + const QString& path, + const QDBusConnection& connection = QDBusConnection::sessionBus() + ); + +signals: + void + interfacesAdded(const QDBusObjectPath& objectPath, const DBusObjectManagerInterfaces& interfaces); + void interfacesRemoved(const QDBusObjectPath& objectPath, const QStringList& interfaces); + +private: + void fetchInitialObjects(); + + DBusObjectManagerInterface* mInterface = nullptr; +}; + +} // namespace qs::dbus \ No newline at end of file diff --git a/src/dbus/org.freedesktop.DBus.ObjectManager.xml b/src/dbus/org.freedesktop.DBus.ObjectManager.xml new file mode 100644 index 0000000..24749f2 --- /dev/null +++ b/src/dbus/org.freedesktop.DBus.ObjectManager.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/dbus/properties.cpp b/src/dbus/properties.cpp index 52f5006..2c478ef 100644 --- a/src/dbus/properties.cpp +++ b/src/dbus/properties.cpp @@ -17,11 +17,13 @@ #include #include #include +#include #include +#include "../core/logcat.hpp" #include "dbus_properties.h" -Q_LOGGING_CATEGORY(logDbusProperties, "quickshell.dbus.properties", QtWarningMsg); +QS_LOGGING_CATEGORY(logDbusProperties, "quickshell.dbus.properties", QtWarningMsg); namespace qs::dbus { @@ -212,8 +214,10 @@ void DBusPropertyGroup::updatePropertySet(const QVariantMap& properties, bool co } } -void DBusPropertyGroup::tryUpdateProperty(DBusPropertyCore* property, const QVariant& variant) - const { +void DBusPropertyGroup::tryUpdateProperty( + DBusPropertyCore* property, + const QVariant& variant +) const { property->mExists = true; auto error = property->store(variant); @@ -244,8 +248,13 @@ void DBusPropertyGroup::requestPropertyUpdate(DBusPropertyCore* property) { const QDBusPendingReply reply = *call; if (reply.isError()) { - qCWarning(logDbusProperties).noquote() << "Error updating property" << propStr; - qCWarning(logDbusProperties) << reply.error(); + if (!property->isRequired() && reply.error().type() == QDBusError::InvalidArgs) { + qCDebug(logDbusProperties) << "Error updating non-required property" << propStr; + qCDebug(logDbusProperties) << reply.error(); + } else { + qCWarning(logDbusProperties).noquote() << "Error updating property" << propStr; + qCWarning(logDbusProperties) << reply.error(); + } } else { this->tryUpdateProperty(property, reply.value().variant()); } @@ -326,3 +335,10 @@ void DBusPropertyGroup::onPropertiesChanged( } } // namespace qs::dbus + +#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) +QDebug operator<<(QDebug debug, const QDBusObjectPath& path) { + debug.nospace() << "QDBusObjectPath(" << path.path() << ")"; + return debug; +} +#endif diff --git a/src/dbus/properties.hpp b/src/dbus/properties.hpp index 5c26a19..1596cb7 100644 --- a/src/dbus/properties.hpp +++ b/src/dbus/properties.hpp @@ -20,13 +20,15 @@ #include #include #include +#include #include +#include "../core/logcat.hpp" #include "../core/util.hpp" class DBusPropertiesInterface; -Q_DECLARE_LOGGING_CATEGORY(logDbusProperties); +QS_DECLARE_LOGGING_CATEGORY(logDbusProperties); namespace qs::dbus { @@ -166,9 +168,9 @@ class DBusBindableProperty: public DBusPropertyCore { public: explicit DBusBindableProperty() { this->group()->attachProperty(this); } - [[nodiscard]] QString name() const override { return Name; }; - [[nodiscard]] QStringView nameRef() const override { return Name; }; - [[nodiscard]] bool isRequired() const override { return required; }; + [[nodiscard]] QString name() const override { return Name; } + [[nodiscard]] QStringView nameRef() const override { return Name; } + [[nodiscard]] bool isRequired() const override { return required; } [[nodiscard]] QString valueString() override { QString str; @@ -215,7 +217,7 @@ protected: private: [[nodiscard]] constexpr Owner* owner() const { - auto* self = std::bit_cast(this); + auto* self = std::bit_cast(this); // NOLINT return std::bit_cast(self - offset()); // NOLINT } @@ -234,6 +236,7 @@ public: void attachProperty(DBusPropertyCore* property); void updateAllDirect(); void updateAllViaGetAll(); + void updatePropertySet(const QVariantMap& properties, bool complainMissing = true); [[nodiscard]] QString toString() const; [[nodiscard]] bool isConnected() const { return this->interface; } @@ -252,7 +255,6 @@ private slots: ); private: - void updatePropertySet(const QVariantMap& properties, bool complainMissing); void tryUpdateProperty(DBusPropertyCore* property, const QVariant& variant) const; [[nodiscard]] QString propertyString(const DBusPropertyCore* property) const; @@ -265,6 +267,10 @@ private: } // namespace qs::dbus +#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) +QDebug operator<<(QDebug debug, const QDBusObjectPath& path); +#endif + // NOLINTBEGIN #define QS_DBUS_BINDABLE_PROPERTY_GROUP(Class, name) qs::dbus::DBusPropertyGroup name {this}; diff --git a/src/debug/lint.cpp b/src/debug/lint.cpp index eb0450f..5e12f76 100644 --- a/src/debug/lint.cpp +++ b/src/debug/lint.cpp @@ -9,12 +9,14 @@ #include #include #include -#include +#include + +#include "../core/logcat.hpp" namespace qs::debug { namespace { -Q_LOGGING_CATEGORY(logLint, "quickshell.linter", QtWarningMsg); +QS_LOGGING_CATEGORY(logLint, "quickshell.linter", QtWarningMsg); void lintZeroSized(QQuickItem* item); bool isRenderable(QQuickItem* item); diff --git a/src/io/CMakeLists.txt b/src/io/CMakeLists.txt index 6bb8e70..991beaa 100644 --- a/src/io/CMakeLists.txt +++ b/src/io/CMakeLists.txt @@ -1,5 +1,6 @@ qt_add_library(quickshell-io STATIC datastream.cpp + processcore.cpp process.cpp fileview.cpp jsonadapter.cpp @@ -20,9 +21,10 @@ qt_add_qml_module(quickshell-io FileView.qml ) +qs_add_module_deps_light(quickshell-io Quickshell) install_qml_module(quickshell-io) -target_link_libraries(quickshell-io PRIVATE Qt::Quick) +target_link_libraries(quickshell-io PRIVATE Qt::Quick quickshell-ipc) target_link_libraries(quickshell PRIVATE quickshell-ioplugin) qs_module_pch(quickshell-io) diff --git a/src/io/datastream.hpp b/src/io/datastream.hpp index d83e571..b91ec04 100644 --- a/src/io/datastream.hpp +++ b/src/io/datastream.hpp @@ -55,7 +55,7 @@ public: // the buffer will be sent in both slots if there is data remaining from a previous parser virtual void parseBytes(QByteArray& incoming, QByteArray& buffer) = 0; - virtual void streamEnded(QByteArray& /*buffer*/) {}; + virtual void streamEnded(QByteArray& /*buffer*/) {} signals: /// Emitted when data is read from the stream. @@ -63,7 +63,7 @@ signals: }; ///! DataStreamParser for delimited data streams. -/// DataStreamParser for delimited data streams. @@read() is emitted once per delimited chunk of the stream. +/// DataStreamParser for delimited data streams. @@DataStreamParser.read(s) is emitted once per delimited chunk of the stream. class SplitParser: public DataStreamParser { Q_OBJECT; /// The delimiter for parsed data. May be multiple characters. Defaults to `\n`. diff --git a/src/io/fileview.cpp b/src/io/fileview.cpp index b6e911f..04d77bd 100644 --- a/src/io/fileview.cpp +++ b/src/io/fileview.cpp @@ -20,12 +20,13 @@ #include #include +#include "../core/logcat.hpp" #include "../core/util.hpp" namespace qs::io { namespace { -Q_LOGGING_CATEGORY(logFileView, "quickshell.io.fileview", QtWarningMsg); +QS_LOGGING_CATEGORY(logFileView, "quickshell.io.fileview", QtWarningMsg); } QString FileViewError::toString(FileViewError::Enum value) { @@ -92,7 +93,8 @@ void FileViewReader::run() { FileViewReader::read(this->owner, this->state, this->doStringConversion, this->shouldCancel); if (this->shouldCancel.loadAcquire()) { - qCDebug(logFileView) << "Read" << this << "of" << state.path << "canceled for" << this->owner; + qCDebug(logFileView) << "Read" << this << "of" << this->state.path << "canceled for" + << this->owner; } } @@ -205,7 +207,7 @@ void FileViewWriter::run() { FileViewWriter::write(this->owner, this->state, this->doAtomicWrite, this->shouldCancel); if (this->shouldCancel.loadAcquire()) { - qCDebug(logFileView) << "Write" << this << "of" << state.path << "canceled for" + qCDebug(logFileView) << "Write" << this << "of" << this->state.path << "canceled for" << this->owner; } } @@ -492,7 +494,10 @@ void FileView::updatePath() { void FileView::updateWatchedFiles() { // If inotify events are sent to the watcher after deletion and deleteLater // isn't used, a use after free in the QML engine will occur. - if (this->watcher) this->watcher->deleteLater(); + if (this->watcher) { + this->watcher->deleteLater(); + this->watcher = nullptr; + } if (!this->targetPath.isEmpty() && this->bWatchChanges) { qCDebug(logFileView) << "Creating watcher for" << this << "at" << this->targetPath; diff --git a/src/io/ipc.cpp b/src/io/ipc.cpp index 768299e..c381567 100644 --- a/src/io/ipc.cpp +++ b/src/io/ipc.cpp @@ -190,6 +190,14 @@ QString WirePropertyDefinition::toString() const { return "property " % this->name % ": " % this->type; } +QString WireSignalDefinition::toString() const { + if (this->rettype.isEmpty()) { + return "signal " % this->name % "()"; + } else { + return "signal " % this->name % "(" % this->retname % ": " % this->rettype % ')'; + } +} + QString WireTargetDefinition::toString() const { QString accum = "target " % this->name; @@ -201,6 +209,10 @@ QString WireTargetDefinition::toString() const { accum += "\n " % prop.toString(); } + for (const auto& sig: this->signalFunctions) { + accum += "\n " % sig.toString(); + } + return accum; } diff --git a/src/io/ipc.hpp b/src/io/ipc.hpp index d2b865a..32486d6 100644 --- a/src/io/ipc.hpp +++ b/src/io/ipc.hpp @@ -146,14 +146,31 @@ struct WirePropertyDefinition { DEFINE_SIMPLE_DATASTREAM_OPS(WirePropertyDefinition, data.name, data.type); -struct WireTargetDefinition { +struct WireSignalDefinition { QString name; - QVector functions; - QVector properties; + QString retname; + QString rettype; [[nodiscard]] QString toString() const; }; -DEFINE_SIMPLE_DATASTREAM_OPS(WireTargetDefinition, data.name, data.functions, data.properties); +DEFINE_SIMPLE_DATASTREAM_OPS(WireSignalDefinition, data.name, data.retname, data.rettype); + +struct WireTargetDefinition { + QString name; + QVector functions; + QVector properties; + QVector signalFunctions; + + [[nodiscard]] QString toString() const; +}; + +DEFINE_SIMPLE_DATASTREAM_OPS( + WireTargetDefinition, + data.name, + data.functions, + data.properties, + data.signalFunctions +); } // namespace qs::io::ipc diff --git a/src/io/ipccomm.cpp b/src/io/ipccomm.cpp index 7203a30..03b688a 100644 --- a/src/io/ipccomm.cpp +++ b/src/io/ipccomm.cpp @@ -1,10 +1,11 @@ #include "ipccomm.hpp" -#include +#include #include #include #include #include +#include #include #include @@ -19,10 +20,6 @@ using namespace qs::ipc; namespace qs::io::ipc::comm { -struct NoCurrentGeneration: std::monostate {}; -struct TargetNotFound: std::monostate {}; -struct EntryNotFound: std::monostate {}; - using QueryResponse = std::variant< std::monostate, NoCurrentGeneration, @@ -314,4 +311,106 @@ int getProperty(IpcClient* client, const QString& target, const QString& propert return -1; } +int listenToSignal(IpcClient* client, const QString& target, const QString& signal, bool once) { + if (target.isEmpty()) { + qCCritical(logBare) << "Target required to listen for signals."; + return -1; + } else if (signal.isEmpty()) { + qCCritical(logBare) << "Signal required to listen."; + return -1; + } + + client->sendMessage(IpcCommand(SignalListenCommand {.target = target, .signal = signal})); + + while (true) { + SignalListenResponse slot; + if (!client->waitForResponse(slot)) return -1; + + if (std::holds_alternative(slot)) { + auto& result = std::get(slot); + QTextStream(stdout) << result.response << Qt::endl; + if (once) return 0; + else continue; + } else if (std::holds_alternative(slot)) { + qCCritical(logBare) << "Target not found."; + } else if (std::holds_alternative(slot)) { + qCCritical(logBare) << "Signal not found."; + } else if (std::holds_alternative(slot)) { + qCCritical(logBare) << "Not ready to accept queries yet."; + } else { + qCCritical(logIpc) << "Received invalid IPC response from" << client; + } + break; + } + + return -1; +} + +void SignalListenCommand::exec(qs::ipc::IpcServerConnection* conn) { + auto resp = conn->responseStream(); + + if (auto* generation = EngineGeneration::currentGeneration()) { + auto* registry = IpcHandlerRegistry::forGeneration(generation); + + auto* handler = registry->findHandler(this->target); + if (!handler) { + resp << TargetNotFound(); + return; + } + + auto* signal = handler->findSignal(this->signal); + if (!signal) { + resp << EntryNotFound(); + return; + } + + new RemoteSignalListener(conn, *this); + } else { + conn->respond(SignalListenResponse(NoCurrentGeneration())); + } +} + +RemoteSignalListener::RemoteSignalListener( + qs::ipc::IpcServerConnection* conn, + SignalListenCommand command +) + : conn(conn) + , command(std::move(command)) { + conn->setParent(this); + + QObject::connect( + IpcSignalRemoteListener::instance(), + &IpcSignalRemoteListener::triggered, + this, + &RemoteSignalListener::onSignal + ); + + QObject::connect( + conn, + &qs::ipc::IpcServerConnection::destroyed, + this, + &RemoteSignalListener::onConnDestroyed + ); + + qCDebug(logIpc) << "Remote listener created for" << this->command.target << this->command.signal + << ":" << this; +} + +RemoteSignalListener::~RemoteSignalListener() { + qCDebug(logIpc) << "Destroying remote listener" << this; +} + +void RemoteSignalListener::onSignal( + const QString& target, + const QString& signal, + const QString& value +) { + if (target != this->command.target || signal != this->command.signal) return; + qCDebug(logIpc) << "Remote signal" << signal << "triggered on" << target << "with value" << value; + + this->conn->respond(SignalListenResponse(SignalResponse {.response = value})); +} + +void RemoteSignalListener::onConnDestroyed() { this->deleteLater(); } + } // namespace qs::io::ipc::comm diff --git a/src/io/ipccomm.hpp b/src/io/ipccomm.hpp index bc7dbf9..ac12979 100644 --- a/src/io/ipccomm.hpp +++ b/src/io/ipccomm.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include "../ipc/ipc.hpp" @@ -48,4 +50,52 @@ DEFINE_SIMPLE_DATASTREAM_OPS(StringPropReadCommand, data.target, data.property); int getProperty(qs::ipc::IpcClient* client, const QString& target, const QString& property); +struct SignalListenCommand { + QString target; + QString signal; + + void exec(qs::ipc::IpcServerConnection* conn); +}; + +DEFINE_SIMPLE_DATASTREAM_OPS(SignalListenCommand, data.target, data.signal); + +int listenToSignal( + qs::ipc::IpcClient* client, + const QString& target, + const QString& signal, + bool once +); + +struct NoCurrentGeneration: std::monostate {}; +struct TargetNotFound: std::monostate {}; +struct EntryNotFound: std::monostate {}; + +struct SignalResponse { + QString response; +}; + +DEFINE_SIMPLE_DATASTREAM_OPS(SignalResponse, data.response); + +using SignalListenResponse = std:: + variant; + +class RemoteSignalListener: public QObject { + Q_OBJECT; + +public: + explicit RemoteSignalListener(qs::ipc::IpcServerConnection* conn, SignalListenCommand command); + + ~RemoteSignalListener() override; + + Q_DISABLE_COPY_MOVE(RemoteSignalListener); + +private slots: + void onSignal(const QString& target, const QString& signal, const QString& value); + void onConnDestroyed(); + +private: + qs::ipc::IpcServerConnection* conn; + SignalListenCommand command; +}; + } // namespace qs::io::ipc::comm diff --git a/src/io/ipchandler.cpp b/src/io/ipchandler.cpp index 517e450..e80cf4b 100644 --- a/src/io/ipchandler.cpp +++ b/src/io/ipchandler.cpp @@ -1,9 +1,12 @@ #include "ipchandler.hpp" #include +#include +#include #include #include #include +#include #include #include #include @@ -14,10 +17,15 @@ #include #include "../core/generation.hpp" +#include "../core/logcat.hpp" #include "ipc.hpp" namespace qs::io::ipc { +namespace { +QS_LOGGING_CATEGORY(logIpcHandler, "quickshell.ipchandler", QtWarningMsg) +} + bool IpcFunction::resolve(QString& error) { if (this->method.parameterCount() > 10) { error = "Due to technical limitations, IPC functions can only have 10 arguments."; @@ -133,6 +141,75 @@ WirePropertyDefinition IpcProperty::wireDef() const { return wire; } +WireSignalDefinition IpcSignal::wireDef() const { + WireSignalDefinition wire; + wire.name = this->signal.name(); + if (this->targetSlot != IpcSignalListener::SLOT_VOID) { + wire.retname = this->signal.parameterNames().value(0); + if (this->targetSlot == IpcSignalListener::SLOT_STRING) wire.rettype = "string"; + else if (this->targetSlot == IpcSignalListener::SLOT_INT) wire.rettype = "int"; + else if (this->targetSlot == IpcSignalListener::SLOT_BOOL) wire.rettype = "bool"; + else if (this->targetSlot == IpcSignalListener::SLOT_REAL) wire.rettype = "real"; + else if (this->targetSlot == IpcSignalListener::SLOT_COLOR) wire.rettype = "color"; + } + return wire; +} + +// NOLINTBEGIN (cppcoreguidelines-interfaces-global-init) +// clang-format off +const int IpcSignalListener::SLOT_VOID = IpcSignalListener::staticMetaObject.indexOfSlot("invokeVoid()"); +const int IpcSignalListener::SLOT_STRING = IpcSignalListener::staticMetaObject.indexOfSlot("invokeString(QString)"); +const int IpcSignalListener::SLOT_INT = IpcSignalListener::staticMetaObject.indexOfSlot("invokeInt(int)"); +const int IpcSignalListener::SLOT_BOOL = IpcSignalListener::staticMetaObject.indexOfSlot("invokeBool(bool)"); +const int IpcSignalListener::SLOT_REAL = IpcSignalListener::staticMetaObject.indexOfSlot("invokeReal(double)"); +const int IpcSignalListener::SLOT_COLOR = IpcSignalListener::staticMetaObject.indexOfSlot("invokeColor(QColor)"); +// clang-format on +// NOLINTEND + +bool IpcSignal::resolve(QString& error) { + if (this->signal.parameterCount() > 1) { + error = "Due to technical limitations, IPC signals can have at most one argument."; + return false; + } + + auto slot = IpcSignalListener::SLOT_VOID; + + if (this->signal.parameterCount() == 1) { + auto paramType = this->signal.parameterType(0); + if (paramType == QMetaType::QString) slot = IpcSignalListener::SLOT_STRING; + else if (paramType == QMetaType::Int) slot = IpcSignalListener::SLOT_INT; + else if (paramType == QMetaType::Bool) slot = IpcSignalListener::SLOT_BOOL; + else if (paramType == QMetaType::Double) slot = IpcSignalListener::SLOT_REAL; + else if (paramType == QMetaType::QColor) slot = IpcSignalListener::SLOT_COLOR; + else { + error = QString("Type of argument (%2: %3) cannot be used across IPC.") + .arg(this->signal.parameterNames().value(0)) + .arg(QMetaType(paramType).name()); + + return false; + } + } + + this->targetSlot = slot; + return true; +} + +void IpcSignal::connectListener(IpcHandler* handler) { + if (this->targetSlot == -1) { + qFatal() << "Tried to connect unresolved IPC signal"; + } + + this->listener = std::make_shared(this->signal.name()); + QMetaObject::connect(handler, this->signal.methodIndex(), this->listener.get(), this->targetSlot); + + QObject::connect( + this->listener.get(), + &IpcSignalListener::triggered, + handler, + &IpcHandler::onSignalTriggered + ); +} + IpcCallStorage::IpcCallStorage(const IpcFunction& function): returnSlot(function.returnType) { for (const auto& arg: function.argumentTypes) { this->argumentSlots.emplace_back(arg); @@ -166,16 +243,28 @@ void IpcHandler::onPostReload() { // which should handle inheritance on the qml side. for (auto i = smeta.methodCount(); i != meta->methodCount(); i++) { const auto& method = meta->method(i); - if (method.methodType() != QMetaMethod::Slot) continue; + if (method.methodType() == QMetaMethod::Slot) { + auto ipcFunc = IpcFunction(method); + QString error; - auto ipcFunc = IpcFunction(method); - QString error; + if (!ipcFunc.resolve(error)) { + qmlWarning(this).nospace().noquote() + << "Error parsing function \"" << method.name() << "\": " << error; + } else { + this->functionMap.insert(method.name(), ipcFunc); + } + } else if (method.methodType() == QMetaMethod::Signal) { + qmlDebug(this) << "Signal detected: " << method.name(); + auto ipcSig = IpcSignal(method); + QString error; - if (!ipcFunc.resolve(error)) { - qmlWarning(this).nospace().noquote() - << "Error parsing function \"" << method.name() << "\": " << error; - } else { - this->functionMap.insert(method.name(), ipcFunc); + if (!ipcSig.resolve(error)) { + qmlWarning(this).nospace().noquote() + << "Error parsing signal \"" << method.name() << "\": " << error; + } else { + ipcSig.connectListener(this); + this->signalMap.emplace(method.name(), std::move(ipcSig)); + } } } @@ -210,11 +299,17 @@ IpcHandlerRegistry* IpcHandlerRegistry::forGeneration(EngineGeneration* generati if (!ext) { ext = new IpcHandlerRegistry(); generation->registerExtension(&key, ext); + qCDebug(logIpcHandler) << "Created new IPC handler registry" << ext << "for" << generation; } return dynamic_cast(ext); } +void IpcHandler::onSignalTriggered(const QString& signal, const QString& value) const { + emit IpcSignalRemoteListener::instance() + -> triggered(this->registeredState.target, signal, value); +} + void IpcHandler::updateRegistration(bool destroying) { if (!this->complete) return; @@ -232,10 +327,12 @@ void IpcHandler::updateRegistration(bool destroying) { if (this->registeredState.enabled) { registry->deregisterHandler(this); + qCDebug(logIpcHandler) << "Deregistered" << this << "from registry" << registry; } if (this->targetState.enabled && !this->targetState.target.isEmpty()) { registry->registerHandler(this); + qCDebug(logIpcHandler) << "Registered" << this << "to registry" << registry; } } @@ -315,6 +412,10 @@ WireTargetDefinition IpcHandler::wireDef() const { wire.properties += prop.wireDef(); } + for (const auto& sig: this->signalMap.values()) { + wire.signalFunctions += sig.wireDef(); + } + return wire; } @@ -359,6 +460,13 @@ IpcProperty* IpcHandler::findProperty(const QString& name) { else return &*itr; } +IpcSignal* IpcHandler::findSignal(const QString& name) { + auto itr = this->signalMap.find(name); + + if (itr == this->signalMap.end()) return nullptr; + else return &*itr; +} + IpcHandler* IpcHandlerRegistry::findHandler(const QString& target) { return this->handlers.value(target); } @@ -373,4 +481,9 @@ QVector IpcHandlerRegistry::wireTargets() const { return wire; } +IpcSignalRemoteListener* IpcSignalRemoteListener::instance() { + static auto* instance = new IpcSignalRemoteListener(); + return instance; +} + } // namespace qs::io::ipc diff --git a/src/io/ipchandler.hpp b/src/io/ipchandler.hpp index e6b24ba..eb274e3 100644 --- a/src/io/ipchandler.hpp +++ b/src/io/ipchandler.hpp @@ -1,8 +1,10 @@ #pragma once #include +#include #include +#include #include #include #include @@ -67,6 +69,54 @@ public: const IpcType* type = nullptr; }; +class IpcSignalListener: public QObject { + Q_OBJECT; + +public: + IpcSignalListener(QString signal): signal(std::move(signal)) {} + + static const int SLOT_VOID; + static const int SLOT_STRING; + static const int SLOT_INT; + static const int SLOT_BOOL; + static const int SLOT_REAL; + static const int SLOT_COLOR; + +signals: + void triggered(const QString& signal, const QString& value); + +private slots: + void invokeVoid() { this->triggered(this->signal, "void"); } + void invokeString(const QString& value) { this->triggered(this->signal, value); } + void invokeInt(int value) { this->triggered(this->signal, QString::number(value)); } + void invokeBool(bool value) { this->triggered(this->signal, value ? "true" : "false"); } + void invokeReal(double value) { this->triggered(this->signal, QString::number(value)); } + void invokeColor(QColor value) { this->triggered(this->signal, value.name(QColor::HexArgb)); } + +private: + QString signal; +}; + +class IpcHandler; + +class IpcSignal { +public: + explicit IpcSignal(QMetaMethod signal): signal(signal) {} + + bool resolve(QString& error); + + [[nodiscard]] WireSignalDefinition wireDef() const; + + QMetaMethod signal; + int targetSlot = -1; + + void connectListener(IpcHandler* handler); + +private: + void connectListener(QObject* handler, IpcSignalListener* listener) const; + std::shared_ptr listener; +}; + class IpcHandlerRegistry; ///! Handler for IPC message calls. @@ -100,6 +150,11 @@ class IpcHandlerRegistry; /// - `real` will be converted to a string and returned. /// - `color` will be converted to a hex string in the form `#AARRGGBB` and returned. /// +/// #### Signals +/// IPC handler signals can be observed remotely using `qs ipc wait` (one call) +/// and `qs ipc listen` (many calls). IPC signals may have zero or one argument, where +/// the argument is one of the types listed above, or no arguments for void. +/// /// #### Example /// The following example creates ipc functions to control and retrieve the appearance /// of a Rectangle. @@ -119,10 +174,18 @@ class IpcHandlerRegistry; /// /// function setColor(color: color): void { rect.color = color; } /// function getColor(): color { return rect.color; } +/// /// function setAngle(angle: real): void { rect.rotation = angle; } /// function getAngle(): real { return rect.rotation; } -/// function setRadius(radius: int): void { rect.radius = radius; } +/// +/// function setRadius(radius: int): void { +/// rect.radius = radius; +/// this.radiusChanged(radius); +/// } +/// /// function getRadius(): int { return rect.radius; } +/// +/// signal radiusChanged(newRadius: int); /// } /// } /// ``` @@ -136,6 +199,7 @@ class IpcHandlerRegistry; /// function getAngle(): real /// function setRadius(radius: int): void /// function getRadius(): int +/// signal radiusChanged(newRadius: int) /// ``` /// /// and then invoked using `qs ipc call`. @@ -154,9 +218,7 @@ class IpcHandlerRegistry; /// #### Properties /// Properties of an IpcHanlder can be read using `qs ipc prop get` as long as they are /// of an IPC compatible type. See the table above for compatible types. -class IpcHandler - : public QObject - , public PostReloadHook { +class IpcHandler: public PostReloadHook { Q_OBJECT; /// If the handler should be able to receive calls. Defaults to true. Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged); @@ -166,7 +228,7 @@ class IpcHandler QML_ELEMENT; public: - explicit IpcHandler(QObject* parent = nullptr): QObject(parent) {}; + explicit IpcHandler(QObject* parent = nullptr): PostReloadHook(parent) {} ~IpcHandler() override; Q_DISABLE_COPY_MOVE(IpcHandler); @@ -181,14 +243,15 @@ public: QString listMembers(qsizetype indent); [[nodiscard]] IpcFunction* findFunction(const QString& name); [[nodiscard]] IpcProperty* findProperty(const QString& name); + [[nodiscard]] IpcSignal* findSignal(const QString& name); [[nodiscard]] WireTargetDefinition wireDef() const; signals: void enabledChanged(); void targetChanged(); -private slots: - //void handleIpcPropertyChange(); +public slots: + void onSignalTriggered(const QString& signal, const QString& value) const; private: void updateRegistration(bool destroying = false); @@ -206,6 +269,7 @@ private: QHash functionMap; QHash propertyMap; + QHash signalMap; friend class IpcHandlerRegistry; }; @@ -229,4 +293,14 @@ private: QHash> knownHandlers; }; +class IpcSignalRemoteListener: public QObject { + Q_OBJECT; + +public: + static IpcSignalRemoteListener* instance(); + +signals: + void triggered(const QString& target, const QString& signal, const QString& value); +}; + } // namespace qs::io::ipc diff --git a/src/io/jsonadapter.cpp b/src/io/jsonadapter.cpp index 80ac091..369ccbe 100644 --- a/src/io/jsonadapter.cpp +++ b/src/io/jsonadapter.cpp @@ -1,11 +1,13 @@ #include "jsonadapter.hpp" +#include #include #include #include #include #include #include +#include #include #include #include @@ -14,6 +16,7 @@ #include #include #include +#include #include #include @@ -44,7 +47,7 @@ void JsonAdapter::deserializeAdapter(const QByteArray& data) { this->deserializeRec(json.object(), this, &JsonAdapter::staticMetaObject); - for (auto* object: oldCreatedObjects) { + for (auto* object: this->oldCreatedObjects) { delete object; // FIXME: QMetaType::destroy? } @@ -56,7 +59,7 @@ void JsonAdapter::deserializeAdapter(const QByteArray& data) { void JsonAdapter::connectNotifiers() { auto notifySlot = JsonAdapter::staticMetaObject.indexOfSlot("onPropertyChanged()"); - connectNotifiersRec(notifySlot, this, &JsonAdapter::staticMetaObject); + this->connectNotifiersRec(notifySlot, this, &JsonAdapter::staticMetaObject); } void JsonAdapter::connectNotifiersRec(int notifySlot, QObject* obj, const QMetaObject* base) { @@ -71,7 +74,7 @@ void JsonAdapter::connectNotifiersRec(int notifySlot, QObject* obj, const QMetaO auto val = prop.read(obj); if (val.canView()) { auto* pobj = prop.read(obj).view(); - if (pobj) connectNotifiersRec(notifySlot, pobj, &JsonObject::staticMetaObject); + if (pobj) this->connectNotifiersRec(notifySlot, pobj, &JsonObject::staticMetaObject); } else if (val.canConvert>()) { auto listVal = val.value>(); @@ -79,7 +82,7 @@ void JsonAdapter::connectNotifiersRec(int notifySlot, QObject* obj, const QMetaO for (auto i = 0; i != len; i++) { auto* pobj = listVal.at(&listVal, i); - if (pobj) connectNotifiersRec(notifySlot, pobj, &JsonObject::staticMetaObject); + if (pobj) this->connectNotifiersRec(notifySlot, pobj, &JsonObject::staticMetaObject); } } } @@ -111,7 +114,7 @@ QJsonObject JsonAdapter::serializeRec(const QObject* obj, const QMetaObject* bas auto* pobj = val.view(); if (pobj) { - json.insert(prop.name(), serializeRec(pobj, &JsonObject::staticMetaObject)); + json.insert(prop.name(), this->serializeRec(pobj, &JsonObject::staticMetaObject)); } else { json.insert(prop.name(), QJsonValue::Null); } @@ -124,20 +127,29 @@ QJsonObject JsonAdapter::serializeRec(const QObject* obj, const QMetaObject* bas auto* pobj = listVal.at(&listVal, i); if (pobj) { - array.push_back(serializeRec(pobj, &JsonObject::staticMetaObject)); + array.push_back(this->serializeRec(pobj, &JsonObject::staticMetaObject)); } else { array.push_back(QJsonValue::Null); } } json.insert(prop.name(), array); - } else if (val.canConvert()) { - auto variant = val.value().toVariant(); - auto jv = QJsonValue::fromVariant(variant); - json.insert(prop.name(), jv); } else { - auto jv = QJsonValue::fromVariant(val); - json.insert(prop.name(), jv); + if (val.canConvert()) val = val.value().toVariant(); + + auto jsonVal = QJsonValue::fromVariant(val); + + if (jsonVal.isNull() && !val.isNull() && val.isValid()) { + if (val.canConvert()) { + val.convert(QMetaType::fromType()); + } else if (val.canConvert()) { + val.convert(QMetaType::fromType()); + } + + jsonVal = QJsonValue::fromVariant(val); + } + + json.insert(prop.name(), jsonVal); } } } @@ -154,14 +166,16 @@ void JsonAdapter::deserializeRec(const QJsonObject& json, QObject* obj, const QM auto jval = json.value(prop.name()); if (prop.metaType() == QMetaType::fromType()) { - auto variant = jval.toVariant(); - auto oldValue = prop.read(this).value(); + auto newVariant = jval.toVariant(); + auto oldValue = prop.read(obj); + auto oldVariant = + oldValue.canConvert() ? oldValue.value().toVariant() : oldValue; // Calling prop.write with a new QJSValue will cause a property update // even if content is identical. - if (jval.toVariant() != oldValue.toVariant()) { - auto jsValue = qmlEngine(this)->fromVariant(jval.toVariant()); - prop.write(this, QVariant::fromValue(jsValue)); + if (newVariant != oldVariant) { + auto jsValue = qmlEngine(this)->fromVariant(newVariant); + prop.write(obj, QVariant::fromValue(jsValue)); } } else if (QMetaType::canView(prop.metaType(), QMetaType::fromType())) { // FIXME: This doesn't support creating descendants of JsonObject, as QMetaType.metaObject() @@ -178,8 +192,8 @@ void JsonAdapter::deserializeRec(const QJsonObject& json, QObject* obj, const QM currentValue->setParent(this); this->createdObjects.push_back(currentValue); - } else if (oldCreatedObjects.removeOne(currentValue)) { - createdObjects.push_back(currentValue); + } else if (this->oldCreatedObjects.removeOne(currentValue)) { + this->createdObjects.push_back(currentValue); } this->deserializeRec(jval.toObject(), currentValue, &JsonObject::staticMetaObject); @@ -196,7 +210,7 @@ void JsonAdapter::deserializeRec(const QJsonObject& json, QObject* obj, const QM QMetaType::fromType>() )) { - auto pval = prop.read(this); + auto pval = prop.read(obj); if (pval.canConvert>()) { auto lp = pval.value>(); @@ -212,8 +226,8 @@ void JsonAdapter::deserializeRec(const QJsonObject& json, QObject* obj, const QM if (jsonValue.isObject()) { if (isNew) { currentValue = lp.at(&lp, i); - if (oldCreatedObjects.removeOne(currentValue)) { - createdObjects.push_back(currentValue); + if (this->oldCreatedObjects.removeOne(currentValue)) { + this->createdObjects.push_back(currentValue); } } else { // FIXME: should be the type inside the QQmlListProperty but how can we get that? @@ -247,12 +261,35 @@ void JsonAdapter::deserializeRec(const QJsonObject& json, QObject* obj, const QM } } else { auto variant = jval.toVariant(); + auto convVariant = variant; - if (variant.convert(prop.metaType())) { - prop.write(obj, variant); + if (convVariant.convert(prop.metaType())) { + prop.write(obj, convVariant); } else { - qmlWarning(this) << "Failed to deserialize property " << prop.name() << ": expected " - << prop.metaType().name() << " but got " << jval.toVariant().typeName(); + auto pval = prop.read(obj); + if (variant.canConvert() && pval.canView()) { + auto targetv = QVariant(pval.metaType()); + auto target = targetv.view().metaContainer(); + auto valueType = target.valueMetaType(); + auto i = 0; + + for (QVariant item: variant.value()) { + if (item.convert(valueType)) { + target.addValueAtEnd(targetv.data(), item.constData()); + } else { + qmlWarning(this) << "Failed to deserialize list member " << i << " of property " + << prop.name() << ": expected " << valueType.name() << " but got " + << item.typeName(); + } + + ++i; + } + prop.write(obj, targetv); + } else { + qmlWarning(this) << "Failed to deserialize property " << prop.name() << ": expected " + << prop.metaType().name() << " but got " + << jval.toVariant().typeName(); + } } } } diff --git a/src/io/jsonadapter.hpp b/src/io/jsonadapter.hpp index a447c41..276d6a7 100644 --- a/src/io/jsonadapter.hpp +++ b/src/io/jsonadapter.hpp @@ -91,6 +91,7 @@ class JsonAdapter , public QQmlParserStatus { Q_OBJECT; QML_ELEMENT; + Q_INTERFACES(QQmlParserStatus); public: void classBegin() override {} diff --git a/src/io/process.cpp b/src/io/process.cpp index 143fdec..6055e2c 100644 --- a/src/io/process.cpp +++ b/src/io/process.cpp @@ -3,9 +3,9 @@ #include #include +#include #include #include -#include #include #include #include @@ -13,12 +13,13 @@ #include #include -#include "../core/common.hpp" #include "../core/generation.hpp" #include "../core/qmlglobal.hpp" +#include "../core/reload.hpp" #include "datastream.hpp" +#include "processcore.hpp" -Process::Process(QObject* parent): QObject(parent) { +Process::Process(QObject* parent): PostReloadHook(parent) { QObject::connect( QuickshellSettings::instance(), &QuickshellSettings::workingDirectoryChanged, @@ -27,6 +28,18 @@ Process::Process(QObject* parent): QObject(parent) { ); } +Process::~Process() { + if (this->process != nullptr && this->process->processId() != 0) { + // Deleting after the process finishes hides the process destroyed warning in logs + QObject::connect(this->process, &QProcess::finished, [p = this->process] { delete p; }); + + this->process->setParent(nullptr); + this->process->kill(); + } +} + +void Process::onPostReload() { this->startProcessIfReady(); } + bool Process::isRunning() const { return this->process != nullptr; } void Process::setRunning(bool running) { @@ -46,17 +59,7 @@ void Process::setCommand(QList command) { if (this->mCommand == command) return; this->mCommand = std::move(command); - auto& cmd = this->mCommand.first(); - if (cmd.startsWith("file://")) { - cmd = cmd.sliced(7); - } else if (cmd.startsWith("root://")) { - cmd = cmd.sliced(7); - auto& root = EngineGeneration::findObjectGeneration(this)->rootPath; - cmd = root.filePath(cmd.startsWith('/') ? cmd.sliced(1) : cmd); - } - emit this->commandChanged(); - this->startProcessIfReady(); } @@ -79,9 +82,9 @@ void Process::onGlobalWorkingDirectoryChanged() { } } -QMap Process::environment() const { return this->mEnvironment; } +QHash Process::environment() const { return this->mEnvironment; } -void Process::setEnvironment(QMap environment) { +void Process::setEnvironment(QHash environment) { if (environment == this->mEnvironment) return; this->mEnvironment = std::move(environment); emit this->environmentChanged(); @@ -175,10 +178,21 @@ void Process::setStdinEnabled(bool enabled) { } void Process::startProcessIfReady() { - if (this->process != nullptr || !this->targetRunning || this->mCommand.isEmpty()) return; + if (this->process != nullptr || !this->isPostReload || !this->targetRunning + || this->mCommand.isEmpty()) + return; + this->targetRunning = false; auto& cmd = this->mCommand.first(); + if (cmd.startsWith("file://")) { + cmd = cmd.sliced(7); + } else if (cmd.startsWith("root://")) { + cmd = cmd.sliced(7); + auto& root = EngineGeneration::findObjectGeneration(this)->rootPath; + cmd = root.filePath(cmd.startsWith('/') ? cmd.sliced(1) : cmd); + } + auto args = this->mCommand.sliced(1); this->process = new QProcess(this); @@ -202,6 +216,25 @@ void Process::startProcessIfReady() { this->process->start(cmd, args); } +void Process::exec(QList command) { + this->exec(qs::io::process::ProcessContext(std::move(command))); +} + +void Process::exec(const qs::io::process::ProcessContext& context) { + this->setRunning(false); + if (context.commandSet) this->setCommand(context.command); + if (context.environmentSet) this->setEnvironment(context.environment); + if (context.clearEnvironmentSet) this->setEnvironmentCleared(context.clearEnvironment); + if (context.workingDirectorySet) this->setWorkingDirectory(context.workingDirectory); + + if (this->mCommand.isEmpty()) { + qmlWarning(this) << "Cannot start process as command is empty."; + return; + } + + this->setRunning(true); +} + void Process::startDetached() { if (this->mCommand.isEmpty()) { qmlWarning(this) << "Cannot start process as command is empty."; @@ -216,6 +249,11 @@ void Process::startDetached() { this->setupEnvironment(&process); process.setProgram(cmd); process.setArguments(args); + + process.setStandardInputFile(QProcess::nullDevice()); + process.setStandardOutputFile(QProcess::nullDevice()); + process.setStandardErrorFile(QProcess::nullDevice()); + process.startDetached(); } @@ -224,24 +262,7 @@ void Process::setupEnvironment(QProcess* process) { process->setWorkingDirectory(this->mWorkingDirectory); } - const auto& sysenv = qs::Common::INITIAL_ENVIRONMENT; - auto env = this->mClearEnvironment ? QProcessEnvironment() : sysenv; - - for (auto& name: this->mEnvironment.keys()) { - auto value = this->mEnvironment.value(name); - if (!value.isValid()) continue; - - if (this->mClearEnvironment) { - if (value.isNull()) { - if (sysenv.contains(name)) env.insert(name, sysenv.value(name)); - } else env.insert(name, value.toString()); - } else { - if (value.isNull()) env.remove(name); - else env.insert(name, value.toString()); - } - } - - process->setProcessEnvironment(env); + qs::io::process::setupProcessEnvironment(process, this->mClearEnvironment, this->mEnvironment); } void Process::onStarted() { @@ -261,6 +282,8 @@ void Process::onFinished(qint32 exitCode, QProcess::ExitStatus exitStatus) { emit this->exited(exitCode, exitStatus); emit this->runningChanged(); emit this->processIdChanged(); + + this->startProcessIfReady(); // for `running = false; running = true` } void Process::onErrorOccurred(QProcess::ProcessError error) { diff --git a/src/io/process.hpp b/src/io/process.hpp index e93004f..3c55745 100644 --- a/src/io/process.hpp +++ b/src/io/process.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -9,7 +10,10 @@ #include #include +#include "../core/doc.hpp" +#include "../core/reload.hpp" #include "datastream.hpp" +#include "processcore.hpp" // Needed when compiling with clang musl-libc++. // Default include paths contain macros that cause name collisions. @@ -27,7 +31,7 @@ /// } /// } /// ``` -class Process: public QObject { +class Process: public PostReloadHook { Q_OBJECT; // clang-format off /// If the process is currently running. Defaults to false. @@ -98,7 +102,7 @@ class Process: public QObject { /// If the process is already running changing this property will affect the next /// started process. If the property has been changed after starting a process it will /// return the new value, not the one for the currently running process. - Q_PROPERTY(QMap environment READ environment WRITE setEnvironment NOTIFY environmentChanged); + Q_PROPERTY(QVariantHash environment READ environment WRITE setEnvironment NOTIFY environmentChanged); /// If the process's environment should be cleared prior to applying @@environment. /// Defaults to false. /// @@ -133,6 +137,44 @@ class Process: public QObject { public: explicit Process(QObject* parent = nullptr); + ~Process() override; + Q_DISABLE_COPY_MOVE(Process); + + void onPostReload() override; + + // MUST be before exec(ctx) or the other will be called with a default constructed obj. + QSDOC_HIDE Q_INVOKABLE void exec(QList command); + /// Launch a process with the given arguments, stopping any currently running process. + /// + /// The context parameter can either be a list of command arguments or a JS object with the following fields: + /// - `command`: A list containing the command and all its arguments. See @@Quickshell.Io.Process.command. + /// - `environment`: Changes to make to the process environment. See @@Quickshell.Io.Process.environment. + /// - `clearEnvironment`: Removes all variables from the environment if true. + /// - `workingDirectory`: The working directory the command should run in. + /// + /// Passed parameters will change the values currently set in the process. + /// + /// > [!WARNING] This does not run command in a shell. All arguments to the command + /// > must be in separate values in the list, e.g. `["echo", "hello"]` + /// > and not `["echo hello"]`. + /// > + /// > Additionally, shell scripts must be run by your shell, + /// > e.g. `["sh", "script.sh"]` instead of `["script.sh"]` unless the script + /// > has a shebang. + /// + /// > [!INFO] You can use `["sh", "-c", ]` to execute your command with + /// > the system shell. + /// + /// Calling this function is equivalent to running: + /// ```qml + /// process.running = false; + /// process.command = ... + /// process.environment = ... + /// process.clearEnvironment = ... + /// process.workingDirectory = ... + /// process.running = true; + /// ``` + Q_INVOKABLE void exec(const qs::io::process::ProcessContext& context); /// Sends a signal to the process if @@running is true, otherwise does nothing. Q_INVOKABLE void signal(qint32 signal); @@ -140,10 +182,12 @@ public: /// Writes to the process's stdin. Does nothing if @@running is false. Q_INVOKABLE void write(const QString& data); - /// Launches an instance of the process detached from quickshell. + /// Launches an instance of the process detached from Quickshell. /// /// The subprocess will not be tracked, @@running will be false, /// and the subprocess will not be killed by Quickshell. + /// + /// This function is equivalent to @@Quickshell.Quickshell.execDetached(). Q_INVOKABLE void startDetached(); [[nodiscard]] bool isRunning() const; @@ -157,8 +201,8 @@ public: [[nodiscard]] QString workingDirectory() const; void setWorkingDirectory(const QString& workingDirectory); - [[nodiscard]] QMap environment() const; - void setEnvironment(QMap environment); + [[nodiscard]] QHash environment() const; + void setEnvironment(QHash environment); [[nodiscard]] bool environmentCleared() const; void setEnvironmentCleared(bool cleared); @@ -203,7 +247,7 @@ private: QProcess* process = nullptr; QList mCommand; QString mWorkingDirectory; - QMap mEnvironment; + QHash mEnvironment; DataStreamParser* mStdoutParser = nullptr; DataStreamParser* mStderrParser = nullptr; QByteArray stdoutBuffer; diff --git a/src/io/processcore.cpp b/src/io/processcore.cpp new file mode 100644 index 0000000..8b5e80e --- /dev/null +++ b/src/io/processcore.cpp @@ -0,0 +1,37 @@ +#include "processcore.hpp" + +#include +#include +#include +#include + +#include "../core/common.hpp" + +namespace qs::io::process { + +void setupProcessEnvironment( + QProcess* process, + bool clear, + const QHash& envChanges +) { + const auto& sysenv = qs::Common::INITIAL_ENVIRONMENT; + auto env = clear ? QProcessEnvironment() : sysenv; + + for (auto& name: envChanges.keys()) { + auto value = envChanges.value(name); + if (!value.isValid()) continue; + + if (clear) { + if (value.isNull()) { + if (sysenv.contains(name)) env.insert(name, sysenv.value(name)); + } else env.insert(name, value.toString()); + } else { + if (value.isNull()) env.remove(name); + else env.insert(name, value.toString()); + } + } + + process->setProcessEnvironment(env); +} + +} // namespace qs::io::process diff --git a/src/io/processcore.hpp b/src/io/processcore.hpp new file mode 100644 index 0000000..8d566c9 --- /dev/null +++ b/src/io/processcore.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace qs::io::process { + +class ProcessContext { + Q_PROPERTY(QList command MEMBER command WRITE setCommand); + Q_PROPERTY(QVariantHash environment MEMBER environment WRITE setEnvironment); + Q_PROPERTY(bool clearEnvironment MEMBER clearEnvironment WRITE setClearEnvironment); + Q_PROPERTY(QString workingDirectory MEMBER workingDirectory WRITE setWorkingDirectory); + Q_PROPERTY(bool unbindStdout MEMBER unbindStdout WRITE setUnbindStdout); + Q_GADGET; + QML_STRUCTURED_VALUE; + QML_VALUE_TYPE(processContext); + +public: + ProcessContext() = default; + // Making this a Q_INVOKABLE does not work with QML_STRUCTURED_VALUe in Qt 6.9. + explicit ProcessContext(QList command): command(std::move(command)), commandSet(true) {} + + void setCommand(QList command) { + this->command = std::move(command); + this->commandSet = true; + } + + void setEnvironment(QHash environment) { + this->environment = std::move(environment); + this->environmentSet = true; + } + + void setClearEnvironment(bool clearEnvironment) { + this->clearEnvironment = clearEnvironment; + this->clearEnvironmentSet = true; + } + + void setWorkingDirectory(QString workingDirectory) { + this->workingDirectory = std::move(workingDirectory); + this->workingDirectorySet = true; + } + + void setUnbindStdout(bool unbindStdout) { this->unbindStdout = unbindStdout; } + + QList command; + QHash environment; + bool clearEnvironment = false; + QString workingDirectory; + + bool commandSet : 1 = false; + bool environmentSet : 1 = false; + bool clearEnvironmentSet : 1 = false; + bool workingDirectorySet : 1 = false; + bool unbindStdout : 1 = true; +}; + +void setupProcessEnvironment( + QProcess* process, + bool clear, + const QHash& envChanges +); + +} // namespace qs::io::process diff --git a/src/io/socket.cpp b/src/io/socket.cpp index a102c7b..371f687 100644 --- a/src/io/socket.cpp +++ b/src/io/socket.cpp @@ -11,9 +11,10 @@ #include #include +#include "../core/logcat.hpp" #include "datastream.hpp" -Q_LOGGING_CATEGORY(logSocket, "quickshell.io.socket", QtWarningMsg); +QS_LOGGING_CATEGORY(logSocket, "quickshell.io.socket", QtWarningMsg); void Socket::setSocket(QLocalSocket* socket) { if (this->socket != nullptr) this->socket->deleteLater(); @@ -107,7 +108,11 @@ void Socket::flush() { SocketServer::~SocketServer() { this->disableServer(); } -void SocketServer::onPostReload() { +void SocketServer::onReload(QObject* oldInstance) { + if (auto* old = qobject_cast(oldInstance)) { + old->disableServer(); + } + this->postReload = true; if (this->isActivatable()) this->enableServer(); } @@ -152,6 +157,8 @@ bool SocketServer::isActivatable() { void SocketServer::enableServer() { this->disableServer(); + qCDebug(logSocket) << "Enabling socket server" << this << "at" << this->mPath; + this->server = new QLocalServer(this); QObject::connect( this->server, @@ -160,31 +167,38 @@ void SocketServer::enableServer() { &SocketServer::onNewConnection ); + if (QFile::remove(this->mPath)) { + qCWarning(logSocket) << "Deleted existing file at" << this->mPath << "to create socket"; + } + if (!this->server->listen(this->mPath)) { - qWarning() << "could not start socket server at" << this->mPath; + qCWarning(logSocket) << "Could not start socket server at" << this->mPath; this->disableServer(); } this->activeTarget = false; + this->activePath = this->mPath; emit this->activeStatusChanged(); } void SocketServer::disableServer() { auto wasActive = this->server != nullptr; - if (this->server != nullptr) { + if (wasActive) { + qCDebug(logSocket) << "Disabling socket server" << this << "at" << this->activePath; for (auto* socket: this->mSockets) { socket->deleteLater(); } this->mSockets.clear(); + this->server->close(); this->server->deleteLater(); this->server = nullptr; - } - if (this->mPath != nullptr) { - if (QFile::exists(this->mPath) && !QFile::remove(this->mPath)) { - qWarning() << "failed to delete socket file at" << this->mPath; + if (!this->activePath.isEmpty()) { + if (QFile::exists(this->activePath) && !QFile::remove(this->activePath)) { + qWarning() << "Failed to delete socket file at" << this->activePath; + } } } diff --git a/src/io/socket.hpp b/src/io/socket.hpp index c710dbd..3fd230e 100644 --- a/src/io/socket.hpp +++ b/src/io/socket.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,10 +10,11 @@ #include #include +#include "../core/logcat.hpp" #include "../core/reload.hpp" #include "datastream.hpp" -Q_DECLARE_LOGGING_CATEGORY(logSocket); +QS_DECLARE_LOGGING_CATEGORY(logSocket); ///! Unix socket listener. class Socket: public DataStream { @@ -90,9 +92,7 @@ private: /// } /// } /// ``` -class SocketServer - : public QObject - , public PostReloadHook { +class SocketServer: public Reloadable { Q_OBJECT; /// If the socket server is currently active. Defaults to false. /// @@ -115,11 +115,11 @@ class SocketServer QML_ELEMENT; public: - explicit SocketServer(QObject* parent = nullptr): QObject(parent) {} + explicit SocketServer(QObject* parent = nullptr): Reloadable(parent) {} ~SocketServer() override; Q_DISABLE_COPY_MOVE(SocketServer); - void onPostReload() override; + void onReload(QObject* oldInstance) override; [[nodiscard]] bool isActive() const; void setActive(bool active); @@ -149,4 +149,5 @@ private: bool activeTarget = false; bool postReload = false; QString mPath; + QString activePath; }; diff --git a/src/io/test/CMakeLists.txt b/src/io/test/CMakeLists.txt index 4ab5173..8b3da6a 100644 --- a/src/io/test/CMakeLists.txt +++ b/src/io/test/CMakeLists.txt @@ -1,7 +1,8 @@ function (qs_test name) add_executable(${name} ${ARGN}) - target_link_libraries(${name} PRIVATE Qt::Quick Qt::Network Qt::Test) + target_link_libraries(${name} PRIVATE Qt::Quick Qt::Network Qt::Test quickshell-io quickshell-core quickshell-window quickshell-ui) add_test(NAME ${name} WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" COMMAND $) endfunction() qs_test(datastream datastream.cpp ../datastream.cpp) +qs_test(process process.cpp ../process.cpp ../datastream.cpp ../processcore.cpp) diff --git a/src/io/test/process.cpp b/src/io/test/process.cpp new file mode 100644 index 0000000..09fc9f7 --- /dev/null +++ b/src/io/test/process.cpp @@ -0,0 +1,47 @@ +#include "process.hpp" + +#include +#include +#include +#include + +#include "../process.hpp" + +void TestProcess::startAfterReload() { + auto process = Process(); + auto startedSpy = QSignalSpy(&process, &Process::started); + auto exitedSpy = QSignalSpy(&process, &Process::exited); + + process.setCommand({"true"}); + process.setRunning(true); + + QVERIFY(!process.isRunning()); + QCOMPARE(startedSpy.count(), 0); + + process.postReload(); + + QVERIFY(process.isRunning()); + QVERIFY(startedSpy.wait(100)); +} + +void TestProcess::testExec() { + auto process = Process(); + auto startedSpy = QSignalSpy(&process, &Process::started); + auto exitedSpy = QSignalSpy(&process, &Process::exited); + + process.postReload(); + + process.setCommand({"sleep", "30"}); + process.setRunning(true); + + QVERIFY(process.isRunning()); + QVERIFY(startedSpy.wait(100)); + + process.exec({"true"}); + + QVERIFY(exitedSpy.wait(100)); + QVERIFY(startedSpy.wait(100)); + QVERIFY(process.isRunning()); +} + +QTEST_MAIN(TestProcess); diff --git a/src/io/test/process.hpp b/src/io/test/process.hpp new file mode 100644 index 0000000..3525be2 --- /dev/null +++ b/src/io/test/process.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +class TestProcess: public QObject { + Q_OBJECT; + +private slots: + static void startAfterReload(); + static void testExec(); +}; diff --git a/src/ipc/ipc.cpp b/src/ipc/ipc.cpp index 3580e2b..4bfea4c 100644 --- a/src/ipc/ipc.cpp +++ b/src/ipc/ipc.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -10,12 +11,13 @@ #include #include "../core/generation.hpp" +#include "../core/logcat.hpp" #include "../core/paths.hpp" #include "ipccommand.hpp" namespace qs::ipc { -Q_LOGGING_CATEGORY(logIpc, "quickshell.ipc", QtWarningMsg); +QS_LOGGING_CATEGORY(logIpc, "quickshell.ipc", QtWarningMsg); IpcServer::IpcServer(const QString& path) { QObject::connect(&this->server, &QLocalServer::newConnection, this, &IpcServer::onNewConnection); @@ -35,7 +37,8 @@ void IpcServer::start() { auto path = run->filePath("ipc.sock"); new IpcServer(path); } else { - qCCritical(logIpc + qCCritical( + logIpc ) << "Could not start IPC server as the instance runtime path could not be created."; } } @@ -59,6 +62,7 @@ IpcServerConnection::IpcServerConnection(QLocalSocket* socket, IpcServer* server void IpcServerConnection::onDisconnected() { qCInfo(logIpc) << "IPC connection disconnected" << this; + this->deleteLater(); } void IpcServerConnection::onReadyRead() { @@ -82,6 +86,11 @@ void IpcServerConnection::onReadyRead() { ); if (!this->stream.commitTransaction()) return; + + // async connections reparent + if (dynamic_cast(this->parent()) != nullptr) { + this->deleteLater(); + } } IpcClient::IpcClient(const QString& path) { @@ -119,7 +128,9 @@ int IpcClient::connect(const QString& id, const std::functionquit(); + auto* generation = EngineGeneration::currentGeneration(); + if (generation) generation->quit(); + else QCoreApplication::exit(0); } } // namespace qs::ipc diff --git a/src/ipc/ipc.hpp b/src/ipc/ipc.hpp index 77bff91..8ad4c42 100644 --- a/src/ipc/ipc.hpp +++ b/src/ipc/ipc.hpp @@ -15,6 +15,8 @@ #include #include +#include "../core/logcat.hpp" + template constexpr void assertSerializable() { // monostate being zero ensures transactional reads wont break @@ -109,7 +111,7 @@ DEFINE_SIMPLE_DATASTREAM_OPS(std::monostate); namespace qs::ipc { -Q_DECLARE_LOGGING_CATEGORY(logIpc); +QS_DECLARE_LOGGING_CATEGORY(logIpc); template class MessageStream { diff --git a/src/ipc/ipccommand.hpp b/src/ipc/ipccommand.hpp index b221b46..105ce1e 100644 --- a/src/ipc/ipccommand.hpp +++ b/src/ipc/ipccommand.hpp @@ -16,6 +16,7 @@ using IpcCommand = std::variant< IpcKillCommand, qs::io::ipc::comm::QueryMetadataCommand, qs::io::ipc::comm::StringCallCommand, + qs::io::ipc::comm::SignalListenCommand, qs::io::ipc::comm::StringPropReadCommand>; } // namespace qs::ipc diff --git a/src/launch/command.cpp b/src/launch/command.cpp index 1704d9d..807eb24 100644 --- a/src/launch/command.cpp +++ b/src/launch/command.cpp @@ -1,8 +1,8 @@ #include #include #include -#include #include +#include #include #include @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -24,12 +25,12 @@ #include #include +#include "../core/debuginfo.hpp" #include "../core/instanceinfo.hpp" #include "../core/logging.hpp" #include "../core/paths.hpp" #include "../io/ipccomm.hpp" #include "../ipc/ipc.hpp" -#include "build.hpp" #include "launch_p.hpp" namespace qs::launch { @@ -88,9 +89,9 @@ int locateConfigFile(CommandState& cmd, QString& path) { } if (!manifestPath.isEmpty()) { - qWarning( - ) << "Config manifests (manifest.conf) are deprecated and will be removed in a future " - "release."; + qWarning() + << "Config manifests (manifest.conf) are deprecated and will be removed in a future " + "release."; qWarning() << "Consider using symlinks to a subfolder of quickshell's XDG config dirs."; auto file = QFile(manifestPath); @@ -108,7 +109,7 @@ int locateConfigFile(CommandState& cmd, QString& path) { } if (split[0].trimmed() == *cmd.config.name) { - path = QDir(QFileInfo(file).canonicalPath()).filePath(split[1].trimmed()); + path = QDir(QFileInfo(file).absolutePath()).filePath(split[1].trimmed()); break; } } @@ -128,7 +129,8 @@ int locateConfigFile(CommandState& cmd, QString& path) { if (path.isEmpty()) { if (name == "default") { - qCCritical(logBare + qCCritical( + logBare ) << "Could not find \"default\" config directory or shell.qml in any valid config path."; } else { qCCritical(logBare) << "Could not find" << name @@ -138,8 +140,7 @@ int locateConfigFile(CommandState& cmd, QString& path) { return -1; } - path = QFileInfo(path).canonicalFilePath(); - return 0; + goto rpath; } } @@ -152,7 +153,8 @@ int locateConfigFile(CommandState& cmd, QString& path) { return -1; } - path = QFileInfo(path).canonicalFilePath(); +rpath: + path = QFileInfo(path).absoluteFilePath(); return 0; } @@ -177,14 +179,34 @@ int selectInstance(CommandState& cmd, InstanceLockInfo* instance, bool deadFallb } } else if (!cmd.instance.id->isEmpty()) { path = basePath->filePath("by-pid"); - auto instances = QsPaths::collectInstances(path, deadFallback); + auto [liveInstances, deadInstances] = + QsPaths::collectInstances(path, cmd.config.anyDisplay ? "" : getDisplayConnection()); - instances.removeIf([&](const InstanceLockInfo& info) { + liveInstances.removeIf([&](const InstanceLockInfo& info) { return !info.instance.instanceId.startsWith(*cmd.instance.id); }); + deadInstances.removeIf([&](const InstanceLockInfo& info) { + return !info.instance.instanceId.startsWith(*cmd.instance.id); + }); + + auto instances = liveInstances.isEmpty() && deadFallback ? deadInstances : liveInstances; + if (instances.isEmpty()) { - qCInfo(logBare) << "No running instances start with" << *cmd.instance.id; + if (deadFallback) { + qCInfo(logBare) << "No instances start with" << *cmd.instance.id; + } else { + qCInfo(logBare) << "No running instances start with" << *cmd.instance.id; + + if (!deadInstances.isEmpty()) { + qCInfo(logBare) << "Some dead instances match:"; + + for (auto& instance: deadInstances) { + qCInfo(logBare).noquote() << " -" << instance.instance.instanceId; + } + } + } + return -1; } else if (instances.length() != 1) { qCInfo(logBare) << "More than one instance starts with" << *cmd.instance.id; @@ -208,14 +230,30 @@ int selectInstance(CommandState& cmd, InstanceLockInfo* instance, bool deadFallb path = QDir(basePath->filePath("by-path")).filePath(pathId); - auto instances = QsPaths::collectInstances(path, deadFallback); + auto [liveInstances, deadInstances] = + QsPaths::collectInstances(path, cmd.config.anyDisplay ? "" : getDisplayConnection()); + + auto instances = liveInstances; + if (instances.isEmpty() && deadFallback) { + instances = deadInstances; + } + sortInstances( instances, cmd.config.newest || (!instances.empty() && instances.first().pid == -1) ); if (instances.isEmpty()) { - qCInfo(logBare) << "No running instances for" << configFilePath; + if (liveInstances.isEmpty() && deadInstances.length() > 1) { + qCInfo(logBare) << "No running instances for" << configFilePath; + qCInfo(logBare) << "Dead instances:"; + sortInstances(deadInstances, cmd.config.newest); + for (auto& instance: deadInstances) { + qCInfo(logBare).noquote() << " -" << instance.instance.instanceId; + } + } else { + qCInfo(logBare) << "No running instances for" << configFilePath; + } return -1; } @@ -276,7 +314,21 @@ int listInstances(CommandState& cmd) { path = QDir(basePath->filePath("by-path")).filePath(pathId); } - auto instances = QsPaths::collectInstances(path); + auto [liveInstances, deadInstances] = QsPaths::collectInstances( + path, + cmd.config.anyDisplay || cmd.instance.all ? "" : getDisplayConnection() + ); + + sortInstances(liveInstances, cmd.config.newest); + + QList instances; + if (cmd.instance.includeDead) { + sortInstances(deadInstances, cmd.config.newest); + instances = std::move(deadInstances); + instances.append(liveInstances); + } else { + instances = std::move(liveInstances); + } if (instances.isEmpty()) { if (cmd.instance.all) { @@ -286,7 +338,6 @@ int listInstances(CommandState& cmd) { qCInfo(logBare) << "Use --all to list all instances."; } } else { - sortInstances(instances, cmd.config.newest); if (cmd.output.json) { auto array = QJsonArray(); @@ -295,7 +346,7 @@ int listInstances(CommandState& cmd) { auto json = QJsonObject(); json["id"] = instance.instance.instanceId; - json["pid"] = instance.pid; + json["pid"] = instance.instance.pid; json["shell_id"] = instance.instance.shellId; json["config_path"] = instance.instance.configPath; json["launch_time"] = instance.instance.launchTime.toString(Qt::ISODate); @@ -319,12 +370,19 @@ int listInstances(CommandState& cmd) { .arg(remMinutes) .arg(remSeconds); + auto isDead = instance.pid == -1; + auto gray = !cmd.log.noColor && isDead; + qCInfo(logBare).noquote().nospace() - << "Instance " << instance.instance.instanceId << ":\n" - << " Process ID: " << instance.pid << '\n' + << (gray ? "\033[90m" : "") << "Instance " << instance.instance.instanceId + << (isDead ? " (dead)" : "") << ":\n" + << " Process ID: " << instance.instance.pid << '\n' << " Shell ID: " << instance.instance.shellId << '\n' << " Config path: " << instance.instance.configPath << '\n' - << " Launch time: " << launchTimeStr << " (running for " << runtimeStr << ")\n"; + << " Display connection: " << instance.instance.display << '\n' + << " Launch time: " << launchTimeStr + << (isDead ? "" : " (running for " + runtimeStr + ")") << '\n' + << (gray ? "\033[0m" : ""); } } } @@ -353,6 +411,10 @@ int ipcCommand(CommandState& cmd) { return qs::io::ipc::comm::queryMetadata(&client, *cmd.ipc.target, *cmd.ipc.name); } else if (*cmd.ipc.getprop) { return qs::io::ipc::comm::getProperty(&client, *cmd.ipc.target, *cmd.ipc.name); + } else if (*cmd.ipc.wait) { + return qs::io::ipc::comm::listenToSignal(&client, *cmd.ipc.target, *cmd.ipc.name, true); + } else if (*cmd.ipc.listen) { + return qs::io::ipc::comm::listenToSignal(&client, *cmd.ipc.target, *cmd.ipc.name, false); } else { QVector arguments; for (auto& arg: cmd.ipc.arguments) { @@ -402,7 +464,7 @@ int runCommand(int argc, char** argv, QCoreApplication* coreApplication) { QTextStream(stdout) << "\033[31mCOMPATIBILITY WARNING: Quickshell was built against Qt " << QT_VERSION_STR << " but the system has updated to Qt " << qVersion() << " without rebuilding the package. This is likely to cause crashes, so " - "you must rebuild the quickshell package.\n"; + "you must rebuild the quickshell package.\n\033[0m"; return 1; } @@ -457,20 +519,10 @@ int runCommand(int argc, char** argv, QCoreApplication* coreApplication) { } if (state.misc.printVersion) { - qCInfo(logBare).noquote().nospace() - << "quickshell 0.1.0, revision " << GIT_REVISION << ", distributed by: " << DISTRIBUTOR; - - if (state.log.verbosity > 1) { - qCInfo(logBare).noquote() << "\nBuildtime Qt Version:" << QT_VERSION_STR; - qCInfo(logBare).noquote() << "Runtime Qt Version:" << qVersion(); - qCInfo(logBare).noquote() << "Compiler:" << COMPILER; - qCInfo(logBare).noquote() << "Compile Flags:" << COMPILE_FLAGS; - } - - if (state.log.verbosity > 0) { - qCInfo(logBare).noquote() << "\nBuild Type:" << BUILD_TYPE; - qCInfo(logBare).noquote() << "Build configuration:"; - qCInfo(logBare).noquote().nospace() << BUILD_CONFIGURATION; + if (state.log.verbosity == 0) { + qCInfo(logBare).noquote() << "Quickshell" << qs::debuginfo::qsVersion(); + } else { + qCInfo(logBare).noquote() << qs::debuginfo::combinedInfo(); } } else if (*state.subcommand.log) { return readLogFile(state); @@ -494,4 +546,18 @@ int runCommand(int argc, char** argv, QCoreApplication* coreApplication) { return 0; } +QString getDisplayConnection() { + auto platform = qEnvironmentVariable("QT_QPA_PLATFORM"); + auto wlDisplay = qEnvironmentVariable("WAYLAND_DISPLAY"); + auto xDisplay = qEnvironmentVariable("DISPLAY"); + + if (platform == "wayland" || (platform.isEmpty() && !wlDisplay.isEmpty())) { + return "wayland," + wlDisplay; + } else if (platform == "xcb" || (platform.isEmpty() && !xDisplay.isEmpty())) { + return "x11," + xDisplay; + } else { + return "unk," + QGuiApplication::platformName(); + } +} + } // namespace qs::launch diff --git a/src/launch/launch.cpp b/src/launch/launch.cpp index fe28a94..dcdefa7 100644 --- a/src/launch/launch.cpp +++ b/src/launch/launch.cpp @@ -27,7 +27,7 @@ #include "build.hpp" #include "launch_p.hpp" -#if CRASH_REPORTER +#if CRASH_HANDLER #include "../crash/handler.hpp" #endif @@ -73,10 +73,14 @@ int launch(const LaunchArgs& args, char** argv, QCoreApplication* coreApplicatio bool useQApplication = false; bool nativeTextRendering = false; bool desktopSettingsAware = true; + bool useSystemStyle = false; QString iconTheme = qEnvironmentVariable("QS_ICON_THEME"); QHash envOverrides; + QString appId = qEnvironmentVariable("QS_APP_ID"); + bool dropExpensiveFonts = false; QString dataDir; QString stateDir; + QString cacheDir; } pragmas; auto stream = QTextStream(&file); @@ -88,6 +92,8 @@ int launch(const LaunchArgs& args, char** argv, QCoreApplication* coreApplicatio if (pragma == "UseQApplication") pragmas.useQApplication = true; else if (pragma == "NativeTextRendering") pragmas.nativeTextRendering = true; else if (pragma == "IgnoreSystemSettings") pragmas.desktopSettingsAware = false; + else if (pragma == "RespectSystemStyle") pragmas.useSystemStyle = true; + else if (pragma == "DropExpensiveFonts") pragmas.dropExpensiveFonts = true; else if (pragma.startsWith("IconTheme ")) pragmas.iconTheme = pragma.sliced(10); else if (pragma.startsWith("Env ")) { auto envPragma = pragma.sliced(4); @@ -101,15 +107,18 @@ int launch(const LaunchArgs& args, char** argv, QCoreApplication* coreApplicatio auto var = envPragma.sliced(0, splitIdx).trimmed(); auto val = envPragma.sliced(splitIdx + 1).trimmed(); pragmas.envOverrides.insert(var, val); + } else if (pragma.startsWith("AppId ")) { + pragmas.appId = pragma.sliced(6).trimmed(); } else if (pragma.startsWith("ShellId ")) { shellId = pragma.sliced(8).trimmed(); } else if (pragma.startsWith("DataDir ")) { pragmas.dataDir = pragma.sliced(8).trimmed(); } else if (pragma.startsWith("StateDir ")) { pragmas.stateDir = pragma.sliced(9).trimmed(); + } else if (pragma.startsWith("CacheDir ")) { + pragmas.cacheDir = pragma.sliced(9).trimmed(); } else { - qCritical() << "Unrecognized pragma" << pragma; - return -1; + qWarning() << "Unrecognized pragma" << pragma; } } else if (line.startsWith("import")) break; } @@ -123,20 +132,26 @@ int launch(const LaunchArgs& args, char** argv, QCoreApplication* coreApplicatio qInfo() << "Shell ID:" << shellId << "Path ID" << pathId; auto launchTime = qs::Common::LAUNCH_TIME.toSecsSinceEpoch(); + auto appId = pragmas.appId.isEmpty() ? QStringLiteral("org.quickshell") : pragmas.appId; + InstanceInfo::CURRENT = InstanceInfo { .instanceId = base36Encode(getpid()) + base36Encode(launchTime), .configPath = args.configPath, .shellId = shellId, + .appId = appId, .launchTime = qs::Common::LAUNCH_TIME, + .pid = getpid(), + .display = getDisplayConnection(), }; -#if CRASH_REPORTER - auto crashHandler = crash::CrashHandler(); - crashHandler.init(); +#if CRASH_HANDLER + if (qEnvironmentVariableIsSet("QS_DISABLE_CRASH_HANDLER")) { + qInfo() << "Crash handling disabled."; + } else { + crash::CrashHandler::init(); - { auto* log = LogManager::instance(); - crashHandler.setRelaunchInfo({ + crash::CrashHandler::setRelaunchInfo({ .instance = InstanceInfo::CURRENT, .noColor = !log->colorLogs, .timestamp = log->timestampLogs, @@ -147,17 +162,64 @@ int launch(const LaunchArgs& args, char** argv, QCoreApplication* coreApplicatio } #endif - QsPaths::init(shellId, pathId, pragmas.dataDir, pragmas.stateDir); + QsPaths::init(shellId, pathId, pragmas.dataDir, pragmas.stateDir, pragmas.cacheDir); QsPaths::instance()->linkRunDir(); QsPaths::instance()->linkPathDir(); LogManager::initFs(); Common::INITIAL_ENVIRONMENT = QProcessEnvironment::systemEnvironment(); + if (!pragmas.useSystemStyle) { + qunsetenv("QT_STYLE_OVERRIDE"); + qputenv("QT_QUICK_CONTROLS_STYLE", "Fusion"); + } + for (auto [var, val]: pragmas.envOverrides.asKeyValueRange()) { qputenv(var.toUtf8(), val.toUtf8()); } + pragmas.dropExpensiveFonts |= qEnvironmentVariableIntValue("QS_DROP_EXPENSIVE_FONTS") == 1; + + if (pragmas.dropExpensiveFonts) { + if (auto* runDir = QsPaths::instance()->instanceRunDir()) { + auto baseConfigPath = qEnvironmentVariable("FONTCONFIG_FILE"); + if (baseConfigPath.isEmpty()) baseConfigPath = "/etc/fonts/fonts.conf"; + + auto filterPath = runDir->filePath("fonts-override.conf"); + auto filterFile = QFile(filterPath); + if (filterFile.open(QFile::WriteOnly | QFile::Truncate | QFile::Text)) { + auto filterTemplate = QStringLiteral(R"( + + + %1 + + + + + woff + + + + + woff2 + + + + + +)"); + + QTextStream(&filterFile) << filterTemplate.arg(baseConfigPath); + filterFile.close(); + qputenv("FONTCONFIG_FILE", filterPath.toUtf8()); + } else { + qCritical() << "Could not write fontconfig filter to" << filterPath; + } + } else { + qCritical() << "Could not create fontconfig filter: instance run directory unavailable"; + } + } + // The qml engine currently refuses to cache non file (qsintercept) paths. // if (auto* cacheDir = QsPaths::instance()->cacheDir()) { @@ -218,6 +280,8 @@ int launch(const LaunchArgs& args, char** argv, QCoreApplication* coreApplicatio app = new QGuiApplication(qArgC, argv); } + QGuiApplication::setDesktopFileName(appId); + if (args.debugPort != -1) { QQmlDebuggingEnabler::enableDebugging(true); auto wait = args.waitForDebug ? QQmlDebuggingEnabler::WaitForClient diff --git a/src/launch/launch_p.hpp b/src/launch/launch_p.hpp index 7780845..f666e7a 100644 --- a/src/launch/launch_p.hpp +++ b/src/launch/launch_p.hpp @@ -50,6 +50,7 @@ struct CommandState { QStringOption manifest; QStringOption name; bool newest = false; + bool anyDisplay = false; } config; struct { @@ -61,6 +62,7 @@ struct CommandState { QStringOption id; pid_t pid = -1; // NOLINT (include) bool all = false; + bool includeDead = false; } instance; struct { @@ -72,6 +74,8 @@ struct CommandState { CLI::App* show = nullptr; CLI::App* call = nullptr; CLI::App* getprop = nullptr; + CLI::App* wait = nullptr; + CLI::App* listen = nullptr; bool showOld = false; QStringOption target; QStringOption name; @@ -105,6 +109,8 @@ void exitDaemon(int code); int parseCommand(int argc, char** argv, CommandState& state); int runCommand(int argc, char** argv, QCoreApplication* coreApplication); +QString getDisplayConnection(); + int launch(const LaunchArgs& args, char** argv, QCoreApplication* coreApplication); } // namespace qs::launch diff --git a/src/launch/main.cpp b/src/launch/main.cpp index 2bcbebd..efd6628 100644 --- a/src/launch/main.cpp +++ b/src/launch/main.cpp @@ -16,7 +16,7 @@ #include "build.hpp" #include "launch_p.hpp" -#if CRASH_REPORTER +#if CRASH_HANDLER #include "../crash/main.hpp" #endif @@ -25,14 +25,17 @@ namespace qs::launch { namespace { void checkCrashRelaunch(char** argv, QCoreApplication* coreApplication) { -#if CRASH_REPORTER +#if CRASH_HANDLER auto lastInfoFdStr = qEnvironmentVariable("__QUICKSHELL_CRASH_INFO_FD"); if (!lastInfoFdStr.isEmpty()) { auto lastInfoFd = lastInfoFdStr.toInt(); QFile file; - file.open(lastInfoFd, QFile::ReadOnly, QFile::AutoCloseHandle); + if (!file.open(lastInfoFd, QFile::ReadOnly, QFile::AutoCloseHandle)) { + qFatal() << "Failed to open crash info fd. Cannot restart."; + } + file.seek(0); auto ds = QDataStream(&file); @@ -81,27 +84,35 @@ void exitDaemon(int code) { close(DAEMON_PIPE); - close(STDIN_FILENO); - close(STDOUT_FILENO); - close(STDERR_FILENO); - - if (open("/dev/null", O_RDONLY) != STDIN_FILENO) { // NOLINT - qFatal() << "Failed to open /dev/null on stdin"; + auto fd = open("/dev/null", O_RDWR); + if (fd == -1) { + qCritical().nospace() << "Failed to open /dev/null for daemon stdio" << errno << ": " + << qt_error_string(); + return; } - if (open("/dev/null", O_WRONLY) != STDOUT_FILENO) { // NOLINT - qFatal() << "Failed to open /dev/null on stdout"; + if (dup2(fd, STDIN_FILENO) != STDIN_FILENO) { // NOLINT + qCritical().nospace() << "Failed to set daemon stdin to /dev/null" << errno << ": " + << qt_error_string(); } - if (open("/dev/null", O_WRONLY) != STDERR_FILENO) { // NOLINT - qFatal() << "Failed to open /dev/null on stderr"; + if (dup2(fd, STDOUT_FILENO) != STDOUT_FILENO) { // NOLINT + qCritical().nospace() << "Failed to set daemon stdout to /dev/null" << errno << ": " + << qt_error_string(); } + + if (dup2(fd, STDERR_FILENO) != STDERR_FILENO) { // NOLINT + qCritical().nospace() << "Failed to set daemon stderr to /dev/null" << errno << ": " + << qt_error_string(); + } + + close(fd); } int main(int argc, char** argv) { QCoreApplication::setApplicationName("quickshell"); -#if CRASH_REPORTER +#if CRASH_HANDLER qsCheckCrash(argc, argv); #endif diff --git a/src/launch/parsecommand.cpp b/src/launch/parsecommand.cpp index e49ded7..fc43b6b 100644 --- a/src/launch/parsecommand.cpp +++ b/src/launch/parsecommand.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include // NOLINT: Need to include this for impls of some CLI11 classes @@ -16,7 +17,7 @@ int parseCommand(int argc, char** argv, CommandState& state) { .argv = argv, }; - auto addConfigSelection = [&](CLI::App* cmd, bool withNewestOption = false) { + auto addConfigSelection = [&](CLI::App* cmd, bool filtering = false) { auto* group = cmd->add_option_group("Config Selection") ->description( @@ -43,15 +44,23 @@ int parseCommand(int argc, char** argv, CommandState& state) { ->excludes(path); group->add_option("-m,--manifest", state.config.manifest) - ->description("[DEPRECATED] Path to a quickshell manifest.\n" - "If a manifest is specified, configs named by -c will point to its entries.\n" - "Defaults to $XDG_CONFIG_HOME/quickshell/manifest.conf") + ->description( + "[DEPRECATED] Path to a quickshell manifest.\n" + "If a manifest is specified, configs named by -c will point to its entries.\n" + "Defaults to $XDG_CONFIG_HOME/quickshell/manifest.conf" + ) ->envname("QS_MANIFEST") ->excludes(path); - if (withNewestOption) { + if (filtering) { group->add_flag("-n,--newest", state.config.newest) ->description("Operate on the most recently launched instance instead of the oldest"); + + group->add_flag("--any-display", state.config.anyDisplay) + ->description( + "If passed, instances will not be filtered by the display connection they " + "were launched on." + ); } return group; @@ -75,9 +84,11 @@ int parseCommand(int argc, char** argv, CommandState& state) { auto* group = noGroup ? cmd : cmd->add_option_group(noDisplay ? "" : "Logging"); group->add_flag("--no-color", state.log.noColor) - ->description("Disables colored logging.\n" - "Colored logging can also be disabled by specifying a non empty value " - "for the NO_COLOR environment variable."); + ->description( + "Disables colored logging.\n" + "Colored logging can also be disabled by specifying a non empty value " + "for the NO_COLOR environment variable." + ); group->add_flag("--log-times", state.log.timestamp) ->description("Log timestamps with each message."); @@ -86,9 +97,11 @@ int parseCommand(int argc, char** argv, CommandState& state) { ->description("Log rules to apply, in the format of QT_LOGGING_RULES."); group->add_flag("-v,--verbose", [&](size_t count) { state.log.verbosity = count; }) - ->description("Increases log verbosity.\n" - "-v will show INFO level internal logs.\n" - "-vv will show DEBUG level internal logs."); + ->description( + "Increases log verbosity.\n" + "-v will show INFO level internal logs.\n" + "-vv will show DEBUG level internal logs." + ); auto* hgroup = cmd->add_option_group(""); hgroup->add_flag("--no-detailed-logs", state.log.sparse); @@ -98,9 +111,11 @@ int parseCommand(int argc, char** argv, CommandState& state) { auto* group = cmd->add_option_group("Instance Selection"); group->add_option("-i,--id", state.instance.id) - ->description("The instance id to operate on.\n" - "You may also use a substring the id as long as it is unique, " - "for example \"abc\" will select \"abcdefg\"."); + ->description( + "The instance id to operate on.\n" + "You may also use a substring the id as long as it is unique, " + "for example \"abc\" will select \"abcdefg\"." + ); group->add_option("--pid", state.instance.pid) ->description("The process id of the instance to operate on."); @@ -157,12 +172,17 @@ int parseCommand(int argc, char** argv, CommandState& state) { auto* sub = cli->add_subcommand("list", "List running quickshell instances."); auto* all = sub->add_flag("-a,--all", state.instance.all) - ->description("List all instances.\n" - "If unspecified, only instances of" - "the selected config will be listed."); + ->description( + "List all instances.\n" + "If unspecified, only instances of" + "the selected config will be listed." + ); sub->add_flag("-j,--json", state.output.json, "Output the list as a json."); + sub->add_flag("--show-dead", state.instance.includeDead) + ->description("Include dead instances in the list."); + addConfigSelection(sub, true)->excludes(all); addLoggingOptions(sub, false, true); @@ -207,6 +227,16 @@ int parseCommand(int argc, char** argv, CommandState& state) { ->allow_extra_args(); } + auto signalCmd = [&](std::string cmd, std::string desc) { + auto* scmd = sub->add_subcommand(std::move(cmd), std::move(desc)); + scmd->add_option("target", state.ipc.target, "The target to listen on."); + scmd->add_option("signal", state.ipc.name, "The signal to listen for."); + return scmd; + }; + + state.ipc.wait = signalCmd("wait", "Wait for one IpcHandler signal."); + state.ipc.listen = signalCmd("listen", "Listen for IpcHandler signals."); + { auto* prop = sub->add_subcommand("prop", "Manipulate IpcHandler properties.")->require_subcommand(); @@ -232,8 +262,10 @@ int parseCommand(int argc, char** argv, CommandState& state) { ->allow_extra_args(); sub->add_flag("-s,--show", state.ipc.showOld) - ->description("Print information about a function or target if given, or all available " - "targets if not."); + ->description( + "Print information about a function or target if given, or all available " + "targets if not." + ); auto* instance = addInstanceSelection(sub); addConfigSelection(sub, true)->excludes(instance); diff --git a/src/network/CMakeLists.txt b/src/network/CMakeLists.txt new file mode 100644 index 0000000..03ef86a --- /dev/null +++ b/src/network/CMakeLists.txt @@ -0,0 +1,25 @@ +add_subdirectory(nm) + +qt_add_library(quickshell-network STATIC + network.cpp + device.cpp + wifi.cpp + enums.cpp +) + +target_include_directories(quickshell-network PRIVATE + ${CMAKE_CURRENT_BINARY_DIR} +) + +qt_add_qml_module(quickshell-network + URI Quickshell.Networking + VERSION 0.1 + DEPENDENCIES QtQml +) + +qs_add_module_deps_light(quickshell-network Quickshell) +install_qml_module(quickshell-network) +target_link_libraries(quickshell-network PRIVATE quickshell-network-nm Qt::Qml Qt::DBus) +qs_add_link_dependencies(quickshell-network quickshell-dbus) +target_link_libraries(quickshell PRIVATE quickshell-networkplugin) +qs_module_pch(quickshell-network SET dbus) diff --git a/src/network/device.cpp b/src/network/device.cpp new file mode 100644 index 0000000..5679e8d --- /dev/null +++ b/src/network/device.cpp @@ -0,0 +1,48 @@ +#include "device.hpp" + +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "enums.hpp" + +namespace qs::network { + +namespace { +QS_LOGGING_CATEGORY(logNetworkDevice, "quickshell.network.device", QtWarningMsg); +} // namespace + +NetworkDevice::NetworkDevice(DeviceType::Enum type, QObject* parent): QObject(parent), mType(type) { + this->bindableConnected().setBinding([this]() { + return this->bState == ConnectionState::Connected; + }); +}; + +void NetworkDevice::setAutoconnect(bool autoconnect) { + if (this->bAutoconnect == autoconnect) return; + emit this->requestSetAutoconnect(autoconnect); +} + +void NetworkDevice::setNmManaged(bool managed) { + if (this->bNmManaged == managed) return; + emit this->requestSetNmManaged(managed); +} + +void NetworkDevice::disconnect() { + if (this->bState == ConnectionState::Disconnected) { + qCCritical(logNetworkDevice) << "Device" << this << "is already disconnected"; + return; + } + if (this->bState == ConnectionState::Disconnecting) { + qCCritical(logNetworkDevice) << "Device" << this << "is already disconnecting"; + return; + } + qCDebug(logNetworkDevice) << "Disconnecting from device" << this; + this->requestDisconnect(); +} + +} // namespace qs::network diff --git a/src/network/device.hpp b/src/network/device.hpp new file mode 100644 index 0000000..8d914a1 --- /dev/null +++ b/src/network/device.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/doc.hpp" +#include "enums.hpp" + +namespace qs::network { + +///! A network device. +/// The @@type property may be used to determine if this device is a @@WifiDevice. +class NetworkDevice: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE("Devices can only be acquired through Network"); + // clang-format off + /// The device type. + /// + /// When the device type is `Wifi`, the device object is a @@WifiDevice which exposes wifi network + /// connection and scanning. + Q_PROPERTY(DeviceType::Enum type READ type CONSTANT); + /// The name of the device's control interface. + Q_PROPERTY(QString name READ name NOTIFY nameChanged BINDABLE bindableName); + /// The hardware address of the device in the XX:XX:XX:XX:XX:XX format. + Q_PROPERTY(QString address READ default NOTIFY addressChanged BINDABLE bindableAddress); + /// True if the device is connected. + Q_PROPERTY(bool connected READ default NOTIFY connectedChanged BINDABLE bindableConnected); + /// Connection state of the device. + Q_PROPERTY(qs::network::ConnectionState::Enum state READ default NOTIFY stateChanged BINDABLE bindableState); + /// True if the device is managed by NetworkManager. + /// + /// > [!WARNING] Only valid for the NetworkManager backend. + Q_PROPERTY(bool nmManaged READ nmManaged WRITE setNmManaged NOTIFY nmManagedChanged) + /// True if the device is allowed to autoconnect to a network. + Q_PROPERTY(bool autoconnect READ autoconnect WRITE setAutoconnect NOTIFY autoconnectChanged); + // clang-format on + +public: + explicit NetworkDevice(DeviceType::Enum type, QObject* parent = nullptr); + + /// Disconnects the device and prevents it from automatically activating further connections. + Q_INVOKABLE void disconnect(); + + [[nodiscard]] DeviceType::Enum type() const { return this->mType; } + QBindable bindableName() { return &this->bName; } + [[nodiscard]] QString name() const { return this->bName; } + QBindable bindableAddress() { return &this->bAddress; } + QBindable bindableConnected() { return &this->bConnected; } + QBindable bindableState() { return &this->bState; } + QBindable bindableNmManaged() { return &this->bNmManaged; } + [[nodiscard]] bool nmManaged() { return this->bNmManaged; } + void setNmManaged(bool managed); + QBindable bindableAutoconnect() { return &this->bAutoconnect; } + [[nodiscard]] bool autoconnect() { return this->bAutoconnect; } + void setAutoconnect(bool autoconnect); + +signals: + QSDOC_HIDE void requestDisconnect(); + QSDOC_HIDE void requestSetAutoconnect(bool autoconnect); + QSDOC_HIDE void requestSetNmManaged(bool managed); + void nameChanged(); + void addressChanged(); + void connectedChanged(); + void stateChanged(); + void nmManagedChanged(); + void autoconnectChanged(); + +private: + DeviceType::Enum mType; + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(NetworkDevice, QString, bName, &NetworkDevice::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkDevice, QString, bAddress, &NetworkDevice::addressChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkDevice, bool, bConnected, &NetworkDevice::connectedChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkDevice, ConnectionState::Enum, bState, &NetworkDevice::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkDevice, bool, bNmManaged, &NetworkDevice::nmManagedChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkDevice, bool, bAutoconnect, &NetworkDevice::autoconnectChanged); + // clang-format on +}; + +} // namespace qs::network diff --git a/src/network/enums.cpp b/src/network/enums.cpp new file mode 100644 index 0000000..2cf36c1 --- /dev/null +++ b/src/network/enums.cpp @@ -0,0 +1,86 @@ +#include "enums.hpp" + +#include + +namespace qs::network { + +QString NetworkConnectivity::toString(NetworkConnectivity::Enum conn) { + switch (conn) { + case Unknown: return QStringLiteral("Unknown"); + case None: return QStringLiteral("Not connected to a network"); + case Portal: return QStringLiteral("Connection intercepted by a captive portal"); + case Limited: return QStringLiteral("Partial internet connectivity"); + case Full: return QStringLiteral("Full internet connectivity"); + default: return QStringLiteral("Unknown"); + } +} + +QString NetworkBackendType::toString(NetworkBackendType::Enum type) { + switch (type) { + case NetworkBackendType::None: return "None"; + case NetworkBackendType::NetworkManager: return "NetworkManager"; + default: return "Unknown"; + } +} + +QString ConnectionState::toString(ConnectionState::Enum state) { + switch (state) { + case Unknown: return QStringLiteral("Unknown"); + case Connecting: return QStringLiteral("Connecting"); + case Connected: return QStringLiteral("Connected"); + case Disconnecting: return QStringLiteral("Disconnecting"); + case Disconnected: return QStringLiteral("Disconnected"); + default: return QStringLiteral("Unknown"); + } +} + +QString ConnectionFailReason::toString(ConnectionFailReason::Enum reason) { + switch (reason) { + case Unknown: return QStringLiteral("Unknown"); + case NoSecrets: return QStringLiteral("Secrets were required but not provided"); + case WifiClientDisconnected: return QStringLiteral("Wi-Fi supplicant diconnected"); + case WifiClientFailed: return QStringLiteral("Wi-Fi supplicant failed"); + case WifiAuthTimeout: return QStringLiteral("Wi-Fi connection took too long to authenticate"); + case WifiNetworkLost: return QStringLiteral("Wi-Fi network could not be found"); + default: return QStringLiteral("Unknown"); + } +} + +QString DeviceType::toString(DeviceType::Enum type) { + switch (type) { + case None: return QStringLiteral("None"); + case Wifi: return QStringLiteral("Wifi"); + default: return QStringLiteral("Unknown"); + } +} + +QString WifiSecurityType::toString(WifiSecurityType::Enum type) { + switch (type) { + case Unknown: return QStringLiteral("Unknown"); + case Wpa3SuiteB192: return QStringLiteral("WPA3 Suite B 192-bit"); + case Sae: return QStringLiteral("WPA3"); + case Wpa2Eap: return QStringLiteral("WPA2 Enterprise"); + case Wpa2Psk: return QStringLiteral("WPA2"); + case WpaEap: return QStringLiteral("WPA Enterprise"); + case WpaPsk: return QStringLiteral("WPA"); + case StaticWep: return QStringLiteral("WEP"); + case DynamicWep: return QStringLiteral("Dynamic WEP"); + case Leap: return QStringLiteral("LEAP"); + case Owe: return QStringLiteral("OWE"); + case Open: return QStringLiteral("Open"); + default: return QStringLiteral("Unknown"); + } +} + +QString WifiDeviceMode::toString(WifiDeviceMode::Enum mode) { + switch (mode) { + case Unknown: return QStringLiteral("Unknown"); + case AdHoc: return QStringLiteral("Ad-Hoc"); + case Station: return QStringLiteral("Station"); + case AccessPoint: return QStringLiteral("Access Point"); + case Mesh: return QStringLiteral("Mesh"); + default: return QStringLiteral("Unknown"); + }; +} + +} // namespace qs::network diff --git a/src/network/enums.hpp b/src/network/enums.hpp new file mode 100644 index 0000000..49c28ce --- /dev/null +++ b/src/network/enums.hpp @@ -0,0 +1,154 @@ +#pragma once + +#include +#include +#include + +namespace qs::network { + +///! The degree to which the host can reach the internet. +class NetworkConnectivity: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// Network connectivity is unknown. This means the connectivity checks are disabled or have not run yet. + Unknown = 0, + /// The host is not connected to any network. + None = 1, + /// The internet connection is hijacked by a captive portal gateway. + /// This indicates the shell should open a sandboxed web browser window for the purpose of authenticating to a gateway. + Portal = 2, + /// The host is connected to a network but does not appear to be able to reach the full internet. + Limited = 3, + /// The host is connected to a network and appears to be able to reach the full internet. + Full = 4, + }; + Q_ENUM(Enum); + Q_INVOKABLE static QString toString(NetworkConnectivity::Enum conn); +}; + +///! The backend supplying the Network service. +class NetworkBackendType: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + None = 0, + NetworkManager = 1, + }; + Q_ENUM(Enum); + Q_INVOKABLE static QString toString(NetworkBackendType::Enum type); +}; + +///! The connection state of a device or network. +class ConnectionState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + Unknown = 0, + Connecting = 1, + Connected = 2, + Disconnecting = 3, + Disconnected = 4, + }; + Q_ENUM(Enum); + Q_INVOKABLE static QString toString(ConnectionState::Enum state); +}; + +///! The reason a connection failed. +class ConnectionFailReason: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// The connection failed for an unknown reason. + Unknown = 0, + /// Secrets were required, but not provided. + NoSecrets = 1, + /// The Wi-Fi supplicant disconnected. + WifiClientDisconnected = 2, + /// The Wi-Fi supplicant failed. + WifiClientFailed = 3, + /// The Wi-Fi connection took too long to authenticate. + WifiAuthTimeout = 4, + /// The Wi-Fi network could not be found. + WifiNetworkLost = 5, + }; + Q_ENUM(Enum); + Q_INVOKABLE static QString toString(ConnectionFailReason::Enum reason); +}; + +///! Type of a @@NetworkDevice. +class DeviceType: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + None = 0, + Wifi = 1, + }; + Q_ENUM(Enum); + Q_INVOKABLE static QString toString(DeviceType::Enum type); +}; + +///! The security type of a @@WifiNetwork. +class WifiSecurityType: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + Wpa3SuiteB192 = 0, + Sae = 1, + Wpa2Eap = 2, + Wpa2Psk = 3, + WpaEap = 4, + WpaPsk = 5, + StaticWep = 6, + DynamicWep = 7, + Leap = 8, + Owe = 9, + Open = 10, + Unknown = 11, + }; + Q_ENUM(Enum); + Q_INVOKABLE static QString toString(WifiSecurityType::Enum type); +}; + +///! The 802.11 mode of a @@WifiDevice. +class WifiDeviceMode: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + /// The device is part of an Ad-Hoc network without a central access point. + AdHoc = 0, + /// The device is a station that can connect to networks. + Station = 1, + /// The device is a local hotspot/access point. + AccessPoint = 2, + /// The device is an 802.11s mesh point. + Mesh = 3, + /// The device mode is unknown. + Unknown = 4, + }; + Q_ENUM(Enum); + Q_INVOKABLE static QString toString(WifiDeviceMode::Enum mode); +}; + +} // namespace qs::network diff --git a/src/network/module.md b/src/network/module.md new file mode 100644 index 0000000..91ff2f1 --- /dev/null +++ b/src/network/module.md @@ -0,0 +1,15 @@ +name = "Quickshell.Networking" +description = "Network API" +headers = [ + "network.hpp", + "device.hpp", + "wifi.hpp", + "enums.hpp", + "nm/settings.hpp", +] +----- +This module exposes Network management APIs provided by a supported network backend. +For now, the only backend available is the NetworkManager DBus interface. +Both DBus and NetworkManager must be running to use it. + +See the @@Quickshell.Networking.Networking singleton. diff --git a/src/network/network.cpp b/src/network/network.cpp new file mode 100644 index 0000000..e66ffa6 --- /dev/null +++ b/src/network/network.cpp @@ -0,0 +1,131 @@ +#include "network.hpp" +#include + +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "device.hpp" +#include "enums.hpp" +#include "nm/backend.hpp" +#include "nm/settings.hpp" + +namespace qs::network { + +namespace { +QS_LOGGING_CATEGORY(logNetwork, "quickshell.network", QtWarningMsg); +} // namespace + +Networking::Networking(QObject* parent): QObject(parent) { + // Try to create the NetworkManager backend and bind to it. + auto* nm = new NetworkManager(this); + if (nm->isAvailable()) { + // clang-format off + QObject::connect(nm, &NetworkManager::deviceAdded, this, &Networking::deviceAdded); + QObject::connect(nm, &NetworkManager::deviceRemoved, this, &Networking::deviceRemoved); + QObject::connect(this, &Networking::requestSetWifiEnabled, nm, &NetworkManager::setWifiEnabled); + QObject::connect(this, &Networking::requestSetConnectivityCheckEnabled, nm, &NetworkManager::setConnectivityCheckEnabled); + QObject::connect(this, &Networking::requestCheckConnectivity, nm, &NetworkManager::checkConnectivity); + this->bindableWifiEnabled().setBinding([nm]() { return nm->wifiEnabled(); }); + this->bindableWifiHardwareEnabled().setBinding([nm]() { return nm->wifiHardwareEnabled(); }); + this->bindableCanCheckConnectivity().setBinding([nm]() { return nm->connectivityCheckAvailable(); }); + this->bindableConnectivityCheckEnabled().setBinding([nm]() { return nm->connectivityCheckEnabled(); }); + this->bindableConnectivity().setBinding([nm]() { return static_cast(nm->connectivity()); }); + // clang-format on + + this->mBackend = nm; + this->mBackendType = NetworkBackendType::NetworkManager; + return; + } else { + delete nm; + } + qCCritical(logNetwork) << "Network will not work. Could not find an available backend."; +} + +Networking* Networking::instance() { + static Networking* instance = new Networking(); // NOLINT + return instance; +} + +void Networking::deviceAdded(NetworkDevice* dev) { this->mDevices.insertObject(dev); } +void Networking::deviceRemoved(NetworkDevice* dev) { this->mDevices.removeObject(dev); } + +void Networking::checkConnectivity() { + if (!this->bConnectivityCheckEnabled || !this->bCanCheckConnectivity) return; + emit this->requestCheckConnectivity(); +} + +void Networking::setWifiEnabled(bool enabled) { + if (this->bWifiEnabled == enabled) return; + emit this->requestSetWifiEnabled(enabled); +} + +void Networking::setConnectivityCheckEnabled(bool enabled) { + if (this->bConnectivityCheckEnabled == enabled) return; + emit this->requestSetConnectivityCheckEnabled(enabled); +} + +NetworkingQml::NetworkingQml(QObject* parent): QObject(parent) { + // clang-format off + QObject::connect(Networking::instance(), &Networking::wifiEnabledChanged, this, &NetworkingQml::wifiEnabledChanged); + QObject::connect(Networking::instance(), &Networking::wifiHardwareEnabledChanged, this, &NetworkingQml::wifiHardwareEnabledChanged); + QObject::connect(Networking::instance(), &Networking::canCheckConnectivityChanged, this, &NetworkingQml::canCheckConnectivityChanged); + QObject::connect(Networking::instance(), &Networking::connectivityCheckEnabledChanged, this, &NetworkingQml::connectivityCheckEnabledChanged); + QObject::connect(Networking::instance(), &Networking::connectivityChanged, this, &NetworkingQml::connectivityChanged); + // clang-format on +} + +void NetworkingQml::checkConnectivity() { Networking::instance()->checkConnectivity(); } + +Network::Network(QString name, QObject* parent): QObject(parent), mName(std::move(name)) { + this->bStateChanging.setBinding([this] { + auto state = this->bState.value(); + return state == ConnectionState::Connecting || state == ConnectionState::Disconnecting; + }); +}; + +void Network::connect() { + if (this->bConnected) { + qCCritical(logNetwork) << this << "is already connected."; + return; + } + this->requestConnect(); +} + +void Network::connectWithSettings(NMSettings* settings) { + if (this->bConnected) { + qCCritical(logNetwork) << this << "is already connected."; + return; + } + if (this->bNmSettings.value().indexOf(settings) == -1) return; + this->requestConnectWithSettings(settings); +} + +void Network::disconnect() { + if (!this->bConnected) { + qCCritical(logNetwork) << this << "is not currently connected"; + return; + } + this->requestDisconnect(); +} + +void Network::forget() { this->requestForget(); } + +void Network::settingsAdded(NMSettings* settings) { + auto list = this->bNmSettings.value(); + if (list.contains(settings)) return; + list.append(settings); + this->bNmSettings = list; +} + +void Network::settingsRemoved(NMSettings* settings) { + auto list = this->bNmSettings.value(); + list.removeOne(settings); + this->bNmSettings = list; +} + +} // namespace qs::network diff --git a/src/network/network.hpp b/src/network/network.hpp new file mode 100644 index 0000000..f7734a2 --- /dev/null +++ b/src/network/network.hpp @@ -0,0 +1,233 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/model.hpp" +#include "device.hpp" +#include "enums.hpp" +#include "nm/settings.hpp" + +namespace qs::network { + +class NetworkBackend: public QObject { + Q_OBJECT; + +public: + [[nodiscard]] virtual bool isAvailable() const = 0; + +protected: + explicit NetworkBackend(QObject* parent = nullptr): QObject(parent) {}; +}; + +class Networking: public QObject { + Q_OBJECT; + +public: + static Networking* instance(); + + void checkConnectivity(); + + [[nodiscard]] ObjectModel* devices() { return &this->mDevices; } + [[nodiscard]] NetworkBackendType::Enum backend() const { return this->mBackendType; } + QBindable bindableWifiEnabled() { return &this->bWifiEnabled; } + [[nodiscard]] bool wifiEnabled() const { return this->bWifiEnabled; } + void setWifiEnabled(bool enabled); + QBindable bindableWifiHardwareEnabled() { return &this->bWifiHardwareEnabled; } + QBindable bindableCanCheckConnectivity() { return &this->bCanCheckConnectivity; } + QBindable bindableConnectivityCheckEnabled() { return &this->bConnectivityCheckEnabled; } + [[nodiscard]] bool connectivityCheckEnabled() const { return this->bConnectivityCheckEnabled; } + void setConnectivityCheckEnabled(bool enabled); + QBindable bindableConnectivity() { return &this->bConnectivity; } + +signals: + void requestSetWifiEnabled(bool enabled); + void requestSetConnectivityCheckEnabled(bool enabled); + void requestCheckConnectivity(); + + void wifiEnabledChanged(); + void wifiHardwareEnabledChanged(); + void canCheckConnectivityChanged(); + void connectivityCheckEnabledChanged(); + void connectivityChanged(); + +private slots: + void deviceAdded(NetworkDevice* dev); + void deviceRemoved(NetworkDevice* dev); + +private: + explicit Networking(QObject* parent = nullptr); + + ObjectModel mDevices {this}; + NetworkBackend* mBackend = nullptr; + NetworkBackendType::Enum mBackendType = NetworkBackendType::None; + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(Networking, bool, bWifiEnabled, &Networking::wifiEnabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(Networking, bool, bWifiHardwareEnabled, &Networking::wifiHardwareEnabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(Networking, bool, bCanCheckConnectivity, &Networking::canCheckConnectivityChanged); + Q_OBJECT_BINDABLE_PROPERTY(Networking, bool, bConnectivityCheckEnabled, &Networking::connectivityCheckEnabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(Networking, NetworkConnectivity::Enum, bConnectivity, &Networking::connectivityChanged); + // clang-format on +}; + +///! The Network service. +/// An interface to a network backend (currently only NetworkManager), +/// which can be used to view, configure, and connect to various networks. +class NetworkingQml: public QObject { + Q_OBJECT; + QML_NAMED_ELEMENT(Networking); + QML_SINGLETON; + // clang-format off + /// A list of all network devices. Networks are exposed through their respective devices. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* devices READ devices CONSTANT); + /// The backend being used to power the Network service. + Q_PROPERTY(qs::network::NetworkBackendType::Enum backend READ backend CONSTANT); + /// Switch for the rfkill software block of all wireless devices. + Q_PROPERTY(bool wifiEnabled READ wifiEnabled WRITE setWifiEnabled NOTIFY wifiEnabledChanged); + /// State of the rfkill hardware block of all wireless devices. + Q_PROPERTY(bool wifiHardwareEnabled READ default NOTIFY wifiHardwareEnabledChanged BINDABLE bindableWifiHardwareEnabled); + /// True if the @@backend supports connectivity checks. + Q_PROPERTY(bool canCheckConnectivity READ default NOTIFY canCheckConnectivityChanged BINDABLE bindableCanCheckConnectivity); + /// True if connectivity checking is enabled. + Q_PROPERTY(bool connectivityCheckEnabled READ connectivityCheckEnabled WRITE setConnectivityCheckEnabled NOTIFY connectivityCheckEnabledChanged); + /// The result of the last connectivity check. + /// + /// Connectivity checks may require additional configuration depending on your distro. + /// + /// > [!NOTE] This property can be used to determine if network access is restricted + /// > or gated behind a captive portal. + /// > + /// > If checking for captive portals, @@checkConnectivity() should be called after + /// > the portal is dismissed to update this property. + Q_PROPERTY(qs::network::NetworkConnectivity::Enum connectivity READ default NOTIFY connectivityChanged BINDABLE bindableConnectivity); + // clang-format on + +public: + explicit NetworkingQml(QObject* parent = nullptr); + + /// Re-check the network connectivity state immediately. + /// > [!NOTE] This should be invoked after a user dismisses a web browser that was opened to authenticate via a captive portal. + Q_INVOKABLE static void checkConnectivity(); + + [[nodiscard]] static ObjectModel* devices() { + return Networking::instance()->devices(); + } + [[nodiscard]] static NetworkBackendType::Enum backend() { + return Networking::instance()->backend(); + } + [[nodiscard]] static bool wifiEnabled() { return Networking::instance()->wifiEnabled(); } + static void setWifiEnabled(bool enabled) { Networking::instance()->setWifiEnabled(enabled); } + [[nodiscard]] static QBindable bindableWifiHardwareEnabled() { + return Networking::instance()->bindableWifiHardwareEnabled(); + } + [[nodiscard]] static QBindable bindableWifiEnabled() { + return Networking::instance()->bindableWifiEnabled(); + } + [[nodiscard]] static QBindable bindableCanCheckConnectivity() { + return Networking::instance()->bindableCanCheckConnectivity(); + } + [[nodiscard]] static bool connectivityCheckEnabled() { + return Networking::instance()->connectivityCheckEnabled(); + } + static void setConnectivityCheckEnabled(bool enabled) { + Networking::instance()->setConnectivityCheckEnabled(enabled); + } + [[nodiscard]] static QBindable bindableConnectivity() { + return Networking::instance()->bindableConnectivity(); + } + +signals: + void wifiEnabledChanged(); + void wifiHardwareEnabledChanged(); + void canCheckConnectivityChanged(); + void connectivityCheckEnabledChanged(); + void connectivityChanged(); +}; + +///! A network. +/// A network. Networks derived from a @@WifiDevice are @@WifiNetwork instances. +class Network: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE("Network can only be aqcuired through networking devices"); + + // clang-format off + /// The name of the network. + Q_PROPERTY(QString name READ name CONSTANT); + /// A list of NetworkManager connnection settings profiles for this network. + /// + /// > [!WARNING] Only valid for the NetworkManager backend. + Q_PROPERTY(QList nmSettings READ nmSettings NOTIFY nmSettingsChanged BINDABLE bindableNmSettings); + /// True if the network is connected. + Q_PROPERTY(bool connected READ default NOTIFY connectedChanged BINDABLE bindableConnected); + /// True if the wifi network has known connection settings saved. + Q_PROPERTY(bool known READ default NOTIFY knownChanged BINDABLE bindableKnown); + /// The connectivity state of the network. + Q_PROPERTY(ConnectionState::Enum state READ default NOTIFY stateChanged BINDABLE bindableState); + /// If the network is currently connecting or disconnecting. Shorthand for checking @@state. + Q_PROPERTY(bool stateChanging READ default NOTIFY stateChangingChanged BINDABLE bindableStateChanging); + // clang-format on + +public: + explicit Network(QString name, QObject* parent = nullptr); + /// Attempt to connect to the network. + /// + /// > [!NOTE] If the network is a @@WifiNetwork and requires secrets, a @@connectionFailed(s) + /// > signal will be emitted with `NoSecrets`. + /// > @@WifiNetwork.connectWithPsk() can be used to provide secrets. + Q_INVOKABLE void connect(); + /// Attempt to connect to the network with a specific @@nmSettings entry. + /// + /// > [!WARNING] Only valid for the NetworkManager backend. + Q_INVOKABLE void connectWithSettings(NMSettings* settings); + /// Disconnect from the network. + Q_INVOKABLE void disconnect(); + /// Forget all connection settings for this network. + Q_INVOKABLE void forget(); + + void settingsAdded(NMSettings* settings); + void settingsRemoved(NMSettings* settings); + + // clang-format off + [[nodiscard]] QString name() const { return this->mName; } + [[nodiscard]] const QList& nmSettings() const { return this->bNmSettings; } + QBindable> bindableNmSettings() const { return &this->bNmSettings; } + QBindable bindableConnected() { return &this->bConnected; } + QBindable bindableKnown() { return &this->bKnown; } + [[nodiscard]] ConnectionState::Enum state() const { return this->bState; } + QBindable bindableState() { return &this->bState; } + QBindable bindableStateChanging() { return &this->bStateChanging; } + // clang-format on + +signals: + /// Signals that a connection to the network has failed because of the given @@ConnectionFailReason. + void connectionFailed(ConnectionFailReason::Enum reason); + + void connectedChanged(); + void knownChanged(); + void stateChanged(); + void stateChangingChanged(); + void nmSettingsChanged(); + QSDOC_HIDE void requestConnect(); + QSDOC_HIDE void requestConnectWithSettings(NMSettings* settings); + QSDOC_HIDE void requestDisconnect(); + QSDOC_HIDE void requestForget(); + +protected: + QString mName; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(Network, bool, bConnected, &Network::connectedChanged); + Q_OBJECT_BINDABLE_PROPERTY(Network, bool, bKnown, &Network::knownChanged); + Q_OBJECT_BINDABLE_PROPERTY(Network, ConnectionState::Enum, bState, &Network::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(Network, bool, bStateChanging, &Network::stateChangingChanged); + Q_OBJECT_BINDABLE_PROPERTY(Network, QList, bNmSettings, &Network::nmSettingsChanged); + // clang-format on +}; + +} // namespace qs::network diff --git a/src/network/nm/CMakeLists.txt b/src/network/nm/CMakeLists.txt new file mode 100644 index 0000000..61f7e66 --- /dev/null +++ b/src/network/nm/CMakeLists.txt @@ -0,0 +1,81 @@ +set_source_files_properties(org.freedesktop.NetworkManager.xml PROPERTIES + CLASSNAME DBusNetworkManagerProxy + NO_NAMESPACE TRUE + INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/dbus_types.hpp +) + +qt_add_dbus_interface(NM_DBUS_INTERFACES + org.freedesktop.NetworkManager.xml + dbus_nm_backend +) + +set_source_files_properties(org.freedesktop.NetworkManager.Device.xml PROPERTIES + CLASSNAME DBusNMDeviceProxy + NO_NAMESPACE TRUE +) + +qt_add_dbus_interface(NM_DBUS_INTERFACES + org.freedesktop.NetworkManager.Device.xml + dbus_nm_device +) + +set_source_files_properties(org.freedesktop.NetworkManager.Device.Wireless.xml PROPERTIES + CLASSNAME DBusNMWirelessProxy + NO_NAMESPACE TRUE +) + +qt_add_dbus_interface(NM_DBUS_INTERFACES + org.freedesktop.NetworkManager.Device.Wireless.xml + dbus_nm_wireless +) + +set_source_files_properties(org.freedesktop.NetworkManager.AccessPoint.xml PROPERTIES + CLASSNAME DBusNMAccessPointProxy + NO_NAMESPACE TRUE +) + +qt_add_dbus_interface(NM_DBUS_INTERFACES + org.freedesktop.NetworkManager.AccessPoint.xml + dbus_nm_accesspoint +) + +set_source_files_properties(org.freedesktop.NetworkManager.Settings.Connection.xml PROPERTIES + CLASSNAME DBusNMConnectionSettingsProxy + NO_NAMESPACE TRUE + INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/dbus_types.hpp +) + +qt_add_dbus_interface(NM_DBUS_INTERFACES + org.freedesktop.NetworkManager.Settings.Connection.xml + dbus_nm_connection_settings +) + +set_source_files_properties(org.freedesktop.NetworkManager.Connection.Active.xml PROPERTIES + CLASSNAME DBusNMActiveConnectionProxy + NO_NAMESPACE TRUE +) + +qt_add_dbus_interface(NM_DBUS_INTERFACES + org.freedesktop.NetworkManager.Connection.Active.xml + dbus_nm_active_connection +) + +qt_add_library(quickshell-network-nm STATIC + backend.cpp + device.cpp + active_connection.cpp + settings.cpp + accesspoint.cpp + wireless.cpp + utils.cpp + dbus_types.cpp + enums.hpp + ${NM_DBUS_INTERFACES} +) + +target_include_directories(quickshell-network-nm PUBLIC + ${CMAKE_CURRENT_BINARY_DIR} +) + +target_link_libraries(quickshell-network-nm PRIVATE Qt::Qml Qt::DBus) +qs_add_link_dependencies(quickshell-network-nm quickshell-dbus) diff --git a/src/network/nm/accesspoint.cpp b/src/network/nm/accesspoint.cpp new file mode 100644 index 0000000..b6e3dfb --- /dev/null +++ b/src/network/nm/accesspoint.cpp @@ -0,0 +1,71 @@ +#include "accesspoint.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "../../dbus/properties.hpp" +#include "dbus_nm_accesspoint.h" +#include "enums.hpp" + +namespace qs::network { +using namespace qs::dbus; + +namespace { +QS_LOGGING_CATEGORY(logNetworkManager, "quickshell.network.networkmanager", QtWarningMsg); +} + +NMAccessPoint::NMAccessPoint(const QString& path, QObject* parent): QObject(parent) { + this->proxy = new DBusNMAccessPointProxy( + "org.freedesktop.NetworkManager", + path, + QDBusConnection::systemBus(), + this + ); + + if (!this->proxy->isValid()) { + qCWarning(logNetworkManager) << "Cannot create DBus interface for access point at" << path; + return; + } + + QObject::connect( + &this->accessPointProperties, + &DBusPropertyGroup::getAllFinished, + this, + &NMAccessPoint::loaded, + Qt::SingleShotConnection + ); + + this->accessPointProperties.setInterface(this->proxy); + this->accessPointProperties.updateAllViaGetAll(); +} + +bool NMAccessPoint::isValid() const { return this->proxy && this->proxy->isValid(); } +QString NMAccessPoint::address() const { return this->proxy ? this->proxy->service() : QString(); } +QString NMAccessPoint::path() const { return this->proxy ? this->proxy->path() : QString(); } + +} // namespace qs::network + +namespace qs::dbus { + +DBusResult +DBusDataTransform::fromWire(quint32 wire) { + return DBusResult(static_cast(wire)); +} + +DBusResult +DBusDataTransform::fromWire(quint32 wire) { + return DBusResult(static_cast(wire)); +} + +DBusResult +DBusDataTransform::fromWire(quint32 wire) { + return DBusResult(static_cast(wire)); +} + +} // namespace qs::dbus diff --git a/src/network/nm/accesspoint.hpp b/src/network/nm/accesspoint.hpp new file mode 100644 index 0000000..63e35ee --- /dev/null +++ b/src/network/nm/accesspoint.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../../dbus/properties.hpp" +#include "../wifi.hpp" +#include "dbus_nm_accesspoint.h" +#include "enums.hpp" + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = quint32; + using Data = qs::network::NM80211ApFlags::Enum; + static DBusResult fromWire(Wire wire); +}; + +template <> +struct DBusDataTransform { + using Wire = quint32; + using Data = qs::network::NM80211ApSecurityFlags::Enum; + static DBusResult fromWire(Wire wire); +}; + +template <> +struct DBusDataTransform { + using Wire = quint32; + using Data = qs::network::NM80211Mode::Enum; + static DBusResult fromWire(Wire wire); +}; + +} // namespace qs::dbus + +namespace qs::network { + +/// Proxy of a /org/freedesktop/NetworkManager/AccessPoint/* object. +class NMAccessPoint: public QObject { + Q_OBJECT; + +public: + explicit NMAccessPoint(const QString& path, QObject* parent = nullptr); + + [[nodiscard]] bool isValid() const; + [[nodiscard]] QString path() const; + [[nodiscard]] QString address() const; + [[nodiscard]] QByteArray ssid() const { return this->bSsid; } + [[nodiscard]] quint8 signalStrength() const { return this->bSignalStrength; } + [[nodiscard]] NM80211ApFlags::Enum flags() const { return this->bFlags; } + [[nodiscard]] NM80211ApSecurityFlags::Enum wpaFlags() const { return this->bWpaFlags; } + [[nodiscard]] NM80211ApSecurityFlags::Enum rsnFlags() const { return this->bRsnFlags; } + [[nodiscard]] NM80211Mode::Enum mode() const { return this->bMode; } + [[nodiscard]] QBindable bindableSecurity() { return &this->bSecurity; } + [[nodiscard]] WifiSecurityType::Enum security() const { return this->bSecurity; } + +signals: + void loaded(); + void ssidChanged(const QByteArray& ssid); + void signalStrengthChanged(quint8 signal); + void flagsChanged(NM80211ApFlags::Enum flags); + void wpaFlagsChanged(NM80211ApSecurityFlags::Enum wpaFlags); + void rsnFlagsChanged(NM80211ApSecurityFlags::Enum rsnFlags); + void modeChanged(NM80211Mode::Enum mode); + void securityChanged(WifiSecurityType::Enum security); + +private: + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(NMAccessPoint, QByteArray, bSsid, &NMAccessPoint::ssidChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMAccessPoint, quint8, bSignalStrength, &NMAccessPoint::signalStrengthChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMAccessPoint, NM80211ApFlags::Enum, bFlags, &NMAccessPoint::flagsChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMAccessPoint, NM80211ApSecurityFlags::Enum, bWpaFlags, &NMAccessPoint::wpaFlagsChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMAccessPoint, NM80211ApSecurityFlags::Enum, bRsnFlags, &NMAccessPoint::rsnFlagsChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMAccessPoint, NM80211Mode::Enum, bMode, &NMAccessPoint::modeChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMAccessPoint, WifiSecurityType::Enum, bSecurity, &NMAccessPoint::securityChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(NMAccessPointAdapter, accessPointProperties); + QS_DBUS_PROPERTY_BINDING(NMAccessPoint, pSsid, bSsid, accessPointProperties, "Ssid"); + QS_DBUS_PROPERTY_BINDING(NMAccessPoint, pSignalStrength, bSignalStrength, accessPointProperties, "Strength"); + QS_DBUS_PROPERTY_BINDING(NMAccessPoint, pFlags, bFlags, accessPointProperties, "Flags"); + QS_DBUS_PROPERTY_BINDING(NMAccessPoint, pWpaFlags, bWpaFlags, accessPointProperties, "WpaFlags"); + QS_DBUS_PROPERTY_BINDING(NMAccessPoint, pRsnFlags, bRsnFlags, accessPointProperties, "RsnFlags"); + QS_DBUS_PROPERTY_BINDING(NMAccessPoint, pMode, bMode, accessPointProperties, "Mode"); + // clang-format on + + DBusNMAccessPointProxy* proxy = nullptr; +}; + +} // namespace qs::network diff --git a/src/network/nm/active_connection.cpp b/src/network/nm/active_connection.cpp new file mode 100644 index 0000000..cab0e52 --- /dev/null +++ b/src/network/nm/active_connection.cpp @@ -0,0 +1,68 @@ +#include "active_connection.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "../../dbus/properties.hpp" +#include "dbus_nm_active_connection.h" +#include "enums.hpp" + +namespace qs::network { +using namespace qs::dbus; + +namespace { +QS_LOGGING_CATEGORY(logNetworkManager, "quickshell.network.networkmanager", QtWarningMsg); +} + +NMActiveConnection::NMActiveConnection(const QString& path, QObject* parent): QObject(parent) { + this->proxy = new DBusNMActiveConnectionProxy( + "org.freedesktop.NetworkManager", + path, + QDBusConnection::systemBus(), + this + ); + + if (!this->proxy->isValid()) { + qCWarning(logNetworkManager) << "Cannot create DBus interface for connection at" << path; + return; + } + + // clang-format off + QObject::connect(&this->activeConnectionProperties, &DBusPropertyGroup::getAllFinished, this, &NMActiveConnection::loaded, Qt::SingleShotConnection); + QObject::connect(this->proxy, &DBusNMActiveConnectionProxy::StateChanged, this, &NMActiveConnection::onStateChanged); + // clang-format on + + this->activeConnectionProperties.setInterface(this->proxy); + this->activeConnectionProperties.updateAllViaGetAll(); +} + +void NMActiveConnection::onStateChanged(quint32 /*state*/, quint32 reason) { + auto enumReason = static_cast(reason); + if (this->bStateReason == enumReason) return; + this->bStateReason = enumReason; +} + +bool NMActiveConnection::isValid() const { return this->proxy && this->proxy->isValid(); } +QString NMActiveConnection::address() const { + return this->proxy ? this->proxy->service() : QString(); +} +QString NMActiveConnection::path() const { return this->proxy ? this->proxy->path() : QString(); } + +} // namespace qs::network + +namespace qs::dbus { + +DBusResult +DBusDataTransform::fromWire(quint32 wire) { + return DBusResult(static_cast(wire)); +} + +} // namespace qs::dbus diff --git a/src/network/nm/active_connection.hpp b/src/network/nm/active_connection.hpp new file mode 100644 index 0000000..33426a1 --- /dev/null +++ b/src/network/nm/active_connection.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../dbus/properties.hpp" +#include "dbus_nm_active_connection.h" +#include "enums.hpp" + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = quint32; + using Data = qs::network::NMConnectionState::Enum; + static DBusResult fromWire(Wire wire); +}; + +} // namespace qs::dbus + +namespace qs::network { + +// Proxy of a /org/freedesktop/NetworkManager/ActiveConnection/* object. +class NMActiveConnection: public QObject { + Q_OBJECT; + +public: + explicit NMActiveConnection(const QString& path, QObject* parent = nullptr); + + [[nodiscard]] bool isValid() const; + [[nodiscard]] QString path() const; + [[nodiscard]] QString address() const; + [[nodiscard]] QDBusObjectPath connection() const { return this->bConnection; } + [[nodiscard]] NMConnectionState::Enum state() const { return this->bState; } + [[nodiscard]] NMConnectionStateReason::Enum stateReason() const { return this->bStateReason; } + +signals: + void loaded(); + void connectionChanged(QDBusObjectPath path); + void stateChanged(NMConnectionState::Enum state); + void stateReasonChanged(NMConnectionStateReason::Enum reason); + +private slots: + void onStateChanged(quint32 state, quint32 reason); + +private: + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(NMActiveConnection, QDBusObjectPath, bConnection, &NMActiveConnection::connectionChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMActiveConnection, NMConnectionState::Enum, bState, &NMActiveConnection::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMActiveConnection, NMConnectionStateReason::Enum, bStateReason, &NMActiveConnection::stateReasonChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(NMActiveConnection, activeConnectionProperties); + QS_DBUS_PROPERTY_BINDING(NMActiveConnection, pConnection, bConnection, activeConnectionProperties, "Connection"); + QS_DBUS_PROPERTY_BINDING(NMActiveConnection, pState, bState, activeConnectionProperties, "State"); + // clang-format on + DBusNMActiveConnectionProxy* proxy = nullptr; +}; + +} // namespace qs::network diff --git a/src/network/nm/backend.cpp b/src/network/nm/backend.cpp new file mode 100644 index 0000000..a46ccb2 --- /dev/null +++ b/src/network/nm/backend.cpp @@ -0,0 +1,305 @@ +#include "backend.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "../../dbus/properties.hpp" +#include "../device.hpp" +#include "../enums.hpp" +#include "../network.hpp" +#include "../wifi.hpp" +#include "dbus_nm_backend.h" +#include "dbus_nm_device.h" +#include "dbus_types.hpp" +#include "device.hpp" +#include "enums.hpp" +#include "wireless.hpp" + +namespace qs::network { + +namespace { +QS_LOGGING_CATEGORY(logNetworkManager, "quickshell.network.networkmanager", QtWarningMsg); +} + +NetworkManager::NetworkManager(QObject* parent): NetworkBackend(parent) { + qCDebug(logNetworkManager) << "Connecting to NetworkManager"; + qDBusRegisterMetaType(); + + auto bus = QDBusConnection::systemBus(); + if (!bus.isConnected()) { + qCWarning( + logNetworkManager + ) << "Could not connect to DBus. NetworkManager backend will not work."; + return; + } + + this->proxy = new DBusNetworkManagerProxy( + "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager", + bus, + this + ); + + if (!this->proxy->isValid()) { + qCDebug( + logNetworkManager + ) << "NetworkManager is not currently running. This network backend will not work"; + } else { + this->init(); + } +} + +void NetworkManager::init() { + // clang-format off + QObject::connect(this->proxy, &DBusNetworkManagerProxy::DeviceAdded, this, &NetworkManager::onDevicePathAdded); + QObject::connect(this->proxy, &DBusNetworkManagerProxy::DeviceRemoved, this, &NetworkManager::onDevicePathRemoved); + // clang-format on + + this->dbusProperties.setInterface(this->proxy); + this->dbusProperties.updateAllViaGetAll(); + + this->registerDevices(); +} + +void NetworkManager::checkConnectivity() { + auto pending = this->proxy->CheckConnectivity(); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + + if (reply.isError()) { + qCInfo(logNetworkManager) << "Failed to check connectivity: " << reply.error().message(); + } + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void NetworkManager::registerDevices() { + auto pending = this->proxy->GetAllDevices(); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [this](QDBusPendingCallWatcher* call) { + const QDBusPendingReply> reply = *call; + + if (reply.isError()) { + qCWarning(logNetworkManager) << "Failed to get devices: " << reply.error().message(); + } else { + for (const QDBusObjectPath& devicePath: reply.value()) { + this->registerDevice(devicePath.path()); + } + } + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void NetworkManager::registerDevice(const QString& path) { + if (this->mDevices.contains(path)) { + qCDebug(logNetworkManager) << "Skipping duplicate registration of device" << path; + return; + } + + auto* temp = new DBusNMDeviceProxy( + "org.freedesktop.NetworkManager", + path, + QDBusConnection::systemBus(), + this + ); + + auto callback = [this, path, temp](uint value, const QDBusError& error) { + if (error.isValid()) { + qCWarning(logNetworkManager) << "Failed to get device type:" << error; + } else { + auto type = static_cast(value); + NMDevice* dev = nullptr; + this->mDevices.insert(path, nullptr); + + switch (type) { + case NMDeviceType::Wifi: dev = new NMWirelessDevice(path); break; + default: break; + } + + if (dev) { + qCDebug(logNetworkManager) << "Device added:" << path; + if (!dev->isValid()) { + qCWarning(logNetworkManager) << "Ignoring invalid registration of" << path; + delete dev; + } else { + this->mDevices[path] = dev; + // clang-format off + QObject::connect(dev, &NMDevice::addAndActivateConnection, this, &NetworkManager::addAndActivateConnection); + QObject::connect(dev, &NMDevice::activateConnection, this, &NetworkManager::activateConnection); + // clang-format on + + this->registerFrontendDevice(type, dev); + } + } else { + qCDebug(logNetworkManager) << "Ignoring registration of unsupported device:" << path; + } + temp->deleteLater(); + } + }; + + qs::dbus::asyncReadProperty(*temp, "DeviceType", callback); +} + +void NetworkManager::registerFrontendDevice(NMDeviceType::Enum type, NMDevice* dev) { + NetworkDevice* frontendDev = nullptr; + switch (type) { + case NMDeviceType::Wifi: { + auto* frontendWifiDev = new WifiDevice(dev); + auto* wifiDev = qobject_cast(dev); + // Bind WifiDevice-specific properties + auto translateMode = [wifiDev]() { + switch (wifiDev->mode()) { + case NM80211Mode::Unknown: return WifiDeviceMode::Unknown; + case NM80211Mode::Adhoc: return WifiDeviceMode::AdHoc; + case NM80211Mode::Infra: return WifiDeviceMode::Station; + case NM80211Mode::Ap: return WifiDeviceMode::AccessPoint; + case NM80211Mode::Mesh: return WifiDeviceMode::Mesh; + } + }; + // clang-format off + frontendWifiDev->bindableMode().setBinding(translateMode); + wifiDev->bindableScanning().setBinding([frontendWifiDev]() { return frontendWifiDev->scannerEnabled(); }); + QObject::connect(wifiDev, &NMWirelessDevice::networkAdded, frontendWifiDev, &WifiDevice::networkAdded); + QObject::connect(wifiDev, &NMWirelessDevice::networkRemoved, frontendWifiDev, &WifiDevice::networkRemoved); + // clang-format on + frontendDev = frontendWifiDev; + break; + } + default: return; + } + + // Bind generic NetworkDevice properties + auto translateState = [dev]() { + switch (dev->state()) { + case 0 ... 20: return ConnectionState::Unknown; + case 30: return ConnectionState::Disconnected; + case 40 ... 90: return ConnectionState::Connecting; + case 100: return ConnectionState::Connected; + case 110 ... 120: return ConnectionState::Disconnecting; + } + }; + // clang-format off + frontendDev->bindableName().setBinding([dev]() { return dev->interface(); }); + frontendDev->bindableAddress().setBinding([dev]() { return dev->hwAddress(); }); + frontendDev->bindableState().setBinding(translateState); + frontendDev->bindableAutoconnect().setBinding([dev]() { return dev->autoconnect(); }); + frontendDev->bindableNmManaged().setBinding([dev]() { return dev->managed(); }); + QObject::connect(frontendDev, &WifiDevice::requestDisconnect, dev, &NMDevice::disconnect); + QObject::connect(frontendDev, &NetworkDevice::requestSetAutoconnect, dev, &NMDevice::setAutoconnect); + QObject::connect(frontendDev, &NetworkDevice::requestSetNmManaged, dev, &NMDevice::setManaged); + // clang-format on + + this->mFrontendDevices.insert(dev->path(), frontendDev); + emit this->deviceAdded(frontendDev); +} + +void NetworkManager::removeFrontendDevice(NMDevice* dev) { + auto* frontendDev = this->mFrontendDevices.take(dev->path()); + if (frontendDev) { + emit this->deviceRemoved(frontendDev); + frontendDev->deleteLater(); + } +} + +void NetworkManager::onDevicePathAdded(const QDBusObjectPath& path) { + this->registerDevice(path.path()); +} + +void NetworkManager::onDevicePathRemoved(const QDBusObjectPath& path) { + auto iter = this->mDevices.find(path.path()); + if (iter == this->mDevices.end()) { + qCWarning(logNetworkManager) << "Sent removal signal for" << path.path() + << "which is not registered."; + } else { + auto* dev = iter.value(); + this->mDevices.erase(iter); + if (dev) { + qCDebug(logNetworkManager) << "Device removed:" << path.path(); + this->removeFrontendDevice(dev); + delete dev; + } + } +} + +void NetworkManager::activateConnection( + const QDBusObjectPath& connPath, + const QDBusObjectPath& devPath +) { + auto pending = this->proxy->ActivateConnection(connPath, devPath, QDBusObjectPath("/")); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + + if (reply.isError()) { + qCWarning(logNetworkManager) << "Failed to activate connection:" << reply.error().message(); + } + delete call; + }; + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void NetworkManager::addAndActivateConnection( + const NMSettingsMap& settings, + const QDBusObjectPath& devPath, + const QDBusObjectPath& specificObjectPath +) { + auto pending = this->proxy->AddAndActivateConnection(settings, devPath, specificObjectPath); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + + if (reply.isError()) { + qCWarning(logNetworkManager) + << "Failed to add and activate connection:" << reply.error().message(); + } + delete call; + }; + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void NetworkManager::setConnectivityCheckEnabled(bool enabled) { + if (enabled == this->bConnectivityCheckEnabled) return; + this->bConnectivityCheckEnabled = enabled; + this->pConnectivityCheckEnabled.write(); +} + +void NetworkManager::setWifiEnabled(bool enabled) { + if (enabled == this->bWifiEnabled) return; + this->bWifiEnabled = enabled; + this->pWifiEnabled.write(); +} + +bool NetworkManager::isAvailable() const { return this->proxy && this->proxy->isValid(); }; + +} // namespace qs::network + +namespace qs::dbus { + +DBusResult +DBusDataTransform::fromWire(quint32 wire) { + return DBusResult(static_cast(wire)); +} + +} // namespace qs::dbus diff --git a/src/network/nm/backend.hpp b/src/network/nm/backend.hpp new file mode 100644 index 0000000..2825a17 --- /dev/null +++ b/src/network/nm/backend.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../../dbus/properties.hpp" +#include "../network.hpp" +#include "dbus_nm_backend.h" +#include "dbus_types.hpp" +#include "device.hpp" +#include "enums.hpp" + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = quint32; + using Data = qs::network::NMConnectivityState::Enum; + static DBusResult fromWire(Wire wire); +}; + +} // namespace qs::dbus + +namespace qs::network { + +class NetworkManager: public NetworkBackend { + Q_OBJECT; + +public: + explicit NetworkManager(QObject* parent = nullptr); + + [[nodiscard]] bool isAvailable() const override; + [[nodiscard]] bool wifiEnabled() const { return this->bWifiEnabled; } + [[nodiscard]] bool wifiHardwareEnabled() const { return this->bWifiHardwareEnabled; } + [[nodiscard]] bool connectivityCheckAvailable() const { + return this->bConnectivityCheckAvailable; + }; + [[nodiscard]] bool connectivityCheckEnabled() const { return this->bConnectivityCheckEnabled; } + [[nodiscard]] NMConnectivityState::Enum connectivity() const { return this->bConnectivity; } + +signals: + void deviceAdded(NetworkDevice* device); + void deviceRemoved(NetworkDevice* device); + void wifiEnabledChanged(bool enabled); + void wifiHardwareEnabledChanged(bool enabled); + void connectivityStateChanged(NMConnectivityState::Enum state); + void connectivityCheckAvailableChanged(bool available); + void connectivityCheckEnabledChanged(bool enabled); + +public slots: + void setWifiEnabled(bool enabled); + void setConnectivityCheckEnabled(bool enabled); + void checkConnectivity(); + +private slots: + void onDevicePathAdded(const QDBusObjectPath& path); + void onDevicePathRemoved(const QDBusObjectPath& path); + void activateConnection(const QDBusObjectPath& connPath, const QDBusObjectPath& devPath); + void addAndActivateConnection( + const NMSettingsMap& settings, + const QDBusObjectPath& devPath, + const QDBusObjectPath& specificObjectPath + ); + +private: + void init(); + void registerDevices(); + void registerDevice(const QString& path); + void registerFrontendDevice(NMDeviceType::Enum type, NMDevice* dev); + void removeFrontendDevice(NMDevice* dev); + + QHash mDevices; + QHash mFrontendDevices; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(NetworkManager, bool, bWifiEnabled, &NetworkManager::wifiEnabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkManager, bool, bWifiHardwareEnabled, &NetworkManager::wifiHardwareEnabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkManager, NMConnectivityState::Enum, bConnectivity, &NetworkManager::connectivityStateChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkManager, bool, bConnectivityCheckAvailable, &NetworkManager::connectivityCheckAvailableChanged); + Q_OBJECT_BINDABLE_PROPERTY(NetworkManager, bool, bConnectivityCheckEnabled, &NetworkManager::connectivityCheckEnabledChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(NetworkManager, dbusProperties); + QS_DBUS_PROPERTY_BINDING(NetworkManager, pWifiEnabled, bWifiEnabled, dbusProperties, "WirelessEnabled"); + QS_DBUS_PROPERTY_BINDING(NetworkManager, pWifiHardwareEnabled, bWifiHardwareEnabled, dbusProperties, "WirelessHardwareEnabled"); + QS_DBUS_PROPERTY_BINDING(NetworkManager, pConnectivity, bConnectivity, dbusProperties, "Connectivity"); + QS_DBUS_PROPERTY_BINDING(NetworkManager, pConnectivityCheckAvailable, bConnectivityCheckAvailable, dbusProperties, "ConnectivityCheckAvailable"); + QS_DBUS_PROPERTY_BINDING(NetworkManager, pConnectivityCheckEnabled, bConnectivityCheckEnabled, dbusProperties, "ConnectivityCheckEnabled"); + // clang-format on + DBusNetworkManagerProxy* proxy = nullptr; +}; + +} // namespace qs::network diff --git a/src/network/nm/dbus_types.cpp b/src/network/nm/dbus_types.cpp new file mode 100644 index 0000000..e161f11 --- /dev/null +++ b/src/network/nm/dbus_types.cpp @@ -0,0 +1,69 @@ +#include "dbus_types.hpp" + +#include +#include +#include +#include +#include +#include + +namespace qs::network { + +const QDBusArgument& operator>>(const QDBusArgument& argument, NMSettingsMap& map) { + argument.beginMap(); + while (!argument.atEnd()) { + argument.beginMapEntry(); + QString groupName; + argument >> groupName; + + QVariantMap group; + argument >> group; + + map.insert(groupName, group); + argument.endMapEntry(); + } + argument.endMap(); + return argument; +} + +const QDBusArgument& operator<<(QDBusArgument& argument, const NMSettingsMap& map) { + argument.beginMap(qMetaTypeId(), qMetaTypeId()); + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + argument.beginMapEntry(); + argument << it.key(); + argument << it.value(); + argument.endMapEntry(); + } + argument.endMap(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, NMIPv6Address& addr) { + argument.beginStructure(); + argument >> addr.address >> addr.prefix >> addr.gateway; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator<<(QDBusArgument& argument, const NMIPv6Address& addr) { + argument.beginStructure(); + argument << addr.address << addr.prefix << addr.gateway; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, NMIPv6Route& route) { + argument.beginStructure(); + argument >> route.destination >> route.prefix >> route.nexthop >> route.metric; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator<<(QDBusArgument& argument, const NMIPv6Route& route) { + argument.beginStructure(); + argument << route.destination << route.prefix << route.nexthop << route.metric; + argument.endStructure(); + return argument; +} + +} // namespace qs::network diff --git a/src/network/nm/dbus_types.hpp b/src/network/nm/dbus_types.hpp new file mode 100644 index 0000000..bf428e5 --- /dev/null +++ b/src/network/nm/dbus_types.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace qs::network { + +using NMSettingsMap = QMap; + +const QDBusArgument& operator>>(const QDBusArgument& argument, NMSettingsMap& map); +const QDBusArgument& operator<<(QDBusArgument& argument, const NMSettingsMap& map); + +struct NMIPv6Address { + QByteArray address; + quint32 prefix = 0; + QByteArray gateway; +}; + +const QDBusArgument& operator>>(const QDBusArgument& argument, qs::network::NMIPv6Address& addr); +const QDBusArgument& operator<<(QDBusArgument& argument, const qs::network::NMIPv6Address& addr); + +struct NMIPv6Route { + QByteArray destination; + quint32 prefix = 0; + QByteArray nexthop; + quint32 metric = 0; +}; + +const QDBusArgument& operator>>(const QDBusArgument& argument, qs::network::NMIPv6Route& route); +const QDBusArgument& operator<<(QDBusArgument& argument, const qs::network::NMIPv6Route& route); + +} // namespace qs::network + +Q_DECLARE_METATYPE(qs::network::NMSettingsMap); +Q_DECLARE_METATYPE(qs::network::NMIPv6Address); +Q_DECLARE_METATYPE(qs::network::NMIPv6Route); diff --git a/src/network/nm/device.cpp b/src/network/nm/device.cpp new file mode 100644 index 0000000..1f229c8 --- /dev/null +++ b/src/network/nm/device.cpp @@ -0,0 +1,163 @@ +#include "device.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "../../dbus/properties.hpp" +#include "active_connection.hpp" +#include "dbus_nm_device.h" +#include "enums.hpp" +#include "settings.hpp" + +namespace qs::network { +using namespace qs::dbus; + +namespace { +QS_LOGGING_CATEGORY(logNetworkManager, "quickshell.network.networkmanager", QtWarningMsg); +} + +NMDevice::NMDevice(const QString& path, QObject* parent): QObject(parent) { + this->deviceProxy = new DBusNMDeviceProxy( + "org.freedesktop.NetworkManager", + path, + QDBusConnection::systemBus(), + this + ); + + if (!this->deviceProxy->isValid()) { + qCWarning(logNetworkManager) << "Cannot create DBus interface for device at" << path; + return; + } + + // clang-format off + QObject::connect(this, &NMDevice::availableSettingsPathsChanged, this, &NMDevice::onAvailableSettingsPathsChanged); + QObject::connect(this, &NMDevice::activeConnectionPathChanged, this, &NMDevice::onActiveConnectionPathChanged); + QObject::connect(this->deviceProxy, &DBusNMDeviceProxy::StateChanged, this, &NMDevice::onStateChanged); + // clang-format on + + this->deviceProperties.setInterface(this->deviceProxy); + this->deviceProperties.updateAllViaGetAll(); +} + +void NMDevice::onStateChanged(quint32 newState, quint32 /*oldState*/, quint32 reason) { + auto enumReason = static_cast(reason); + auto enumNewState = static_cast(newState); + if (enumNewState == NMDeviceState::Failed) this->bLastFailReason = enumReason; + if (this->bStateReason == enumReason) return; + this->bStateReason = enumReason; +} + +void NMDevice::onActiveConnectionPathChanged(const QDBusObjectPath& path) { + const QString stringPath = path.path(); + + // Remove old active connection + if (this->mActiveConnection) { + qCDebug(logNetworkManager) << "Active connection removed:" << this->mActiveConnection->path(); + QObject::disconnect(this->mActiveConnection, nullptr, this, nullptr); + delete this->mActiveConnection; + this->mActiveConnection = nullptr; + } + + // Create new active connection + if (stringPath != "/") { + auto* active = new NMActiveConnection(stringPath, this); + if (!active->isValid()) { + qCWarning(logNetworkManager) << "Ignoring invalid registration of" << stringPath; + delete active; + } else { + qCDebug(logNetworkManager) << "Active connection added:" << stringPath; + this->mActiveConnection = active; + QObject::connect( + active, + &NMActiveConnection::loaded, + this, + [this, active]() { emit this->activeConnectionLoaded(active); }, + Qt::SingleShotConnection + ); + } + } +} + +void NMDevice::onAvailableSettingsPathsChanged(const QList& paths) { + QSet newPathSet; + for (const QDBusObjectPath& path: paths) { + newPathSet.insert(path.path()); + } + const auto existingPaths = this->mSettings.keys(); + const QSet existingPathSet(existingPaths.begin(), existingPaths.end()); + + const auto addedSettings = newPathSet - existingPathSet; + const auto removedSettings = existingPathSet - newPathSet; + + for (const QString& path: addedSettings) { + this->registerSettings(path); + } + for (const QString& path: removedSettings) { + auto* connection = this->mSettings.take(path); + if (!connection) { + qCDebug(logNetworkManager) << "Sent removal signal for" << path << "which is not registered."; + } else { + qCDebug(logNetworkManager) << "Connection settings removed:" << path; + delete connection; + } + }; +} + +void NMDevice::registerSettings(const QString& path) { + auto* settings = new NMSettings(path, this); + if (!settings->isValid()) { + qCWarning(logNetworkManager) << "Ignoring invalid registration of" << path; + delete settings; + } else { + qCDebug(logNetworkManager) << "Connection settings added:" << path; + this->mSettings.insert(path, settings); + QObject::connect( + settings, + &NMSettings::loaded, + this, + [this, settings]() { emit this->settingsLoaded(settings); }, + Qt::SingleShotConnection + ); + } +} + +void NMDevice::disconnect() { this->deviceProxy->Disconnect(); } + +void NMDevice::setAutoconnect(bool autoconnect) { + if (autoconnect == this->bAutoconnect) return; + this->bAutoconnect = autoconnect; + this->pAutoconnect.write(); +} + +void NMDevice::setManaged(bool managed) { + if (managed == this->bManaged) return; + this->bManaged = managed; + this->pManaged.write(); +} + +bool NMDevice::isValid() const { return this->deviceProxy && this->deviceProxy->isValid(); } +QString NMDevice::address() const { + return this->deviceProxy ? this->deviceProxy->service() : QString(); +} +QString NMDevice::path() const { return this->deviceProxy ? this->deviceProxy->path() : QString(); } + +} // namespace qs::network + +namespace qs::dbus { + +DBusResult +DBusDataTransform::fromWire(quint32 wire) { + return DBusResult(static_cast(wire)); +} + +} // namespace qs::dbus diff --git a/src/network/nm/device.hpp b/src/network/nm/device.hpp new file mode 100644 index 0000000..963f574 --- /dev/null +++ b/src/network/nm/device.hpp @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../../dbus/properties.hpp" +#include "../enums.hpp" +#include "active_connection.hpp" +#include "dbus_nm_device.h" +#include "settings.hpp" + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = quint32; + using Data = qs::network::NMDeviceState::Enum; + static DBusResult fromWire(Wire wire); +}; + +} // namespace qs::dbus + +namespace qs::network { + +// Proxy of a /org/freedesktop/NetworkManager/Device/* object. +// Only the members from the org.freedesktop.NetworkManager.Device interface. +// Owns the lifetime of NMActiveConnection(s) and NMConnectionSetting(s). +class NMDevice: public QObject { + Q_OBJECT; + +public: + explicit NMDevice(const QString& path, QObject* parent = nullptr); + + [[nodiscard]] virtual bool isValid() const; + [[nodiscard]] QString path() const; + [[nodiscard]] QString address() const; + [[nodiscard]] QString interface() const { return this->bInterface; } + [[nodiscard]] QString hwAddress() const { return this->bHwAddress; } + [[nodiscard]] bool managed() const { return this->bManaged; } + [[nodiscard]] NMDeviceState::Enum state() const { return this->bState; } + [[nodiscard]] NMDeviceStateReason::Enum stateReason() const { return this->bStateReason; } + [[nodiscard]] NMDeviceStateReason::Enum lastFailReason() const { return this->bLastFailReason; } + [[nodiscard]] bool autoconnect() const { return this->bAutoconnect; } + [[nodiscard]] NMActiveConnection* activeConnection() const { return this->mActiveConnection; } + +signals: + void activateConnection(const QDBusObjectPath& connPath, const QDBusObjectPath& devPath); + void addAndActivateConnection( + const NMSettingsMap& settings, + const QDBusObjectPath& devPath, + const QDBusObjectPath& apPath + ); + void settingsLoaded(NMSettings* settings); + void settingsRemoved(NMSettings* settings); + void availableSettingsPathsChanged(QList paths); + void activeConnectionPathChanged(const QDBusObjectPath& connection); + void activeConnectionLoaded(NMActiveConnection* active); + void interfaceChanged(const QString& interface); + void hwAddressChanged(const QString& hwAddress); + void managedChanged(bool managed); + void stateChanged(NMDeviceState::Enum state); + void stateReasonChanged(NMDeviceStateReason::Enum reason); + void lastFailReasonChanged(NMDeviceStateReason::Enum reason); + void autoconnectChanged(bool autoconnect); + +public slots: + void disconnect(); + void setAutoconnect(bool autoconnect); + void setManaged(bool managed); + +private slots: + void onStateChanged(quint32 newState, quint32 oldState, quint32 reason); + void onAvailableSettingsPathsChanged(const QList& paths); + void onActiveConnectionPathChanged(const QDBusObjectPath& path); + +private: + void registerSettings(const QString& path); + + QHash mSettings; + NMActiveConnection* mActiveConnection = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, QString, bInterface, &NMDevice::interfaceChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, QString, bHwAddress, &NMDevice::hwAddressChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, bool, bManaged, &NMDevice::managedChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, NMDeviceState::Enum, bState, &NMDevice::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, NMDeviceStateReason::Enum, bStateReason, &NMDevice::stateReasonChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, NMDeviceStateReason::Enum, bLastFailReason, &NMDevice::lastFailReasonChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, bool, bAutoconnect, &NMDevice::autoconnectChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, QList, bAvailableConnections, &NMDevice::availableSettingsPathsChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMDevice, QDBusObjectPath, bActiveConnection, &NMDevice::activeConnectionPathChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(NMDeviceAdapter, deviceProperties); + QS_DBUS_PROPERTY_BINDING(NMDevice, pName, bInterface, deviceProperties, "Interface"); + QS_DBUS_PROPERTY_BINDING(NMDevice, pAddress, bHwAddress, deviceProperties, "HwAddress"); + QS_DBUS_PROPERTY_BINDING(NMDevice, pManaged, bManaged, deviceProperties, "Managed"); + QS_DBUS_PROPERTY_BINDING(NMDevice, pState, bState, deviceProperties, "State"); + QS_DBUS_PROPERTY_BINDING(NMDevice, pAutoconnect, bAutoconnect, deviceProperties, "Autoconnect"); + QS_DBUS_PROPERTY_BINDING(NMDevice, pAvailableConnections, bAvailableConnections, deviceProperties, "AvailableConnections"); + QS_DBUS_PROPERTY_BINDING(NMDevice, pActiveConnection, bActiveConnection, deviceProperties, "ActiveConnection"); + // clang-format on + + DBusNMDeviceProxy* deviceProxy = nullptr; +}; + +} // namespace qs::network diff --git a/src/network/nm/enums.hpp b/src/network/nm/enums.hpp new file mode 100644 index 0000000..18b1b8b --- /dev/null +++ b/src/network/nm/enums.hpp @@ -0,0 +1,314 @@ +#pragma once + +#include +#include +#include +#include + +namespace qs::network { + +// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMConnectivityState +class NMConnectivityState: public QObject { + Q_OBJECT; + +public: + enum Enum : quint8 { + Unknown = 0, + None = 1, + Portal = 2, + Limited = 3, + Full = 4, + }; +}; + +// Indicates the type of hardware represented by a device object. +class NMDeviceType: public QObject { + Q_OBJECT; + +public: + enum Enum : quint8 { + Unknown = 0, + Ethernet = 1, + Wifi = 2, + Unused1 = 3, + Unused2 = 4, + Bluetooth = 5, + OlpcMesh = 6, + Wimax = 7, + Modem = 8, + InfiniBand = 9, + Bond = 10, + Vlan = 11, + Adsl = 12, + Bridge = 13, + Generic = 14, + Team = 15, + Tun = 16, + IpTunnel = 17, + MacVlan = 18, + VxLan = 19, + Veth = 20, + MacSec = 21, + Dummy = 22, + Ppp = 23, + OvsInterface = 24, + OvsPort = 25, + OvsBridge = 26, + Wpan = 27, + Lowpan = 28, + Wireguard = 29, + WifiP2P = 30, + Vrf = 31, + Loopback = 32, + Hsr = 33, + IpVlan = 34, + }; + Q_ENUM(Enum); +}; + +// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceState. +class NMDeviceState: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + Unknown = 0, + Unmanaged = 10, + Unavailable = 20, + Disconnected = 30, + Prepare = 40, + Config = 50, + NeedAuth = 60, + IPConfig = 70, + IPCheck = 80, + Secondaries = 90, + Activated = 100, + Deactivating = 110, + Failed = 120, + }; + Q_ENUM(Enum); +}; + +// Device state change reason codes. +// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceStateReason. +class NMDeviceStateReason: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + None = 0, + Unknown = 1, + NowManaged = 2, + NowUnmanaged = 3, + ConfigFailed = 4, + IpConfigUnavailable = 5, + IpConfigExpired = 6, + NoSecrets = 7, + SupplicantDisconnect = 8, + SupplicantConfigFailed = 9, + SupplicantFailed = 10, + SupplicantTimeout = 11, + PppStartFailed = 12, + PppDisconnect = 13, + PppFailed = 14, + DhcpStartFailed = 15, + DhcpError = 16, + DhcpFailed = 17, + SharedStartFailed = 18, + SharedFailed = 19, + AutoIpStartFailed = 20, + AutoIpError = 21, + AutoIpFailed = 22, + ModemBusy = 23, + ModemNoDialTone = 24, + ModemNoCarrier = 25, + ModemDialTimeout = 26, + ModemDialFailed = 27, + ModemInitFailed = 28, + GsmApnFailed = 29, + GsmRegistrationNotSearching = 30, + GsmRegistrationDenied = 31, + GsmRegistrationTimeout = 32, + GsmRegistrationFailed = 33, + GsmPinCheckFailed = 34, + FirmwareMissing = 35, + Removed = 36, + Sleeping = 37, + ConnectionRemoved = 38, + UserRequested = 39, + Carrier = 40, + ConnectionAssumed = 41, + SupplicantAvailable = 42, + ModemNotFound = 43, + BtFailed = 44, + GsmSimNotInserted = 45, + GsmSimPinRequired = 46, + GsmSimPukRequired = 47, + GsmSimWrong = 48, + InfinibandMode = 49, + DependencyFailed = 50, + Br2684Failed = 51, + ModemManagerUnavailable = 52, + SsidNotFound = 53, + SecondaryConnectionFailed = 54, + DcbFcoeFailed = 55, + TeamdControlFailed = 56, + ModemFailed = 57, + ModemAvailable = 58, + SimPinIncorrect = 59, + NewActivation = 60, + ParentChanged = 61, + ParentManagedChanged = 62, + OvsdbFailed = 63, + IpAddressDuplicate = 64, + IpMethodUnsupported = 65, + SriovConfigurationFailed = 66, + PeerNotFound = 67, + DeviceHandlerFailed = 68, + UnmanagedByDefault = 69, + UnmanagedExternalDown = 70, + UnmanagedLinkNotInit = 71, + UnmanagedQuitting = 72, + UnmanagedManagerDisabled = 73, + UnmanagedUserConf = 74, + UnmanagedUserExplicit = 75, + UnmanagedUserSettings = 76, + UnmanagedUserUdev = 77, + NetworkingOff = 78, + }; + Q_ENUM(Enum); +}; + +// 802.11 specific device encryption and authentication capabilities. +// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceWifiCapabilities. +class NMWirelessCapabilities: public QObject { + Q_OBJECT; + +public: + enum Enum : quint16 { + None = 0, + CipherWep40 = 1, + CipherWep104 = 2, + CipherTkip = 4, + CipherCcmp = 8, + Wpa = 16, + Rsn = 32, + Ap = 64, + Adhoc = 128, + FreqValid = 256, + Freq2Ghz = 512, + Freq5Ghz = 1024, + Freq6Ghz = 2048, + Mesh = 4096, + IbssRsn = 8192, + }; + Q_ENUM(Enum); +}; + +// Indicates the 802.11 mode an access point is currently in. +// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NM80211Mode. +class NM80211Mode: public QObject { + Q_OBJECT; + +public: + enum Enum : quint8 { + Unknown = 0, + Adhoc = 1, + Infra = 2, + Ap = 3, + Mesh = 4, + }; + Q_ENUM(Enum); +}; + +// 802.11 access point flags. +// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NM80211ApSecurityFlags. +class NM80211ApFlags: public QObject { + Q_OBJECT; + +public: + enum Enum : quint8 { + None = 0, + Privacy = 1, + Wps = 2, + WpsPbc = 4, + WpsPin = 8, + }; + Q_ENUM(Enum); +}; + +// 802.11 access point security and authentication flags. +// These flags describe the current system requirements of an access point as determined from the access point's beacon. +// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NM80211ApSecurityFlags. +class NM80211ApSecurityFlags: public QObject { + Q_OBJECT; + +public: + enum Enum : quint16 { + None = 0, + PairWep40 = 1, + PairWep104 = 2, + PairTkip = 4, + PairCcmp = 8, + GroupWep40 = 16, + GroupWep104 = 32, + GroupTkip = 64, + GroupCcmp = 128, + KeyMgmtPsk = 256, + KeyMgmt8021x = 512, + KeyMgmtSae = 1024, + KeyMgmtOwe = 2048, + KeyMgmtOweTm = 4096, + KeyMgmtEapSuiteB192 = 8192, + }; + Q_ENUM(Enum); +}; + +// Indicates the state of a connection to a specific network while it is starting, connected, or disconnected from that network. +// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState. +class NMConnectionState: public QObject { + Q_OBJECT; + +public: + enum Enum : quint8 { + Unknown = 0, + Activating = 1, + Activated = 2, + Deactivating = 3, + Deactivated = 4 + }; + Q_ENUM(Enum); +}; + +/// In sync with https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionStateReason. +class NMConnectionStateReason: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum : quint8 { + Unknown = 0, + None = 1, + UserDisconnected = 2, + DeviceDisconnected = 3, + ServiceStopped = 4, + IpConfigInvalid = 5, + ConnectTimeout = 6, + ServiceStartTimeout = 7, + ServiceStartFailed = 8, + NoSecrets = 9, + LoginFailed = 10, + ConnectionRemoved = 11, + DependencyFailed = 12, + DeviceRealizeFailed = 13, + DeviceRemoved = 14 + }; + Q_ENUM(Enum); +}; + +} // namespace qs::network diff --git a/src/network/nm/org.freedesktop.NetworkManager.AccessPoint.xml b/src/network/nm/org.freedesktop.NetworkManager.AccessPoint.xml new file mode 100644 index 0000000..c5e7737 --- /dev/null +++ b/src/network/nm/org.freedesktop.NetworkManager.AccessPoint.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/network/nm/org.freedesktop.NetworkManager.Connection.Active.xml b/src/network/nm/org.freedesktop.NetworkManager.Connection.Active.xml new file mode 100644 index 0000000..fa0e778 --- /dev/null +++ b/src/network/nm/org.freedesktop.NetworkManager.Connection.Active.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/network/nm/org.freedesktop.NetworkManager.Device.Wireless.xml b/src/network/nm/org.freedesktop.NetworkManager.Device.Wireless.xml new file mode 100644 index 0000000..ccfe333 --- /dev/null +++ b/src/network/nm/org.freedesktop.NetworkManager.Device.Wireless.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/network/nm/org.freedesktop.NetworkManager.Device.xml b/src/network/nm/org.freedesktop.NetworkManager.Device.xml new file mode 100644 index 0000000..414d24f --- /dev/null +++ b/src/network/nm/org.freedesktop.NetworkManager.Device.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/network/nm/org.freedesktop.NetworkManager.Settings.Connection.xml b/src/network/nm/org.freedesktop.NetworkManager.Settings.Connection.xml new file mode 100644 index 0000000..81419b9 --- /dev/null +++ b/src/network/nm/org.freedesktop.NetworkManager.Settings.Connection.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/network/nm/org.freedesktop.NetworkManager.xml b/src/network/nm/org.freedesktop.NetworkManager.xml new file mode 100644 index 0000000..75c314a --- /dev/null +++ b/src/network/nm/org.freedesktop.NetworkManager.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/network/nm/settings.cpp b/src/network/nm/settings.cpp new file mode 100644 index 0000000..af36dae --- /dev/null +++ b/src/network/nm/settings.cpp @@ -0,0 +1,227 @@ +#include "settings.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "../../dbus/properties.hpp" +#include "dbus_nm_connection_settings.h" +#include "dbus_types.hpp" +#include "utils.hpp" + +namespace qs::network { +using namespace qs::dbus; + +namespace { +QS_LOGGING_CATEGORY(logNetworkManager, "quickshell.network", QtWarningMsg); +QS_LOGGING_CATEGORY(logNMSettings, "quickshell.network.nm_settings", QtWarningMsg); +} // namespace + +NMSettings::NMSettings(const QString& path, QObject* parent): QObject(parent) { + qDBusRegisterMetaType>(); + qDBusRegisterMetaType>(); + qDBusRegisterMetaType>>(); + qDBusRegisterMetaType>(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType>(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType>(); + + this->proxy = new DBusNMConnectionSettingsProxy( + "org.freedesktop.NetworkManager", + path, + QDBusConnection::systemBus(), + this + ); + + if (!this->proxy->isValid()) { + qCWarning(logNetworkManager) << "Cannot create DBus interface for connection settings at" + << path; + return; + } + + QObject::connect( + this->proxy, + &DBusNMConnectionSettingsProxy::Updated, + this, + &NMSettings::getSettings + ); + + this->bId.setBinding([this]() { return this->bSettings.value()["connection"]["id"].toString(); }); + this->bUuid.setBinding([this]() { + return this->bSettings.value()["connection"]["uuid"].toString(); + }); + + this->settingsProperties.setInterface(this->proxy); + this->settingsProperties.updateAllViaGetAll(); + + this->getSettings(); +} + +void NMSettings::getSettings() { + auto pending = this->proxy->GetSettings(); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [this](QDBusPendingCallWatcher* call) { + const QDBusPendingReply reply = *call; + + if (reply.isError()) { + qCWarning(logNetworkManager) + << "Failed to get settings for" << this->path() << ":" << reply.error().message(); + } else { + auto settings = reply.value(); + manualSettingDemarshall(settings); + this->bSettings = settings; + qCDebug(logNetworkManager) << "Settings map updated for" << this->path(); + + if (!this->mLoaded) { + emit this->loaded(); + this->mLoaded = true; + } + }; + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +QDBusPendingCallWatcher* NMSettings::updateSettings( + const NMSettingsMap& settingsToChange, + const NMSettingsMap& settingsToRemove +) { + auto settings = removeSettingsInMap(this->bSettings, settingsToRemove); + settings = mergeSettingsMaps(settings, settingsToChange); + auto pending = this->proxy->Update(settings); + auto* call = new QDBusPendingCallWatcher(pending, this); + + return call; +} + +void NMSettings::clearSecrets() { + auto pending = this->proxy->ClearSecrets(); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [this](QDBusPendingCallWatcher* call) { + const QDBusPendingReply<> reply = *call; + + if (reply.isError()) { + qCWarning(logNetworkManager) + << "Failed to clear secrets for" << this->path() << ":" << reply.error().message(); + } else { + qCDebug(logNetworkManager) << "Cleared secrets for" << this->path(); + } + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void NMSettings::forget() { + auto pending = this->proxy->Delete(); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [this](QDBusPendingCallWatcher* call) { + const QDBusPendingReply<> reply = *call; + + if (reply.isError()) { + qCWarning(logNetworkManager) + << "Failed to forget" << this->path() << ":" << reply.error().message(); + } else { + qCDebug(logNetworkManager) << "Successfully deletion of" << this->path(); + } + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +QVariantMap NMSettings::read() { + QVariantMap result; + const auto& settings = this->bSettings.value(); + for (auto it = settings.constBegin(); it != settings.constEnd(); ++it) { + QVariantMap group; + for (auto jt = it.value().constBegin(); jt != it.value().constEnd(); ++jt) { + group.insert(jt.key(), settingTypeToQml(jt.value())); + } + result.insert(it.key(), group); + } + return result; +} + +void NMSettings::write(const QVariantMap& settings) { + NMSettingsMap changedSettings; + NMSettingsMap removedSettings; + QStringList failedSettings; + + for (auto it = settings.constBegin(); it != settings.constEnd(); ++it) { + if (!it.value().canConvert()) continue; + + auto group = it.value().toMap(); + QVariantMap toChange; + QVariantMap toRemove; + for (auto jt = group.constBegin(); jt != group.constEnd(); ++jt) { + if (jt.value().isNull()) { + toRemove.insert(jt.key(), QVariant()); + } else { + auto converted = settingTypeFromQml(it.key(), jt.key(), jt.value()); + if (!converted.isValid()) failedSettings.append(it.key() + "." + jt.key()); + else toChange.insert(jt.key(), converted); + } + } + if (!toChange.isEmpty()) changedSettings.insert(it.key(), toChange); + if (!toRemove.isEmpty()) removedSettings.insert(it.key(), toRemove); + } + + if (!failedSettings.isEmpty()) { + qCWarning(logNMSettings) << "A write to" << this + << "has received bad types for the following settings:" + << failedSettings.join(", "); + } + + auto* call = this->updateSettings(changedSettings, removedSettings); + + auto responseCallback = [this](QDBusPendingCallWatcher* call) { + const QDBusPendingReply<> reply = *call; + + if (reply.isError()) { + qCWarning(logNetworkManager) + << "Failed to update settings for" << this->path() << ":" << reply.error().message(); + } else { + qCDebug(logNMSettings) << "Successful write to" << this; + } + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +bool NMSettings::isValid() const { return this->proxy && this->proxy->isValid(); } +QString NMSettings::address() const { return this->proxy ? this->proxy->service() : QString(); } +QString NMSettings::path() const { return this->proxy ? this->proxy->path() : QString(); } + +} // namespace qs::network + +QDebug operator<<(QDebug debug, const qs::network::NMSettings* settings) { + auto saver = QDebugStateSaver(debug); + + if (settings) { + debug.nospace() << "NMSettings(" << static_cast(settings) + << ", uuid=" << settings->uuid() << ")"; + } else { + debug << "WifiNetwork(nullptr)"; + } + + return debug; +} diff --git a/src/network/nm/settings.hpp b/src/network/nm/settings.hpp new file mode 100644 index 0000000..3a76c61 --- /dev/null +++ b/src/network/nm/settings.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../dbus/properties.hpp" +#include "dbus_nm_connection_settings.h" +#include "dbus_types.hpp" + +namespace qs::network { + +// Proxy of a /org/freedesktop/NetworkManager/Settings/Connection/* object. +///! A NetworkManager connection settings profile. +class NMSettings: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + + /// The human-readable unique identifier for the connection. + Q_PROPERTY(QString id READ default NOTIFY idChanged BINDABLE bindableId); + /// A universally unique identifier for the connection. + Q_PROPERTY(QString uuid READ uuid NOTIFY uuidChanged BINDABLE bindableUuid); + +public: + explicit NMSettings(const QString& path, QObject* parent = nullptr); + + /// Clear all of the secrets belonging to the settings. + Q_INVOKABLE void clearSecrets(); + /// Delete the settings. + Q_INVOKABLE void forget(); + /// Update the connection with new settings and save the connection to disk. + /// Only changed fields need to be included. + /// Writing a setting to `null` will remove the setting or reset it to its default. + /// + /// > [!NOTE] Secrets may be part of the update request, + /// > and will be either stored in persistent storage or sent to a Secret Agent for storage, + /// > depending on the flags associated with each secret. + Q_INVOKABLE void write(const QVariantMap& settings); + /// Get the settings map describing this network configuration. + /// + /// > [!NOTE] This will never include any secrets required for connection to the network, as those are often protected. + Q_INVOKABLE QVariantMap read(); + + [[nodiscard]] bool isValid() const; + [[nodiscard]] QString path() const; + [[nodiscard]] QString address() const; + [[nodiscard]] NMSettingsMap map() { return this->bSettings; } + QDBusPendingCallWatcher* + updateSettings(const NMSettingsMap& settingsToChange, const NMSettingsMap& settingsToRemove = {}); + QBindable bindableId() { return &this->bId; } + [[nodiscard]] QString uuid() const { return this->bUuid; } + QBindable bindableUuid() { return &this->bUuid; } + +signals: + void loaded(); + void settingsChanged(NMSettingsMap settings); + void idChanged(QString id); + void uuidChanged(QString uuid); + +private: + bool mLoaded = false; + + void getSettings(); + + Q_OBJECT_BINDABLE_PROPERTY(NMSettings, NMSettingsMap, bSettings, &NMSettings::settingsChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMSettings, QString, bId, &NMSettings::idChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMSettings, QString, bUuid, &NMSettings::uuidChanged); + QS_DBUS_BINDABLE_PROPERTY_GROUP(NMSettings, settingsProperties); + DBusNMConnectionSettingsProxy* proxy = nullptr; +}; + +} // namespace qs::network + +QDebug operator<<(QDebug debug, const qs::network::NMSettings* settings); diff --git a/src/network/nm/utils.cpp b/src/network/nm/utils.cpp new file mode 100644 index 0000000..afdc796 --- /dev/null +++ b/src/network/nm/utils.cpp @@ -0,0 +1,525 @@ +#include "utils.hpp" +#include +// We depend on non-std Linux extensions that ctime doesn't put in the global namespace +// NOLINTNEXTLINE(modernize-deprecated-headers) +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "../enums.hpp" +#include "dbus_types.hpp" +#include "enums.hpp" + +namespace qs::network { + +WifiSecurityType::Enum securityFromSettingsMap(const NMSettingsMap& settings) { + const QString mapName = "802-11-wireless-security"; + if (!settings.contains(mapName)) return WifiSecurityType::Unknown; + const QVariantMap& security = settings.value(mapName); + if (security.isEmpty()) return WifiSecurityType::Open; + + const QString keyMgmt = security["key-mgmt"].toString(); + const QString authAlg = security["auth-alg"].toString(); + const QList proto = security["proto"].toList(); + + if (keyMgmt == "none") { + return WifiSecurityType::StaticWep; + } else if (keyMgmt == "ieee8021x") { + if (authAlg == "leap") { + return WifiSecurityType::Leap; + } else { + return WifiSecurityType::DynamicWep; + } + } else if (keyMgmt == "wpa-psk") { + if (proto.contains("wpa") && proto.contains("rsn")) return WifiSecurityType::WpaPsk; + return WifiSecurityType::Wpa2Psk; + } else if (keyMgmt == "wpa-eap") { + if (proto.contains("wpa") && proto.contains("rsn")) return WifiSecurityType::WpaEap; + return WifiSecurityType::Wpa2Eap; + } else if (keyMgmt == "sae") { + return WifiSecurityType::Sae; + } else if (keyMgmt == "wpa-eap-suite-b-192") { + return WifiSecurityType::Wpa3SuiteB192; + } else if (keyMgmt == "owe") { + return WifiSecurityType::Owe; + } + return WifiSecurityType::Open; +} + +bool deviceSupportsApCiphers( + NMWirelessCapabilities::Enum caps, + NM80211ApSecurityFlags::Enum apFlags, + WifiSecurityType::Enum type +) { + bool havePair = false; + bool haveGroup = false; + // Device needs to support at least one pairwise and one group cipher + + if (type == WifiSecurityType::StaticWep) { + // Static WEP only uses group ciphers + havePair = true; + } else { + if (caps & NMWirelessCapabilities::CipherWep40 && apFlags & NM80211ApSecurityFlags::PairWep40) { + havePair = true; + } + if (caps & NMWirelessCapabilities::CipherWep104 && apFlags & NM80211ApSecurityFlags::PairWep104) + { + havePair = true; + } + if (caps & NMWirelessCapabilities::CipherTkip && apFlags & NM80211ApSecurityFlags::PairTkip) { + havePair = true; + } + if (caps & NMWirelessCapabilities::CipherCcmp && apFlags & NM80211ApSecurityFlags::PairCcmp) { + havePair = true; + } + } + + if (caps & NMWirelessCapabilities::CipherWep40 && apFlags & NM80211ApSecurityFlags::GroupWep40) { + haveGroup = true; + } + if (caps & NMWirelessCapabilities::CipherWep104 && apFlags & NM80211ApSecurityFlags::GroupWep104) + { + haveGroup = true; + } + if (type != WifiSecurityType::StaticWep) { + if (caps & NMWirelessCapabilities::CipherTkip && apFlags & NM80211ApSecurityFlags::GroupTkip) { + haveGroup = true; + } + if (caps & NMWirelessCapabilities::CipherCcmp && apFlags & NM80211ApSecurityFlags::GroupCcmp) { + haveGroup = true; + } + } + + return (havePair && haveGroup); +} + +bool securityIsValid( + WifiSecurityType::Enum type, + NMWirelessCapabilities::Enum caps, + bool adhoc, + NM80211ApFlags::Enum apFlags, + NM80211ApSecurityFlags::Enum apWpa, + NM80211ApSecurityFlags::Enum apRsn +) { + switch (type) { + case WifiSecurityType::Open: + if (apFlags & NM80211ApFlags::Privacy) return false; + if (apWpa || apRsn) return false; + break; + case WifiSecurityType::Leap: + if (adhoc) return false; + case WifiSecurityType::StaticWep: + if (!(apFlags & NM80211ApFlags::Privacy)) return false; + if (apWpa || apRsn) { + if (!deviceSupportsApCiphers(caps, apWpa, WifiSecurityType::StaticWep)) { + if (!deviceSupportsApCiphers(caps, apRsn, WifiSecurityType::StaticWep)) return false; + } + } + break; + case WifiSecurityType::DynamicWep: + if (adhoc) return false; + if (apRsn || !(apFlags & NM80211ApFlags::Privacy)) return false; + if (apWpa) { + if (!(apWpa & NM80211ApSecurityFlags::KeyMgmt8021x)) return false; + if (!deviceSupportsApCiphers(caps, apWpa, WifiSecurityType::DynamicWep)) return false; + } + break; + case WifiSecurityType::WpaPsk: + if (adhoc) return false; + if (!(caps & NMWirelessCapabilities::Wpa)) return false; + if (apWpa & NM80211ApSecurityFlags::KeyMgmtPsk) { + if (apWpa & NM80211ApSecurityFlags::PairTkip && caps & NMWirelessCapabilities::CipherTkip) { + return true; + } + if (apWpa & NM80211ApSecurityFlags::PairCcmp && caps & NMWirelessCapabilities::CipherCcmp) { + return true; + } + } + return false; + case WifiSecurityType::Wpa2Psk: + if (!(caps & NMWirelessCapabilities::Rsn)) return false; + if (adhoc) { + if (!(caps & NMWirelessCapabilities::IbssRsn)) return false; + if (apRsn & NM80211ApSecurityFlags::PairCcmp && caps & NMWirelessCapabilities::CipherCcmp) { + return true; + } + } else { + if (apRsn & NM80211ApSecurityFlags::KeyMgmtPsk) { + if (apRsn & NM80211ApSecurityFlags::PairTkip && caps & NMWirelessCapabilities::CipherTkip) { + return true; + } + if (apRsn & NM80211ApSecurityFlags::PairCcmp && caps & NMWirelessCapabilities::CipherCcmp) { + return true; + } + } + } + return false; + case WifiSecurityType::WpaEap: + if (adhoc) return false; + if (!(caps & NMWirelessCapabilities::Wpa)) return false; + if (!(apWpa & NM80211ApSecurityFlags::KeyMgmt8021x)) return false; + if (!deviceSupportsApCiphers(caps, apWpa, WifiSecurityType::WpaEap)) return false; + break; + case WifiSecurityType::Wpa2Eap: + if (adhoc) return false; + if (!(caps & NMWirelessCapabilities::Rsn)) return false; + if (!(apRsn & NM80211ApSecurityFlags::KeyMgmt8021x)) return false; + if (!deviceSupportsApCiphers(caps, apRsn, WifiSecurityType::Wpa2Eap)) return false; + break; + case WifiSecurityType::Sae: + if (!(caps & NMWirelessCapabilities::Rsn)) return false; + if (adhoc) { + if (!(caps & NMWirelessCapabilities::IbssRsn)) return false; + if (apRsn & NM80211ApSecurityFlags::PairCcmp && caps & NMWirelessCapabilities::CipherCcmp) { + return true; + } + } else { + if (apRsn & NM80211ApSecurityFlags::KeyMgmtSae) { + if (apRsn & NM80211ApSecurityFlags::PairTkip && caps & NMWirelessCapabilities::CipherTkip) { + return true; + } + if (apRsn & NM80211ApSecurityFlags::PairCcmp && caps & NMWirelessCapabilities::CipherCcmp) { + return true; + } + } + } + return false; + case WifiSecurityType::Owe: + if (adhoc) return false; + if (!(caps & NMWirelessCapabilities::Rsn)) return false; + if (!(apRsn & NM80211ApSecurityFlags::KeyMgmtOwe) + && !(apRsn & NM80211ApSecurityFlags::KeyMgmtOweTm)) + { + return false; + } + break; + case WifiSecurityType::Wpa3SuiteB192: + if (adhoc) return false; + if (!(caps & NMWirelessCapabilities::Rsn)) return false; + if (!(apRsn & NM80211ApSecurityFlags::KeyMgmtEapSuiteB192)) return false; + break; + default: return false; + } + return true; +} + +WifiSecurityType::Enum findBestWirelessSecurity( + NMWirelessCapabilities::Enum caps, + bool adHoc, + NM80211ApFlags::Enum apFlags, + NM80211ApSecurityFlags::Enum apWpa, + NM80211ApSecurityFlags::Enum apRsn +) { + // Loop through security types from most to least secure since the enum + // values are sequential and in priority order (0-10, excluding Unknown=11) + for (int i = WifiSecurityType::Wpa3SuiteB192; i <= WifiSecurityType::Open; ++i) { + auto type = static_cast(i); + if (securityIsValid(type, caps, adHoc, apFlags, apWpa, apRsn)) { + return type; + } + } + return WifiSecurityType::Unknown; +} + +NMSettingsMap mergeSettingsMaps(const NMSettingsMap& target, const NMSettingsMap& source) { + NMSettingsMap result = target; + for (auto iter = source.constBegin(); iter != source.constEnd(); ++iter) { + result[iter.key()].insert(iter.value()); + } + return result; +} + +NMSettingsMap removeSettingsInMap(const NMSettingsMap& target, const NMSettingsMap& toRemove) { + NMSettingsMap result = target; + for (auto iter = toRemove.constBegin(); iter != toRemove.constEnd(); ++iter) { + const QString& group = iter.key(); + const QVariantMap& keysToRemove = iter.value(); + + if (!result.contains(group)) continue; + + for (auto jt = keysToRemove.constBegin(); jt != keysToRemove.constEnd(); ++jt) { + result[group].remove(jt.key()); + } + + // Remove the group entirely if it's now empty + if (result[group].isEmpty()) { + result.remove(group); + } + } + return result; +} + +// Some NMSettingsMap settings remain QDBusArguments after autodemarshalling. +// Manually demarshall these for any complex signature we have registered. +void manualSettingDemarshall(NMSettingsMap& map) { + auto demarshallValue = [](const QVariant& value) -> QVariant { + if (value.userType() != qMetaTypeId()) { + return value; + } + + auto arg = value.value(); + auto signature = arg.currentSignature(); + + if (signature == "ay") return QVariant::fromValue(qdbus_cast(arg)); + if (signature == "aay") return QVariant::fromValue(qdbus_cast>(arg)); + if (signature == "au") return QVariant::fromValue(qdbus_cast>(arg)); + if (signature == "aau") return QVariant::fromValue(qdbus_cast>>(arg)); + if (signature == "aa{sv}") return QVariant::fromValue(qdbus_cast>(arg)); + if (signature == "a(ayuay)") return QVariant::fromValue(qdbus_cast>(arg)); + if (signature == "a(ayuayu)") return QVariant::fromValue(qdbus_cast>(arg)); + + return value; + }; + + for (auto it = map.begin(); it != map.end(); ++it) + for (auto jt = it.value().begin(); jt != it.value().end(); ++jt) + jt.value() = demarshallValue(jt.value()); +} + +// Some NMSettingsMap setting types can't be expressed in QML. +// Convert these settings to their correct type or return an invalid QVariant. +QVariant settingTypeFromQml(const QString& group, const QString& key, const QVariant& value) { + auto s = group + "." + key; + + // QString -> QByteArray + if (s == "802-1x.ca-cert" || s == "802-1x.client-cert" || s == "802-1x.private-key" + || s == "802-1x.password-raw" || s == "802-1x.phase2-ca-cert" + || s == "802-1x.phase2-client-cert" || s == "802-1x.phase2-private-key" + || s == "802-11-wireless.ssid") + { + if (value.typeId() == QMetaType::QString) { + return value.toString().toUtf8(); + } + if (value.typeId() == QMetaType::QByteArray) { + return value; + } + return QVariant(); + } + + // QVariantList -> QList + if (s == "ipv6.dns") { + if (value.typeId() == QMetaType::QVariantList) { + QList r; + for (const auto& v: value.toList()) { + if (v.typeId() == QMetaType::QString) { + r.append(v.toString().toUtf8()); + } else { + r.append(v.toByteArray()); + } + } + return QVariant::fromValue(r); + } + return QVariant(); + } + + // QVariantList -> QList + if (s == "ipv4.dns") { + if (value.typeId() == QMetaType::QVariantList) { + QList r; + for (const auto& v: value.toList()) { + r.append(v.value()); + } + return QVariant::fromValue(r); + } + return QVariant(); + } + + // QVariantList -> QList> + if (s == "ipv4.addresses" || s == "ipv4.routes") { + if (value.typeId() == QMetaType::QVariantList) { + QList> r; + for (const auto& v: value.toList()) { + if (v.typeId() != QMetaType::QVariantList) { + continue; + } + QList inner; + for (const auto& u: v.toList()) { + inner.append(u.value()); + } + r.append(inner); + } + return QVariant::fromValue(r); + } + return QVariant(); + } + + // QVariantList -> QList + if (s == "ipv4.address-data" || s == "ipv4.route-data" || s == "ipv4.routing-rules" + || s == "ipv6.address-data" || s == "ipv6.route-data" || s == "ipv6.routing-rules") + { + if (value.typeId() == QMetaType::QVariantList) { + QList r; + for (const auto& v: value.toList()) { + if (!v.canConvert()) { + continue; + } + r.append(v.toMap()); + } + return QVariant::fromValue(r); + } + return QVariant(); + } + + // QVariantList -> QList + if (s == "ipv6.addresses") { + if (value.typeId() == QMetaType::QVariantList) { + QList r; + for (const auto& v: value.toList()) { + if (v.typeId() != QMetaType::QVariantList) { + continue; + } + auto fields = v.toList(); + if (fields.size() != 3) { + continue; + } + const QByteArray address = fields[0].typeId() == QMetaType::QString + ? fields[0].toString().toUtf8() + : fields[0].toByteArray(); + const QByteArray gateway = fields[2].typeId() == QMetaType::QString + ? fields[2].toString().toUtf8() + : fields[2].toByteArray(); + r.append({.address = address, .prefix = fields[1].value(), .gateway = gateway}); + } + return QVariant::fromValue(r); + } + return QVariant(); + } + + // QVariantList -> QList + if (s == "ipv6.routes") { + if (value.typeId() == QMetaType::QVariantList) { + QList r; + for (const auto& v: value.toList()) { + if (v.typeId() != QMetaType::QVariantList) { + continue; + } + auto fields = v.toList(); + if (fields.size() != 4) { + continue; + } + const QByteArray destination = fields[0].typeId() == QMetaType::QString + ? fields[0].toString().toUtf8() + : fields[0].toByteArray(); + const QByteArray nexthop = fields[2].typeId() == QMetaType::QString + ? fields[2].toString().toUtf8() + : fields[2].toByteArray(); + r.append( + {.destination = destination, + .prefix = fields[1].value(), + .nexthop = nexthop, + .metric = fields[3].value()} + ); + } + return QVariant::fromValue(r); + } + return QVariant(); + } + + // QVariantList -> QStringList + if (s == "connection.permissions" || s == "ipv4.dns-search" || s == "ipv6.dns-search" + || s == "802-11-wireless.seen-bssids") + { + if (value.typeId() == QMetaType::QVariantList) { + QStringList stringList; + for (const auto& item: value.toList()) { + stringList.append(item.toString()); + } + return stringList; + } + return QVariant(); + } + + // double (whole number) -> qint32 + if (value.typeId() == QMetaType::Double) { + auto num = value.toDouble(); + if (std::isfinite(num) && num == std::trunc(num)) { + return QVariant::fromValue(static_cast(num)); + } + } + + return value; +} + +// Some NMSettingsMap setting types must be converted to a type that is supported by QML. +// Although QByteArrays can be represented in QML, we convert them to strings for convenience. +QVariant settingTypeToQml(const QVariant& value) { + // QByteArray -> QString + if (value.typeId() == QMetaType::QByteArray) { + return QString::fromUtf8(value.toByteArray()); + } + + // QList -> QVariantList + if (value.userType() == qMetaTypeId>()) { + QVariantList out; + for (const auto& ba: value.value>()) { + out.append(QString::fromUtf8(ba)); + } + return out; + } + + // QList -> QVariantList + if (value.userType() == qMetaTypeId>()) { + QVariantList out; + for (const auto& addr: value.value>()) { + out.append( + QVariant::fromValue( + QVariantList { + QString::fromUtf8(addr.address), + addr.prefix, + QString::fromUtf8(addr.gateway), + } + ) + ); + } + return out; + } + + // QList -> QVariantList + if (value.userType() == qMetaTypeId>()) { + QVariantList out; + for (const auto& route: value.value>()) { + out.append( + QVariant::fromValue( + QVariantList { + QString::fromUtf8(route.destination), + route.prefix, + QString::fromUtf8(route.nexthop), + route.metric, + } + ) + ); + } + return out; + } + + return value; +} + +// NOLINTBEGIN +QDateTime clockBootTimeToDateTime(qint64 clockBootTime) { + clockid_t clkId = CLOCK_BOOTTIME; + struct timespec tp {}; + + const QDateTime now = QDateTime::currentDateTime(); + int r = clock_gettime(clkId, &tp); + if (r == -1 && errno == EINVAL) { + clkId = CLOCK_MONOTONIC; + r = clock_gettime(clkId, &tp); + } + + // Convert to milliseconds + const qint64 nowInMs = tp.tv_sec * 1000 + tp.tv_nsec / 1000000; + + // Return a QDateTime of the millisecond diff + const qint64 offset = clockBootTime - nowInMs; + return QDateTime::fromMSecsSinceEpoch(now.toMSecsSinceEpoch() + offset); +} +// NOLINTEND + +} // namespace qs::network diff --git a/src/network/nm/utils.hpp b/src/network/nm/utils.hpp new file mode 100644 index 0000000..8c51423 --- /dev/null +++ b/src/network/nm/utils.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +#include "../enums.hpp" +#include "dbus_types.hpp" +#include "enums.hpp" + +namespace qs::network { + +WifiSecurityType::Enum securityFromSettingsMap(const NMSettingsMap& settings); + +bool deviceSupportsApCiphers( + NMWirelessCapabilities::Enum caps, + NM80211ApSecurityFlags::Enum apFlags, + WifiSecurityType::Enum type +); + +// In sync with NetworkManager/libnm-core/nm-utils.c:nm_utils_security_valid() +// Given a set of device capabilities, and a desired security type to check +// against, determines whether the combination of device, desired security type, +// and AP capabilities intersect. +bool securityIsValid( + WifiSecurityType::Enum type, + NMWirelessCapabilities::Enum caps, + bool adhoc, + NM80211ApFlags::Enum apFlags, + NM80211ApSecurityFlags::Enum apWpa, + NM80211ApSecurityFlags::Enum apRsn +); + +WifiSecurityType::Enum findBestWirelessSecurity( + NMWirelessCapabilities::Enum caps, + bool adHoc, + NM80211ApFlags::Enum apFlags, + NM80211ApSecurityFlags::Enum apWpa, + NM80211ApSecurityFlags::Enum apRsn +); + +NMSettingsMap mergeSettingsMaps(const NMSettingsMap& target, const NMSettingsMap& source); + +NMSettingsMap removeSettingsInMap(const NMSettingsMap& target, const NMSettingsMap& toRemove); + +void manualSettingDemarshall(NMSettingsMap& map); + +QVariant settingTypeFromQml(const QString& group, const QString& key, const QVariant& value); + +QVariant settingTypeToQml(const QVariant& value); + +QDateTime clockBootTimeToDateTime(qint64 clockBootTime); + +} // namespace qs::network diff --git a/src/network/nm/wireless.cpp b/src/network/nm/wireless.cpp new file mode 100644 index 0000000..5f55bed --- /dev/null +++ b/src/network/nm/wireless.cpp @@ -0,0 +1,554 @@ +#include "wireless.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "../../dbus/properties.hpp" +#include "../enums.hpp" +#include "../wifi.hpp" +#include "accesspoint.hpp" +#include "active_connection.hpp" +#include "dbus_nm_wireless.h" +#include "dbus_types.hpp" +#include "device.hpp" +#include "enums.hpp" +#include "settings.hpp" +#include "utils.hpp" + +namespace qs::network { +using namespace qs::dbus; + +namespace { +QS_LOGGING_CATEGORY(logNetworkManager, "quickshell.network.networkmanager", QtWarningMsg); +} + +NMWirelessNetwork::NMWirelessNetwork(QString ssid, QObject* parent) + : QObject(parent) + , mSsid(std::move(ssid)) + , bKnown(false) + , bSecurity(WifiSecurityType::Unknown) + , bReason(NMConnectionStateReason::None) + , bState(NMConnectionState::Deactivated) {} + +void NMWirelessNetwork::updateReferenceSettings() { + // If the network has no connections, the reference is nullptr. + if (this->mSettings.isEmpty()) { + this->mReferenceSettings = nullptr; + this->bSecurity = WifiSecurityType::Unknown; + if (this->mReferenceAp) { + this->bSecurity.setBinding([this]() { return this->mReferenceAp->security(); }); + } + return; + }; + + // If the network has an active connection, use its settings as the reference. + if (this->mActiveConnection) { + auto* settings = this->mSettings.value(this->mActiveConnection->connection().path()); + if (settings && settings != this->mReferenceSettings) { + this->mReferenceSettings = settings; + this->bSecurity.setBinding([settings]() { return securityFromSettingsMap(settings->map()); }); + } + return; + } + + // Otherwise, choose the settings responsible for the last successful connection. + NMSettings* selectedSettings = nullptr; + quint64 selectedTimestamp = 0; + for (auto* settings: this->mSettings.values()) { + const quint64 timestamp = settings->map()["connection"]["timestamp"].toULongLong(); + if (!selectedSettings || timestamp > selectedTimestamp) { + selectedSettings = settings; + selectedTimestamp = timestamp; + } + } + + if (this->mReferenceSettings != selectedSettings) { + this->mReferenceSettings = selectedSettings; + this->bSecurity.setBinding([selectedSettings]() { + return securityFromSettingsMap(selectedSettings->map()); + }); + } +} + +void NMWirelessNetwork::updateReferenceAp() { + // If the network has no APs, the reference is a nullptr. + if (this->mAccessPoints.isEmpty()) { + this->mReferenceAp = nullptr; + this->bSignalStrength = 0; + return; + } + + // Otherwise, choose the AP with the strongest signal. + NMAccessPoint* selectedAp = nullptr; + for (auto* ap: this->mAccessPoints.values()) { + // Always prefer the active AP. + if (ap->path() == this->bActiveApPath) { + selectedAp = ap; + break; + } + if (!selectedAp || ap->signalStrength() > selectedAp->signalStrength()) { + selectedAp = ap; + } + } + if (this->mReferenceAp != selectedAp) { + this->mReferenceAp = selectedAp; + this->bSignalStrength.setBinding([selectedAp]() { return selectedAp->signalStrength(); }); + // Reference AP is used for security when there's no connection settings. + if (!this->mReferenceSettings) { + this->bSecurity.setBinding([selectedAp]() { return selectedAp->security(); }); + } + } +} + +void NMWirelessNetwork::addAccessPoint(NMAccessPoint* ap) { + if (this->mAccessPoints.contains(ap->path())) return; + this->mAccessPoints.insert(ap->path(), ap); + auto onDestroyed = [this, ap]() { + if (this->mAccessPoints.take(ap->path())) { + this->updateReferenceAp(); + if (this->mAccessPoints.isEmpty() && this->mSettings.isEmpty()) emit this->disappeared(); + } + }; + // clang-format off + QObject::connect(ap, &NMAccessPoint::signalStrengthChanged, this, &NMWirelessNetwork::updateReferenceAp); + QObject::connect(ap, &NMAccessPoint::destroyed, this, onDestroyed); + // clang-format on + this->updateReferenceAp(); +}; + +void NMWirelessNetwork::addSettings(NMSettings* settings) { + if (this->mSettings.contains(settings->path())) return; + this->mSettings.insert(settings->path(), settings); + + auto onDestroyed = [this, settings]() { + if (this->mSettings.take(settings->path())) { + emit this->settingsRemoved(settings); + this->updateReferenceSettings(); + if (this->mSettings.isEmpty()) this->bKnown = false; + if (this->mAccessPoints.isEmpty() && this->mSettings.isEmpty()) emit this->disappeared(); + } + }; + QObject::connect(settings, &NMSettings::destroyed, this, onDestroyed); + this->bKnown = true; + this->updateReferenceSettings(); + emit this->settingsAdded(settings); +}; + +void NMWirelessNetwork::addActiveConnection(NMActiveConnection* active) { + if (this->mActiveConnection) return; + this->mActiveConnection = active; + + this->bState.setBinding([active]() { return active->state(); }); + this->bReason.setBinding([active]() { return active->stateReason(); }); + auto onDestroyed = [this, active]() { + if (this->mActiveConnection && this->mActiveConnection == active) { + this->mActiveConnection = nullptr; + this->updateReferenceSettings(); + this->bState = NMConnectionState::Deactivated; + this->bReason = NMConnectionStateReason::None; + } + }; + QObject::connect(active, &NMActiveConnection::destroyed, this, onDestroyed); + this->updateReferenceSettings(); +}; + +void NMWirelessNetwork::forget() { + if (this->mSettings.isEmpty()) return; + for (auto* conn: this->mSettings.values()) { + conn->forget(); + } +} + +NMWirelessDevice::NMWirelessDevice(const QString& path, QObject* parent) + : NMDevice(path, parent) + , mScanTimer(this) { + this->wirelessProxy = new DBusNMWirelessProxy( + "org.freedesktop.NetworkManager", + path, + QDBusConnection::systemBus(), + this + ); + + if (!this->wirelessProxy->isValid()) { + qCWarning(logNetworkManager) << "Cannot create DBus interface for wireless device at" << path; + return; + } + + QObject::connect( + &this->wirelessProperties, + &DBusPropertyGroup::getAllFinished, + this, + &NMWirelessDevice::initWireless, + Qt::SingleShotConnection + ); + + QObject::connect(&this->mScanTimer, &QTimer::timeout, this, &NMWirelessDevice::onScanTimeout); + this->mScanTimer.setSingleShot(true); + + this->wirelessProperties.setInterface(this->wirelessProxy); + this->wirelessProperties.updateAllViaGetAll(); +} + +void NMWirelessDevice::initWireless() { + // clang-format off + QObject::connect(this->wirelessProxy, &DBusNMWirelessProxy::AccessPointAdded, this, &NMWirelessDevice::onAccessPointAdded); + QObject::connect(this->wirelessProxy, &DBusNMWirelessProxy::AccessPointRemoved, this, &NMWirelessDevice::onAccessPointRemoved); + QObject::connect(this, &NMWirelessDevice::accessPointLoaded, this, &NMWirelessDevice::onAccessPointLoaded); + QObject::connect(this, &NMWirelessDevice::settingsLoaded, this, &NMWirelessDevice::onSettingsLoaded); + QObject::connect(this, &NMWirelessDevice::activeConnectionLoaded, this, &NMWirelessDevice::onActiveConnectionLoaded); + QObject::connect(this, &NMWirelessDevice::scanningChanged, this, &NMWirelessDevice::onScanningChanged); + // clang-format on + this->registerAccessPoints(); +} + +void NMWirelessDevice::onAccessPointAdded(const QDBusObjectPath& path) { + this->registerAccessPoint(path.path()); +} + +void NMWirelessDevice::onAccessPointRemoved(const QDBusObjectPath& path) { + auto* ap = this->mAccessPoints.take(path.path()); + if (!ap) { + qCDebug(logNetworkManager) << "Sent removal signal for" << path.path() + << "which is not registered."; + return; + } + qCDebug(logNetworkManager) << "Access point removed:" << path.path(); + delete ap; +} + +void NMWirelessDevice::onAccessPointLoaded(NMAccessPoint* ap) { + const QString ssid = ap->ssid(); + if (!ssid.isEmpty()) { + auto mode = ap->mode(); + if (mode == NM80211Mode::Infra) { + auto* net = this->mNetworks.value(ssid); + if (!net) net = this->registerNetwork(ssid); + net->addAccessPoint(ap); + } + } +} + +void NMWirelessDevice::onSettingsLoaded(NMSettings* settings) { + const NMSettingsMap& map = settings->map(); + // Filter connections that aren't wireless or have missing settings + if (map["connection"]["id"].toString().isEmpty() || map["connection"]["uuid"].toString().isEmpty() + || !map.contains("802-11-wireless") || map["802-11-wireless"]["ssid"].toString().isEmpty()) + { + return; + } + + const auto ssid = map["802-11-wireless"]["ssid"].toString(); + const auto mode = map["802-11-wireless"]["mode"].toString(); + + if (mode == "infrastructure") { + auto* net = this->mNetworks.value(ssid); + if (!net) net = this->registerNetwork(ssid); + net->addSettings(settings); + + // Check for active connections that loaded before their respective connection settings + auto* active = this->activeConnection(); + if (active && settings->path() == active->connection().path()) { + net->addActiveConnection(active); + } + } + // TODO: Create hotspots when mode == "ap" +} + +void NMWirelessDevice::onActiveConnectionLoaded(NMActiveConnection* active) { + // Find an exisiting network with connection settings that matches the active + const QString activeConnPath = active->connection().path(); + for (const auto& net: this->mNetworks.values()) { + for (auto* settings: net->settings()) { + if (activeConnPath == settings->path()) { + net->addActiveConnection(active); + return; + } + } + } +} + +void NMWirelessDevice::onScanTimeout() { + const QDateTime now = QDateTime::currentDateTime(); + const QDateTime lastScan = this->bLastScan; + const QDateTime lastScanRequest = this->mLastScanRequest; + + if (lastScan.isValid() && lastScan.msecsTo(now) < this->mScanIntervalMs) { + // Rate limit if backend last scan property updated within the interval + auto diff = static_cast(this->mScanIntervalMs - lastScan.msecsTo(now)); + this->mScanTimer.start(diff); + } else if (lastScanRequest.isValid() && lastScanRequest.msecsTo(now) < this->mScanIntervalMs) { + // Rate limit if frontend changes scanner state within the interval + auto diff = static_cast(this->mScanIntervalMs - lastScanRequest.msecsTo(now)); + this->mScanTimer.start(diff); + } else { + this->wirelessProxy->RequestScan({}); + this->mLastScanRequest = now; + this->mScanTimer.start(this->mScanIntervalMs); + } +} + +void NMWirelessDevice::onScanningChanged(bool scanning) { + scanning ? this->onScanTimeout() : this->mScanTimer.stop(); +} + +void NMWirelessDevice::registerAccessPoints() { + auto pending = this->wirelessProxy->GetAllAccessPoints(); + auto* call = new QDBusPendingCallWatcher(pending, this); + + auto responseCallback = [this](QDBusPendingCallWatcher* call) { + const QDBusPendingReply> reply = *call; + + if (reply.isError()) { + qCWarning(logNetworkManager) + << "Failed to get all access points: " << reply.error().message(); + } else { + for (const QDBusObjectPath& devicePath: reply.value()) { + this->registerAccessPoint(devicePath.path()); + } + } + + delete call; + }; + + QObject::connect(call, &QDBusPendingCallWatcher::finished, this, responseCallback); +} + +void NMWirelessDevice::registerAccessPoint(const QString& path) { + if (this->mAccessPoints.contains(path)) { + qCDebug(logNetworkManager) << "Skipping duplicate registration of access point" << path; + return; + } + + auto* ap = new NMAccessPoint(path, this); + + if (!ap->isValid()) { + qCWarning(logNetworkManager) << "Ignoring invalid registration of" << path; + delete ap; + return; + } + + qCDebug(logNetworkManager) << "Access point added:" << path; + this->mAccessPoints.insert(path, ap); + QObject::connect( + ap, + &NMAccessPoint::loaded, + this, + [this, ap]() { emit this->accessPointLoaded(ap); }, + Qt::SingleShotConnection + ); + ap->bindableSecurity().setBinding([this, ap]() { + return findBestWirelessSecurity( + this->bCapabilities, + ap->mode() == NM80211Mode::Adhoc, + ap->flags(), + ap->wpaFlags(), + ap->rsnFlags() + ); + }); +} + +NMWirelessNetwork* NMWirelessDevice::registerNetwork(const QString& ssid) { + auto* net = new NMWirelessNetwork(ssid, this); + + auto visible = [this, net]() { + return this->bScanning || net->state() == NMConnectionState::Activated || net->known(); + }; + + net->bindableVisible().setBinding(visible); + net->bindableActiveApPath().setBinding([this]() { return this->activeApPath().path(); }); + net->bindableDeviceFailReason().setBinding([this]() { return this->lastFailReason(); }); + QObject::connect(net, &NMWirelessNetwork::disappeared, this, &NMWirelessDevice::removeNetwork); + + qCDebug(logNetworkManager) << "Registered network for SSID" << ssid; + this->mNetworks.insert(ssid, net); + this->registerFrontendNetwork(net); + return net; +} + +void NMWirelessDevice::registerFrontendNetwork(NMWirelessNetwork* net) { + auto ssid = net->ssid(); + auto* frontendNet = new WifiNetwork(ssid, net); + + // Bind WifiNetwork to NMWirelessNetwork + auto translateSignal = [net]() { return net->signalStrength() / 100.0; }; + auto translateState = [net]() { return net->state() == NMConnectionState::Activated; }; + frontendNet->bindableSignalStrength().setBinding(translateSignal); + frontendNet->bindableConnected().setBinding(translateState); + frontendNet->bindableKnown().setBinding([net]() { return net->known(); }); + frontendNet->bindableSecurity().setBinding([net]() { return net->security(); }); + frontendNet->bindableState().setBinding([net]() { + return static_cast(net->state()); + }); + + QObject::connect(net, &NMWirelessNetwork::reasonChanged, this, [net, frontendNet]() { + if (net->reason() == NMConnectionStateReason::DeviceDisconnected) { + auto deviceReason = net->deviceFailReason(); + if (deviceReason == NMDeviceStateReason::NoSecrets) + emit frontendNet->connectionFailed(ConnectionFailReason::NoSecrets); + if (deviceReason == NMDeviceStateReason::SupplicantDisconnect) + emit frontendNet->connectionFailed(ConnectionFailReason::WifiClientDisconnected); + if (deviceReason == NMDeviceStateReason::SupplicantFailed) + emit frontendNet->connectionFailed(ConnectionFailReason::WifiClientFailed); + if (deviceReason == NMDeviceStateReason::SupplicantTimeout) + emit frontendNet->connectionFailed(ConnectionFailReason::WifiAuthTimeout); + if (deviceReason == NMDeviceStateReason::SsidNotFound) + emit frontendNet->connectionFailed(ConnectionFailReason::WifiNetworkLost); + } + }); + + QObject::connect(frontendNet, &WifiNetwork::requestConnect, this, [this, net]() { + if (net->referenceSettings()) { + emit this->activateConnection( + QDBusObjectPath(net->referenceSettings()->path()), + QDBusObjectPath(this->path()) + ); + return; + } + if (net->referenceAp()) { + emit this->addAndActivateConnection( + NMSettingsMap(), + QDBusObjectPath(this->path()), + QDBusObjectPath(net->referenceAp()->path()) + ); + return; + } + qCInfo(logNetworkManager) << "Failed to connect to" + << this->path() + ": The network disappeared."; + }); + + QObject::connect( + frontendNet, + &WifiNetwork::requestConnectWithPsk, + this, + [this, net](const QString& psk) { + NMSettingsMap settings; + settings["802-11-wireless-security"]["psk"] = psk; + if (const QPointer ref = net->referenceSettings()) { + auto* call = ref->updateSettings(settings); + QObject::connect( + call, + &QDBusPendingCallWatcher::finished, + this, + [this, ref](QDBusPendingCallWatcher* call) { + const QDBusPendingReply<> reply = *call; + + if (reply.isError()) { + qCInfo(logNetworkManager) + << "Failed to write PSK for" << this->path() + ":" << reply.error().message(); + } else { + if (!ref) { + qCInfo(logNetworkManager) << "Failed to connectWithPsk to" + << this->path() + ": The settings disappeared."; + } else { + emit this->activateConnection( + QDBusObjectPath(ref->path()), + QDBusObjectPath(this->path()) + ); + } + } + delete call; + } + ); + return; + } + if (net->referenceAp()) { + emit this->addAndActivateConnection( + settings, + QDBusObjectPath(this->path()), + QDBusObjectPath(net->referenceAp()->path()) + ); + return; + } + qCInfo(logNetworkManager) << "Failed to connectWithPsk to" + << this->path() + ": The network disappeared."; + } + ); + + QObject::connect( + frontendNet, + &WifiNetwork::requestConnectWithSettings, + this, + [this](NMSettings* settings) { + if (settings) { + emit this->activateConnection( + QDBusObjectPath(settings->path()), + QDBusObjectPath(this->path()) + ); + return; + } + qCInfo(logNetworkManager) << "Failed to connectWithSettings to" + << this->path() + ": The provided settings no longer exist."; + } + ); + + QObject::connect( + net, + &NMWirelessNetwork::visibilityChanged, + this, + [this, frontendNet](bool visible) { + if (visible) this->networkAdded(frontendNet); + else this->networkRemoved(frontendNet); + } + ); + + // clang-format off + QObject::connect(frontendNet, &WifiNetwork::requestDisconnect, this, &NMWirelessDevice::disconnect); + QObject::connect(frontendNet, &WifiNetwork::requestForget, net, &NMWirelessNetwork::forget); + QObject::connect(net, &NMWirelessNetwork::settingsAdded, frontendNet, &WifiNetwork::settingsAdded); + QObject::connect(net, &NMWirelessNetwork::settingsRemoved, frontendNet, &WifiNetwork::settingsRemoved); + // clang-format on + + this->mFrontendNetworks.insert(ssid, frontendNet); + if (net->visible()) emit this->networkAdded(frontendNet); +} + +void NMWirelessDevice::removeFrontendNetwork(NMWirelessNetwork* net) { + auto* frontendNet = this->mFrontendNetworks.take(net->ssid()); + if (frontendNet) { + if (net->visible()) emit this->networkRemoved(frontendNet); + frontendNet->deleteLater(); + } +} + +void NMWirelessDevice::removeNetwork() { + auto* net = qobject_cast(this->sender()); + if (this->mNetworks.take(net->ssid())) { + this->removeFrontendNetwork(net); + delete net; + }; +} + +bool NMWirelessDevice::isValid() const { + return this->NMDevice::isValid() && (this->wirelessProxy && this->wirelessProxy->isValid()); +} + +} // namespace qs::network + +namespace qs::dbus { + +DBusResult +DBusDataTransform::fromWire(quint32 wire) { + return DBusResult(static_cast(wire)); +} + +DBusResult DBusDataTransform::fromWire(qint64 wire) { + return DBusResult(qs::network::clockBootTimeToDateTime(wire)); +} + +} // namespace qs::dbus diff --git a/src/network/nm/wireless.hpp b/src/network/nm/wireless.hpp new file mode 100644 index 0000000..94ce754 --- /dev/null +++ b/src/network/nm/wireless.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../wifi.hpp" +#include "accesspoint.hpp" +#include "active_connection.hpp" +#include "dbus_nm_wireless.h" +#include "device.hpp" +#include "enums.hpp" +#include "settings.hpp" + +namespace qs::dbus { +template <> +struct DBusDataTransform { + using Wire = quint32; + using Data = qs::network::NMWirelessCapabilities::Enum; + static DBusResult fromWire(Wire wire); +}; + +template <> +struct DBusDataTransform { + using Wire = qint64; + using Data = QDateTime; + static DBusResult fromWire(Wire wire); +}; + +} // namespace qs::dbus +namespace qs::network { + +// NMWirelessNetwork aggregates all related NMActiveConnection, NMAccessPoint, and NMSettings objects. +class NMWirelessNetwork: public QObject { + Q_OBJECT; + +public: + explicit NMWirelessNetwork(QString ssid, QObject* parent = nullptr); + + void addAccessPoint(NMAccessPoint* ap); + void addSettings(NMSettings* settings); + void addActiveConnection(NMActiveConnection* active); + void forget(); + + // clang-format off + [[nodiscard]] QString ssid() const { return this->mSsid; } + [[nodiscard]] quint8 signalStrength() const { return this->bSignalStrength; } + [[nodiscard]] WifiSecurityType::Enum security() const { return this->bSecurity; } + [[nodiscard]] NMConnectionState::Enum state() const { return this->bState; } + [[nodiscard]] bool known() const { return this->bKnown; } + [[nodiscard]] NMConnectionStateReason::Enum reason() const { return this->bReason; } + QBindable bindableDeviceFailReason() { return &this->bDeviceFailReason; } + [[nodiscard]] NMDeviceStateReason::Enum deviceFailReason() const { return this->bDeviceFailReason; } + [[nodiscard]] NMAccessPoint* referenceAp() const { return this->mReferenceAp; } + [[nodiscard]] QList accessPoints() const { return this->mAccessPoints.values(); } + [[nodiscard]] QList settings() const { return this->mSettings.values(); } + [[nodiscard]] NMSettings* referenceSettings() const { return this->mReferenceSettings; } + QBindable bindableActiveApPath() { return &this->bActiveApPath; } + QBindable bindableVisible() { return &this->bVisible; } + bool visible() const { return this->bVisible; } + // clang-format on + +signals: + void disappeared(); + void settingsAdded(NMSettings* settings); + void settingsRemoved(NMSettings* settings); + void visibilityChanged(bool visible); + void signalStrengthChanged(quint8 signal); + void stateChanged(NMConnectionState::Enum state); + void knownChanged(bool known); + void securityChanged(WifiSecurityType::Enum security); + void reasonChanged(NMConnectionStateReason::Enum reason); + void deviceFailReasonChanged(NMDeviceStateReason::Enum reason); + void capabilitiesChanged(NMWirelessCapabilities::Enum caps); + void activeApPathChanged(QString path); + +private: + void updateReferenceAp(); + void updateReferenceSettings(); + + QString mSsid; + QHash mAccessPoints; + QHash mSettings; + NMAccessPoint* mReferenceAp = nullptr; + NMSettings* mReferenceSettings = nullptr; + NMActiveConnection* mActiveConnection = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessNetwork, bool, bVisible, &NMWirelessNetwork::visibilityChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessNetwork, bool, bKnown, &NMWirelessNetwork::knownChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessNetwork, WifiSecurityType::Enum, bSecurity, &NMWirelessNetwork::securityChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessNetwork, NMConnectionStateReason::Enum, bReason, &NMWirelessNetwork::reasonChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessNetwork, NMConnectionState::Enum, bState, &NMWirelessNetwork::stateChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessNetwork, NMDeviceStateReason::Enum, bDeviceFailReason, &NMWirelessNetwork::deviceFailReasonChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessNetwork, quint8, bSignalStrength, &NMWirelessNetwork::signalStrengthChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessNetwork, QString, bActiveApPath, &NMWirelessNetwork::activeApPathChanged); + // clang-format on +}; + +// Proxy of a /org/freedesktop/NetworkManager/Device/* object. +// Extends NMDevice to also include members from the org.freedesktop.NetworkManager.Device.Wireless interface +// Owns the lifetime of NMAccessPoints(s), NMWirelessNetwork(s), frontend WifiNetwork(s). +class NMWirelessDevice: public NMDevice { + Q_OBJECT; + +public: + explicit NMWirelessDevice(const QString& path, QObject* parent = nullptr); + + [[nodiscard]] bool isValid() const override; + [[nodiscard]] NMWirelessCapabilities::Enum capabilities() { return this->bCapabilities; } + [[nodiscard]] const QDBusObjectPath& activeApPath() { return this->bActiveAccessPoint; } + [[nodiscard]] NM80211Mode::Enum mode() { return this->bMode; } + [[nodiscard]] QBindable bindableScanning() { return &this->bScanning; } + +signals: + void accessPointLoaded(NMAccessPoint* ap); + void accessPointRemoved(NMAccessPoint* ap); + void networkAdded(WifiNetwork* net); + void networkRemoved(WifiNetwork* net); + void lastScanChanged(QDateTime lastScan); + void scanningChanged(bool scanning); + void capabilitiesChanged(NMWirelessCapabilities::Enum caps); + void activeAccessPointChanged(const QDBusObjectPath& path); + void modeChanged(NM80211Mode::Enum mode); + +private slots: + void onAccessPointAdded(const QDBusObjectPath& path); + void onAccessPointRemoved(const QDBusObjectPath& path); + void onAccessPointLoaded(NMAccessPoint* ap); + void onSettingsLoaded(NMSettings* settings); + void onActiveConnectionLoaded(NMActiveConnection* active); + void onScanTimeout(); + void onScanningChanged(bool scanning); + +private: + void registerAccessPoint(const QString& path); + void registerFrontendNetwork(NMWirelessNetwork* net); + void removeFrontendNetwork(NMWirelessNetwork* net); + void removeNetwork(); + bool checkVisibility(WifiNetwork* net); + void registerAccessPoints(); + void initWireless(); + NMWirelessNetwork* registerNetwork(const QString& ssid); + + QHash mAccessPoints; + QHash mNetworks; + QHash mFrontendNetworks; + + QDateTime mLastScanRequest; + QTimer mScanTimer; + qint32 mScanIntervalMs = 10001; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessDevice, bool, bScanning, &NMWirelessDevice::scanningChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessDevice, QDateTime, bLastScan, &NMWirelessDevice::lastScanChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessDevice, NMWirelessCapabilities::Enum, bCapabilities, &NMWirelessDevice::capabilitiesChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessDevice, QDBusObjectPath, bActiveAccessPoint, &NMWirelessDevice::activeAccessPointChanged); + Q_OBJECT_BINDABLE_PROPERTY(NMWirelessDevice, NM80211Mode::Enum, bMode, &NMWirelessDevice::modeChanged); + + QS_DBUS_BINDABLE_PROPERTY_GROUP(NMWireless, wirelessProperties); + QS_DBUS_PROPERTY_BINDING(NMWirelessDevice, pLastScan, bLastScan, wirelessProperties, "LastScan"); + QS_DBUS_PROPERTY_BINDING(NMWirelessDevice, pCapabilities, bCapabilities, wirelessProperties, "WirelessCapabilities"); + QS_DBUS_PROPERTY_BINDING(NMWirelessDevice, pActiveAccessPoint, bActiveAccessPoint, wirelessProperties, "ActiveAccessPoint"); + QS_DBUS_PROPERTY_BINDING(NMWirelessDevice, pMode, bMode, wirelessProperties, "Mode"); + // clang-format on + + DBusNMWirelessProxy* wirelessProxy = nullptr; +}; + +} // namespace qs::network diff --git a/src/network/test/manual/network.qml b/src/network/test/manual/network.qml new file mode 100644 index 0000000..eadc159 --- /dev/null +++ b/src/network/test/manual/network.qml @@ -0,0 +1,360 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Networking + +Scope { + Component { + id: editorComponent + FloatingWindow { + id: editorWindow + required property var nmSettings + color: contentItem.palette.window + + Component.onCompleted: editorArea.text = JSON.stringify(nmSettings.read(), null, 2) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + + Label { + text: "Editing " + nmSettings?.id + " (" + nmSettings?.uuid + ")" + font.bold: true + font.pointSize: 12 + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + TextArea { + id: editorArea + wrapMode: TextEdit.Wrap + selectByMouse: true + } + } + + RowLayout { + Layout.fillWidth: true + Label { + id: statusLabel + Layout.fillWidth: true + color: palette.placeholderText + } + Button { + text: "Reload" + onClicked: { + editorArea.text = JSON.stringify(editorWindow.nmSettings.read(), null, 2); + statusLabel.text = "Reloaded"; + } + } + Button { + text: "Save" + onClicked: { + try { + const parsed = JSON.parse(editorArea.text); + nmSettings.write(parsed); + statusLabel.text = "Saved"; + } catch (e) { + statusLabel.text = "Parse error: " + e.message; + } + } + } + Button { + text: "Close" + onClicked: { + editorArea.focus = false; + editorWindow.destroy(); + } + } + } + } + } + } + + FloatingWindow { + color: contentItem.palette.window + + ColumnLayout { + anchors.fill: parent + anchors.margins: 5 + + ColumnLayout { + Label { + text: `Networking (${NetworkBackendType.toString(Networking.backend)} backend)` + font.bold: true + font.pointSize: 12 + } + RowLayout { + Label { + text: `Connectivity` + font.bold: true + } + Label { + text: `${NetworkConnectivity.toString(Networking.connectivity)}` + visible: Networking.canCheckConnectivity + } + Button { + text: "Re-check" + visible: Networking.canCheckConnectivity && Networking.connectivityCheckEnabled + onClicked: Networking.checkConnectivity() + } + CheckBox { + text: "Checking enabled" + checked: Networking.connectivityCheckEnabled + onClicked: Networking.connectivityCheckEnabled = !Networking.connectivityCheckEnabled + visible: Networking.canCheckConnectivity + } + CheckBox { + enabled: false + text: "Supported" + checked: Networking.canCheckConnectivity + } + } + } + + Column { + Layout.fillWidth: true + RowLayout { + Label { + text: "WiFi" + font.bold: true + } + CheckBox { + text: "Software" + checked: Networking.wifiEnabled + onClicked: Networking.wifiEnabled = !Networking.wifiEnabled + } + CheckBox { + enabled: false + text: "Hardware" + checked: Networking.wifiHardwareEnabled + } + } + } + + ListView { + clip: true + Layout.fillWidth: true + Layout.fillHeight: true + model: Networking.devices + + delegate: WrapperRectangle { + width: parent.width + color: "transparent" + border.color: palette.button + border.width: 1 + margin: 5 + + ColumnLayout { + RowLayout { + Label { + text: modelData.name + font.bold: true + } + Label { + text: modelData.address + } + Label { + text: `(Type: ${DeviceType.toString(modelData.type)})` + } + CheckBox { + text: `Managed` + checked: modelData.nmManaged + onClicked: modelData.nmManaged = !modelData.nmManaged + } + } + RowLayout { + Label { + text: ConnectionState.toString(modelData.state) + color: modelData.connected ? palette.link : palette.placeholderText + } + Button { + visible: modelData.state == ConnectionState.Connected + text: "Disconnect" + onClicked: modelData.disconnect() + } + CheckBox { + text: "Autoconnect" + checked: modelData.autoconnect + onClicked: modelData.autoconnect = !modelData.autoconnect + } + Label { + text: `Mode: ${WifiDeviceMode.toString(modelData.mode)}` + visible: modelData.type == DeviceType.Wifi + } + CheckBox { + text: "Scanner" + checked: modelData.scannerEnabled + onClicked: modelData.scannerEnabled = !modelData.scannerEnabled + visible: modelData.type === DeviceType.Wifi + } + } + + Repeater { + Layout.fillWidth: true + model: ScriptModel { + values: [...modelData.networks.values].sort((a, b) => { + if (a.connected !== b.connected) { + return b.connected - a.connected; + } + return b.signalStrength - a.signalStrength; + }) + } + + WrapperRectangle { + property var chosenSettings: { + const settings = modelData.nmSettings; + if (!settings || settings.length === 0) { + return null; + } + if (settings.length === 1) { + return settings[0]; + } + return settings[settingsComboBox.currentIndex]; + } + + Connections { + target: modelData + function onConnectionFailed(reason) { + failLoader.sourceComponent = failComponent; + failLoader.item.failReason = reason; + } + function onStateChanged() { + if (modelData.state == ConnectionState.Connecting) { + failLoader.sourceComponent = null; + } + } + } + + Component { + id: failComponent + RowLayout { + property var failReason + Label { + text: ConnectionFailReason.toString(failReason) + } + RowLayout { + TextField { + id: pskField + placeholderText: "PSK" + } + Button { + text: "Set" + visible: pskField.visible + onClicked: { + modelData.connectWithPsk(pskField.text); + failLoader.sourceComponent = null; + } + } + visible: modelData.security === WifiSecurityType.WpaPsk || modelData.security === WifiSecurityType.Wpa2Psk || modelData.security === WifiSecurityType.Sae + } + Button { + text: "Close" + onClicked: failLoader.sourceComponent = null + } + } + } + + Layout.fillWidth: true + color: modelData.connected ? palette.highlight : palette.button + border.color: palette.mid + border.width: 1 + margin: 5 + + RowLayout { + ColumnLayout { + Layout.fillWidth: true + RowLayout { + Label { + text: modelData.name + font.bold: true + } + Label { + text: modelData.known ? "Known" : "" + color: palette.placeholderText + } + } + RowLayout { + Label { + text: `Security: ${WifiSecurityType.toString(modelData.security)}` + color: palette.placeholderText + } + Label { + text: `| Signal strength: ${Math.round(modelData.signalStrength * 100)}%` + color: palette.placeholderText + } + } + } + ColumnLayout { + Layout.alignment: Qt.AlignRight + RowLayout { + Layout.alignment: Qt.AlignRight + BusyIndicator { + implicitHeight: 30 + implicitWidth: 30 + running: modelData.stateChanging + visible: modelData.stateChanging + } + Label { + text: ConnectionState.toString(modelData.state) + color: modelData.connected ? palette.link : palette.placeholderText + } + RowLayout { + Label { + text: "Choose settings:" + } + ComboBox { + id: settingsComboBox + model: modelData.nmSettings.map(s => s?.read()?.connection?.id) + currentIndex: 0 + } + visible: modelData.nmSettings.length > 1 + } + Button { + text: "Connect" + onClicked: { + if (chosenSettings) + modelData.connectWithSettings(chosenSettings); + else + modelData.connect(); + } + visible: !modelData.connected + } + Button { + text: "Disconnect" + onClicked: modelData.disconnect() + visible: modelData.connected + } + Button { + text: "Forget" + onClicked: modelData.forget() + visible: modelData.known + } + Button { + text: "Edit" + visible: modelData.known + onClicked: { + if (chosenSettings) + editorComponent.createObject(null, { + nmSettings: chosenSettings + }); + } + } + } + Loader { + id: failLoader + Layout.alignment: Qt.AlignRight + visible: sourceComponent !== null + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/network/wifi.cpp b/src/network/wifi.cpp new file mode 100644 index 0000000..e9939c2 --- /dev/null +++ b/src/network/wifi.cpp @@ -0,0 +1,74 @@ +#include "wifi.hpp" +#include + +#include +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" +#include "device.hpp" +#include "enums.hpp" +#include "network.hpp" + +namespace qs::network { + +namespace { +QS_LOGGING_CATEGORY(logWifiNetwork, "quickshell.wifinetwork", QtWarningMsg); +} + +WifiNetwork::WifiNetwork(QString ssid, QObject* parent): Network(std::move(ssid), parent) {}; + +void WifiNetwork::connectWithPsk(const QString& psk) { + if (this->bConnected) { + qCCritical(logWifiNetwork) << this << "is already connected."; + return; + } + if (this->bSecurity != WifiSecurityType::WpaPsk && this->bSecurity != WifiSecurityType::Wpa2Psk + && this->bSecurity != WifiSecurityType::Sae) + { + qCCritical(logWifiNetwork) << this << "has the wrong security type for a PSK."; + return; + } + emit this->requestConnectWithPsk(psk); +} + +WifiDevice::WifiDevice(QObject* parent): NetworkDevice(DeviceType::Wifi, parent) {}; + +void WifiDevice::setScannerEnabled(bool enabled) { + if (this->bScannerEnabled == enabled) return; + this->bScannerEnabled = enabled; +} + +void WifiDevice::networkAdded(WifiNetwork* net) { this->mNetworks.insertObject(net); } +void WifiDevice::networkRemoved(WifiNetwork* net) { this->mNetworks.removeObject(net); } + +} // namespace qs::network + +QDebug operator<<(QDebug debug, const qs::network::WifiNetwork* network) { + auto saver = QDebugStateSaver(debug); + + if (network) { + debug.nospace() << "WifiNetwork(" << static_cast(network) + << ", name=" << network->name() << ")"; + } else { + debug << "WifiNetwork(nullptr)"; + } + + return debug; +} + +QDebug operator<<(QDebug debug, const qs::network::WifiDevice* device) { + auto saver = QDebugStateSaver(debug); + + if (device) { + debug.nospace() << "WifiDevice(" << static_cast(device) + << ", name=" << device->name() << ")"; + } else { + debug << "WifiDevice(nullptr)"; + } + + return debug; +} diff --git a/src/network/wifi.hpp b/src/network/wifi.hpp new file mode 100644 index 0000000..c0091f7 --- /dev/null +++ b/src/network/wifi.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/model.hpp" +#include "device.hpp" +#include "enums.hpp" +#include "network.hpp" + +namespace qs::network { + +///! WiFi subtype of @@Network. +class WifiNetwork: public Network { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE("WifiNetwork can only be acquired through WifiDevice"); + // clang-format off + /// The current signal strength of the network, from 0.0 to 1.0. + Q_PROPERTY(qreal signalStrength READ default NOTIFY signalStrengthChanged BINDABLE bindableSignalStrength); + /// The security type of the wifi network. + Q_PROPERTY(WifiSecurityType::Enum security READ default NOTIFY securityChanged BINDABLE bindableSecurity); + // clang-format on + +public: + explicit WifiNetwork(QString ssid, QObject* parent = nullptr); + /// Attempt to connect to the network with the given PSK. If the PSK is wrong, + /// a @@Network.connectionFailed(s) signal will be emitted with `NoSecrets`. + /// + /// The networking backend may store the PSK for future use with @@Network.connect(). + /// As such, calling that function first is recommended to avoid having to show a + /// prompt if not required. + /// + /// > [!NOTE] PSKs should only be provided when the @@security is one of + /// > `WpaPsk`, `Wpa2Psk`, or `Sae`. + Q_INVOKABLE void connectWithPsk(const QString& psk); + + QBindable bindableSignalStrength() { return &this->bSignalStrength; } + QBindable bindableSecurity() { return &this->bSecurity; } + +signals: + QSDOC_HIDE void requestConnectWithPsk(QString psk); + void signalStrengthChanged(); + void securityChanged(); + +private: + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(WifiNetwork, qreal, bSignalStrength, &WifiNetwork::signalStrengthChanged); + Q_OBJECT_BINDABLE_PROPERTY(WifiNetwork, WifiSecurityType::Enum, bSecurity, &WifiNetwork::securityChanged); + // clang-format on +}; + +///! WiFi variant of a @@NetworkDevice. +class WifiDevice: public NetworkDevice { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + + // clang-format off + /// A list of this available or connected wifi networks. + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* networks READ networks CONSTANT); + /// True when currently scanning for networks. + /// When enabled, the scanner populates the device with an active list of available wifi networks. + Q_PROPERTY(bool scannerEnabled READ scannerEnabled WRITE setScannerEnabled NOTIFY scannerEnabledChanged BINDABLE bindableScannerEnabled); + /// The 802.11 mode the device is in. + Q_PROPERTY(WifiDeviceMode::Enum mode READ default NOTIFY modeChanged BINDABLE bindableMode); + // clang-format on + +public: + explicit WifiDevice(QObject* parent = nullptr); + + void networkAdded(WifiNetwork* net); + void networkRemoved(WifiNetwork* net); + + [[nodiscard]] ObjectModel* networks() { return &this->mNetworks; } + QBindable bindableScannerEnabled() { return &this->bScannerEnabled; } + [[nodiscard]] bool scannerEnabled() const { return this->bScannerEnabled; } + void setScannerEnabled(bool enabled); + QBindable bindableMode() { return &this->bMode; } + +signals: + void modeChanged(); + void scannerEnabledChanged(bool enabled); + +private: + ObjectModel mNetworks {this}; + Q_OBJECT_BINDABLE_PROPERTY(WifiDevice, bool, bScannerEnabled, &WifiDevice::scannerEnabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(WifiDevice, WifiDeviceMode::Enum, bMode, &WifiDevice::modeChanged); +}; + +}; // namespace qs::network + +QDebug operator<<(QDebug debug, const qs::network::WifiNetwork* network); +QDebug operator<<(QDebug debug, const qs::network::WifiDevice* device); diff --git a/src/services/CMakeLists.txt b/src/services/CMakeLists.txt index 5ab5c55..f3912a9 100644 --- a/src/services/CMakeLists.txt +++ b/src/services/CMakeLists.txt @@ -14,6 +14,10 @@ if (SERVICE_PAM) add_subdirectory(pam) endif() +if (SERVICE_POLKIT) + add_subdirectory(polkit) +endif() + if (SERVICE_GREETD) add_subdirectory(greetd) endif() diff --git a/src/services/greetd/CMakeLists.txt b/src/services/greetd/CMakeLists.txt index 2252f8c..a103531 100644 --- a/src/services/greetd/CMakeLists.txt +++ b/src/services/greetd/CMakeLists.txt @@ -12,7 +12,7 @@ qt_add_qml_module(quickshell-service-greetd install_qml_module(quickshell-service-greetd) # can't be Qt::Qml because generation.hpp pulls in gui types -target_link_libraries(quickshell-service-greetd PRIVATE Qt::Quick) +target_link_libraries(quickshell-service-greetd PRIVATE Qt::Quick quickshell-core) qs_module_pch(quickshell-service-greetd) diff --git a/src/services/greetd/connection.cpp b/src/services/greetd/connection.cpp index ecfd9a5..3b8fa24 100644 --- a/src/services/greetd/connection.cpp +++ b/src/services/greetd/connection.cpp @@ -14,9 +14,10 @@ #include #include "../../core/generation.hpp" +#include "../../core/logcat.hpp" namespace { -Q_LOGGING_CATEGORY(logGreetd, "quickshell.service.greetd"); +QS_LOGGING_CATEGORY(logGreetd, "quickshell.service.greetd"); } QString GreetdState::toString(GreetdState::Enum value) { @@ -144,6 +145,7 @@ void GreetdConnection::setInactive() { QString GreetdConnection::user() const { return this->mUser; } void GreetdConnection::onSocketConnected() { + this->reader.setDevice(&this->socket); qCDebug(logGreetd) << "Connected to greetd socket."; if (this->mTargetActive) { @@ -159,77 +161,84 @@ void GreetdConnection::onSocketError(QLocalSocket::LocalSocketError error) { } void GreetdConnection::onSocketReady() { - qint32 length = 0; + while (true) { + this->reader.startTransaction(); + auto length = this->reader.readI32(); + auto text = this->reader.readBytes(length); + if (!this->reader.commitTransaction()) return; - this->socket.read(reinterpret_cast(&length), sizeof(qint32)); + auto json = QJsonDocument::fromJson(text).object(); + auto type = json.value("type").toString(); - auto text = this->socket.read(length); - auto json = QJsonDocument::fromJson(text).object(); - auto type = json.value("type").toString(); + qCDebug(logGreetd).noquote() << "Received greetd response:" << text; - qCDebug(logGreetd).noquote() << "Received greetd response:" << text; + if (type == "success") { + switch (this->mState) { + case GreetdState::Authenticating: + qCDebug(logGreetd) << "Authentication complete."; + this->mState = GreetdState::ReadyToLaunch; + emit this->stateChanged(); + emit this->readyToLaunch(); + break; + case GreetdState::Launching: + qCDebug(logGreetd) << "Target session set successfully."; + this->mState = GreetdState::Launched; + emit this->stateChanged(); + emit this->launched(); - if (type == "success") { - switch (this->mState) { - case GreetdState::Authenticating: - qCDebug(logGreetd) << "Authentication complete."; - this->mState = GreetdState::ReadyToLaunch; - emit this->stateChanged(); - emit this->readyToLaunch(); - break; - case GreetdState::Launching: - qCDebug(logGreetd) << "Target session set successfully."; - this->mState = GreetdState::Launched; - emit this->stateChanged(); - emit this->launched(); + if (this->mExitAfterLaunch) { + qCDebug(logGreetd) << "Quitting."; + EngineGeneration::currentGeneration()->quit(); + } - if (this->mExitAfterLaunch) { - qCDebug(logGreetd) << "Quitting."; - EngineGeneration::currentGeneration()->quit(); + break; + default: goto unexpected; + } + } else if (type == "error") { + auto errorType = json.value("error_type").toString(); + auto desc = json.value("description").toString(); + + // Special case this error in case a session was already running. + // This cancels and restarts the session. + if (errorType == "error" && desc == "a session is already being configured") { + qCDebug( + logGreetd + ) << "A session was already in progress, cancelling it and starting a new one."; + this->setActive(false); + this->setActive(true); + return; } - break; - default: goto unexpected; - } - } else if (type == "error") { - auto errorType = json.value("error_type").toString(); - auto desc = json.value("description").toString(); + if (errorType == "auth_error") { + emit this->authFailure(desc); + this->setActive(false); + } else if (errorType == "error") { + qCWarning(logGreetd) << "Greetd error occurred" << desc; + emit this->error(desc); + } else goto unexpected; - // Special case this error in case a session was already running. - // This cancels and restarts the session. - if (errorType == "error" && desc == "a session is already being configured") { - qCDebug(logGreetd - ) << "A session was already in progress, cancelling it and starting a new one."; - this->setActive(false); - this->setActive(true); - return; - } + // errors terminate the session + this->setInactive(); + } else if (type == "auth_message") { + auto message = json.value("auth_message").toString(); + auto type = json.value("auth_message_type").toString(); + auto error = type == "error"; + auto responseRequired = type == "visible" || type == "secret"; + auto echoResponse = type != "secret"; - if (errorType == "auth_error") { - emit this->authFailure(desc); - this->setActive(false); - } else if (errorType == "error") { - qCWarning(logGreetd) << "Greetd error occurred" << desc; - emit this->error(desc); + this->mResponseRequired = responseRequired; + emit this->authMessage(message, error, responseRequired, echoResponse); + + if (!responseRequired) { + this->sendRequest({{"type", "post_auth_message_response"}}); + } } else goto unexpected; - // errors terminate the session - this->setInactive(); - } else if (type == "auth_message") { - auto message = json.value("auth_message").toString(); - auto type = json.value("auth_message_type").toString(); - auto error = type == "error"; - auto responseRequired = type == "visible" || type == "secret"; - auto echoResponse = type != "secret"; - - this->mResponseRequired = responseRequired; - emit this->authMessage(message, error, responseRequired, echoResponse); - } else goto unexpected; - - return; -unexpected: - qCCritical(logGreetd) << "Received unexpected greetd response" << text; - this->setActive(false); + continue; + unexpected: + qCCritical(logGreetd) << "Received unexpected greetd response" << text; + this->setActive(false); + } } void GreetdConnection::sendRequest(const QJsonObject& json) { diff --git a/src/services/greetd/connection.hpp b/src/services/greetd/connection.hpp index 0c1d1eb..89348dc 100644 --- a/src/services/greetd/connection.hpp +++ b/src/services/greetd/connection.hpp @@ -8,6 +8,8 @@ #include #include +#include "../../core/streamreader.hpp" + ///! State of the Greetd connection. /// See @@Greetd.state. class GreetdState: public QObject { @@ -74,4 +76,5 @@ private: bool mResponseRequired = false; QString mUser; QLocalSocket socket; + StreamReader reader; }; diff --git a/src/services/mpris/player.cpp b/src/services/mpris/player.cpp index 6730572..fe8b349 100644 --- a/src/services/mpris/player.cpp +++ b/src/services/mpris/player.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include +#include "../../core/logcat.hpp" #include "../../dbus/properties.hpp" #include "dbus_player.h" #include "dbus_player_app.h" @@ -23,7 +25,7 @@ using namespace qs::dbus; namespace qs::service::mpris { namespace { -Q_LOGGING_CATEGORY(logMprisPlayer, "quickshell.service.mp.player", QtWarningMsg); +QS_LOGGING_CATEGORY(logMprisPlayer, "quickshell.service.mp.player", QtWarningMsg); } QString MprisPlaybackState::toString(MprisPlaybackState::Enum status) { @@ -98,41 +100,12 @@ MprisPlayer::MprisPlayer(const QString& address, QObject* parent): QObject(paren } else return static_cast(-1); }); - this->bPlaybackState.setBinding([this]() { - const auto& status = this->bpPlaybackStatus.value(); - - if (status == "Playing") { - return MprisPlaybackState::Playing; - } else if (status == "Paused") { - this->pausedTime = QDateTime::currentDateTimeUtc(); - return MprisPlaybackState::Paused; - } else if (status == "Stopped") { - return MprisPlaybackState::Stopped; - } else { - qWarning() << "Received unexpected PlaybackStatus for" << this << status; - return MprisPlaybackState::Stopped; - } - }); + this->bLengthSupported.setBinding([this]() { return this->bInternalLength.value() != -1; }); this->bIsPlaying.setBinding([this]() { return this->bPlaybackState == MprisPlaybackState::Playing; }); - this->bLoopState.setBinding([this]() { - const auto& status = this->bpLoopStatus.value(); - - if (status == "None") { - return MprisLoopState::None; - } else if (status == "Track") { - return MprisLoopState::Track; - } else if (status == "Playlist") { - return MprisLoopState::Playlist; - } else { - qWarning() << "Received unexpected LoopStatus for" << this << status; - return MprisLoopState::None; - } - }); - // clang-format off QObject::connect(this->player, &DBusMprisPlayer::Seeked, this, &MprisPlayer::onSeek); QObject::connect(&this->playerProperties, &DBusPropertyGroup::getAllFinished, this, &MprisPlayer::onGetAllFinished); @@ -257,21 +230,19 @@ void MprisPlayer::setPosition(qlonglong position) { } void MprisPlayer::onExportedPositionChanged() { - if (!this->lengthSupported()) emit this->lengthChanged(); + if (!this->bLengthSupported) emit this->lengthChanged(); } void MprisPlayer::onSeek(qlonglong time) { this->setPosition(time); } qreal MprisPlayer::length() const { - if (this->bInternalLength == -1) { + if (!this->bLengthSupported) { return this->position(); // unsupported } else { return static_cast(this->bInternalLength / 1000) / 1000; // NOLINT } } -bool MprisPlayer::lengthSupported() const { return this->bInternalLength != -1; } - bool MprisPlayer::volumeSupported() const { return this->pVolume.exists(); } void MprisPlayer::setVolume(qreal volume) { @@ -407,7 +378,7 @@ void MprisPlayer::onPlaybackStatusUpdated() { // For exceptionally bad players that update playback timestamps at an indeterminate time AFTER // updating playback state. (Youtube) - QTimer::singleShot(100, this, [&]() { this->pPosition.requestUpdate(); }); + QTimer::singleShot(100, this, [this]() { this->pPosition.requestUpdate(); }); // For exceptionally bad players that don't update length (or other metadata) until a new track actually // starts playing, and then don't trigger a metadata update when they do. (Jellyfin) @@ -431,18 +402,11 @@ void MprisPlayer::setLoopState(MprisLoopState::Enum loopState) { } if (loopState == this->bLoopState) return; - - QString loopStatusStr; - switch (loopState) { - case MprisLoopState::None: loopStatusStr = "None"; break; - case MprisLoopState::Track: loopStatusStr = "Track"; break; - case MprisLoopState::Playlist: loopStatusStr = "Playlist"; break; - default: + if (loopState < MprisLoopState::None || loopState > MprisLoopState::Playlist) { qWarning() << "Cannot set loopState of" << this << "to unknown value" << loopState; - return; } - this->bpLoopStatus = loopStatusStr; + this->bLoopState = loopState; this->pLoopStatus.write(); } @@ -495,3 +459,43 @@ void MprisPlayer::onGetAllFinished() { } } // namespace qs::service::mpris + +namespace qs::dbus { + +using namespace qs::service::mpris; + +DBusResult +DBusDataTransform::fromWire(const QString& wire) { + if (wire == "Playing") return MprisPlaybackState::Playing; + if (wire == "Paused") return MprisPlaybackState::Paused; + if (wire == "Stopped") return MprisPlaybackState::Stopped; + return QDBusError(QDBusError::InvalidArgs, QString("Invalid MprisPlaybackState: %1").arg(wire)); +} + +QString DBusDataTransform::toWire(MprisPlaybackState::Enum data) { + switch (data) { + case MprisPlaybackState::Playing: return "Playing"; + case MprisPlaybackState::Paused: return "Paused"; + case MprisPlaybackState::Stopped: return "Stopped"; + default: qFatal() << "Tried to convert an invalid MprisPlaybackState to String"; return QString(); + } +} + +DBusResult +DBusDataTransform::fromWire(const QString& wire) { + if (wire == "None") return MprisLoopState::None; + if (wire == "Track") return MprisLoopState::Track; + if (wire == "Playlist") return MprisLoopState::Playlist; + return QDBusError(QDBusError::InvalidArgs, QString("Invalid MprisLoopState: %1").arg(wire)); +} + +QString DBusDataTransform::toWire(MprisLoopState::Enum data) { + switch (data) { + case MprisLoopState::None: return "None"; + case MprisLoopState::Track: return "Track"; + case MprisLoopState::Playlist: return "Playlist"; + default: qFatal() << "Tried to convert an invalid MprisLoopState to String"; return QString(); + } +} + +} // namespace qs::dbus diff --git a/src/services/mpris/player.hpp b/src/services/mpris/player.hpp index a2ea59b..423453d 100644 --- a/src/services/mpris/player.hpp +++ b/src/services/mpris/player.hpp @@ -51,6 +51,30 @@ public: Q_INVOKABLE static QString toString(qs::service::mpris::MprisLoopState::Enum status); }; +}; // namespace qs::service::mpris + +namespace qs::dbus { + +template <> +struct DBusDataTransform { + using Wire = QString; + using Data = qs::service::mpris::MprisPlaybackState::Enum; + static DBusResult fromWire(const QString& wire); + static QString toWire(Data data); +}; + +template <> +struct DBusDataTransform { + using Wire = QString; + using Data = qs::service::mpris::MprisLoopState::Enum; + static DBusResult fromWire(const QString& wire); + static QString toWire(Data data); +}; + +}; // namespace qs::dbus + +namespace qs::service::mpris { + ///! A media player exposed over MPRIS. /// A media player exposed over MPRIS. /// @@ -117,7 +141,7 @@ class MprisPlayer: public QObject { /// The length of the playing track, as seconds, with millisecond precision, /// or the value of @@position if @@lengthSupported is false. Q_PROPERTY(qreal length READ length NOTIFY lengthChanged); - Q_PROPERTY(bool lengthSupported READ lengthSupported NOTIFY lengthSupportedChanged); + Q_PROPERTY(bool lengthSupported READ default NOTIFY lengthSupportedChanged BINDABLE bindableLengthSupported); /// The volume of the playing track from 0.0 to 1.0, or 1.0 if @@volumeSupported is false. /// /// May only be written to if @@canControl and @@volumeSupported are true. @@ -250,23 +274,23 @@ public: [[nodiscard]] bool isValid() const; [[nodiscard]] QString address() const; - [[nodiscard]] QBindable bindableCanControl() const { return &this->bCanControl; }; - [[nodiscard]] QBindable bindableCanSeek() const { return &this->bCanSeek; }; - [[nodiscard]] QBindable bindableCanGoNext() const { return &this->bCanGoNext; }; - [[nodiscard]] QBindable bindableCanGoPrevious() const { return &this->bCanGoPrevious; }; - [[nodiscard]] QBindable bindableCanPlay() const { return &this->bCanPlay; }; - [[nodiscard]] QBindable bindableCanPause() const { return &this->bCanPause; }; + [[nodiscard]] QBindable bindableCanControl() const { return &this->bCanControl; } + [[nodiscard]] QBindable bindableCanSeek() const { return &this->bCanSeek; } + [[nodiscard]] QBindable bindableCanGoNext() const { return &this->bCanGoNext; } + [[nodiscard]] QBindable bindableCanGoPrevious() const { return &this->bCanGoPrevious; } + [[nodiscard]] QBindable bindableCanPlay() const { return &this->bCanPlay; } + [[nodiscard]] QBindable bindableCanPause() const { return &this->bCanPause; } [[nodiscard]] QBindable bindableCanTogglePlaying() const { return &this->bCanTogglePlaying; - }; - [[nodiscard]] QBindable bindableCanQuit() const { return &this->bCanQuit; }; - [[nodiscard]] QBindable bindableCanRaise() const { return &this->bCanRaise; }; + } + [[nodiscard]] QBindable bindableCanQuit() const { return &this->bCanQuit; } + [[nodiscard]] QBindable bindableCanRaise() const { return &this->bCanRaise; } [[nodiscard]] QBindable bindableCanSetFullscreen() const { return &this->bCanSetFullscreen; - }; + } - [[nodiscard]] QBindable bindableIdentity() const { return &this->bIdentity; }; - [[nodiscard]] QBindable bindableDesktopEntry() const { return &this->bDesktopEntry; }; + [[nodiscard]] QBindable bindableIdentity() const { return &this->bIdentity; } + [[nodiscard]] QBindable bindableDesktopEntry() const { return &this->bDesktopEntry; } [[nodiscard]] qlonglong positionMs() const; [[nodiscard]] qreal position() const; @@ -274,51 +298,51 @@ public: void setPosition(qreal position); [[nodiscard]] qreal length() const; - [[nodiscard]] bool lengthSupported() const; + [[nodiscard]] QBindable bindableLengthSupported() const { return &this->bLengthSupported; } - [[nodiscard]] qreal volume() const { return this->bVolume; }; + [[nodiscard]] qreal volume() const { return this->bVolume; } [[nodiscard]] bool volumeSupported() const; void setVolume(qreal volume); - [[nodiscard]] QBindable bindableUniqueId() const { return &this->bUniqueId; }; - [[nodiscard]] QBindable bindableMetadata() const { return &this->bMetadata; }; - [[nodiscard]] QBindable bindableTrackTitle() const { return &this->bTrackTitle; }; - [[nodiscard]] QBindable bindableTrackAlbum() const { return &this->bTrackAlbum; }; + [[nodiscard]] QBindable bindableUniqueId() const { return &this->bUniqueId; } + [[nodiscard]] QBindable bindableMetadata() const { return &this->bMetadata; } + [[nodiscard]] QBindable bindableTrackTitle() const { return &this->bTrackTitle; } + [[nodiscard]] QBindable bindableTrackAlbum() const { return &this->bTrackAlbum; } [[nodiscard]] QBindable bindableTrackAlbumArtist() const { return &this->bTrackAlbumArtist; - }; - [[nodiscard]] QBindable bindableTrackArtist() const { return &this->bTrackArtist; }; - [[nodiscard]] QBindable bindableTrackArtUrl() const { return &this->bTrackArtUrl; }; + } + [[nodiscard]] QBindable bindableTrackArtist() const { return &this->bTrackArtist; } + [[nodiscard]] QBindable bindableTrackArtUrl() const { return &this->bTrackArtUrl; } - [[nodiscard]] MprisPlaybackState::Enum playbackState() const { return this->bPlaybackState; }; + [[nodiscard]] MprisPlaybackState::Enum playbackState() const { return this->bPlaybackState; } void setPlaybackState(MprisPlaybackState::Enum playbackState); - [[nodiscard]] bool isPlaying() const { return this->bIsPlaying; }; + [[nodiscard]] bool isPlaying() const { return this->bIsPlaying; } void setPlaying(bool playing); - [[nodiscard]] MprisLoopState::Enum loopState() const { return this->bLoopState; }; + [[nodiscard]] MprisLoopState::Enum loopState() const { return this->bLoopState; } [[nodiscard]] bool loopSupported() const; void setLoopState(MprisLoopState::Enum loopState); - [[nodiscard]] qreal rate() const { return this->bRate; }; - [[nodiscard]] QBindable bindableMinRate() const { return &this->bRate; }; - [[nodiscard]] QBindable bindableMaxRate() const { return &this->bRate; }; + [[nodiscard]] qreal rate() const { return this->bRate; } + [[nodiscard]] QBindable bindableMinRate() const { return &this->bRate; } + [[nodiscard]] QBindable bindableMaxRate() const { return &this->bRate; } void setRate(qreal rate); - [[nodiscard]] bool shuffle() const { return this->bShuffle; }; + [[nodiscard]] bool shuffle() const { return this->bShuffle; } [[nodiscard]] bool shuffleSupported() const; void setShuffle(bool shuffle); - [[nodiscard]] bool fullscreen() const { return this->bFullscreen; }; + [[nodiscard]] bool fullscreen() const { return this->bFullscreen; } void setFullscreen(bool fullscreen); [[nodiscard]] QBindable> bindableSupportedUriSchemes() const { return &this->bSupportedUriSchemes; - }; + } [[nodiscard]] QBindable> bindableSupportedMimeTypes() const { return &this->bSupportedMimeTypes; - }; + } signals: /// The track has changed. @@ -390,7 +414,7 @@ private: void onPlaybackStatusUpdated(); // call instead of setting bpPosition void setPosition(qlonglong position); - void requestPositionUpdate() { this->pPosition.requestUpdate(); }; + void requestPositionUpdate() { this->pPosition.requestUpdate(); } // clang-format off Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, QString, bIdentity, &MprisPlayer::identityChanged); @@ -404,13 +428,13 @@ private: QS_DBUS_BINDABLE_PROPERTY_GROUP(MprisPlayer, appProperties); QS_DBUS_PROPERTY_BINDING(MprisPlayer, pIdentity, bIdentity, appProperties, "Identity"); - QS_DBUS_PROPERTY_BINDING(MprisPlayer, pDesktopEntry, bDesktopEntry, appProperties, "DesktopEntry"); - QS_DBUS_PROPERTY_BINDING(MprisPlayer, pCanQuit, bCanQuit, appProperties, "CanQuit"); - QS_DBUS_PROPERTY_BINDING(MprisPlayer, pCanRaise, bCanRaise, appProperties, "CanRaise"); + QS_DBUS_PROPERTY_BINDING(MprisPlayer, pDesktopEntry, bDesktopEntry, appProperties, "DesktopEntry", false); + QS_DBUS_PROPERTY_BINDING(MprisPlayer, pCanQuit, bCanQuit, appProperties, "CanQuit", false); + QS_DBUS_PROPERTY_BINDING(MprisPlayer, pCanRaise, bCanRaise, appProperties, "CanRaise", false); QS_DBUS_PROPERTY_BINDING(MprisPlayer, pFullscreen, bFullscreen, appProperties, "Fullscreen", false); QS_DBUS_PROPERTY_BINDING(MprisPlayer, pCanSetFullscreen, bCanSetFullscreen, appProperties, "CanSetFullscreen", false); - QS_DBUS_PROPERTY_BINDING(MprisPlayer, pSupportedUriSchemes, bSupportedUriSchemes, appProperties, "SupportedUriSchemes"); - QS_DBUS_PROPERTY_BINDING(MprisPlayer, pSupportedMimeTypes, bSupportedMimeTypes, appProperties, "SupportedMimeTypes"); + QS_DBUS_PROPERTY_BINDING(MprisPlayer, pSupportedUriSchemes, bSupportedUriSchemes, appProperties, "SupportedUriSchemes", false); + QS_DBUS_PROPERTY_BINDING(MprisPlayer, pSupportedMimeTypes, bSupportedMimeTypes, appProperties, "SupportedMimeTypes", false); Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, bool, bpCanPlay); Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, bool, bpCanPause); @@ -420,8 +444,6 @@ private: Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, QVariantMap, bpMetadata); QS_BINDING_SUBSCRIBE_METHOD(MprisPlayer, bpMetadata, onMetadataChanged, onValueChanged); Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(MprisPlayer, qlonglong, bpPosition, -1, &MprisPlayer::positionChanged); - Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, QString, bpPlaybackStatus); - Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, QString, bpLoopStatus); Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, bool, bCanControl, &MprisPlayer::canControlChanged); Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, bool, bCanPlay, &MprisPlayer::canPlayChanged); @@ -447,6 +469,7 @@ private: Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, QString, bTrackAlbumArtist, &MprisPlayer::trackAlbumArtistChanged); Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, QString, bTrackArtUrl, &MprisPlayer::trackArtUrlChanged); Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, qlonglong, bInternalLength, &MprisPlayer::lengthChanged); + Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, bool, bLengthSupported, &MprisPlayer::lengthSupportedChanged); Q_OBJECT_BINDABLE_PROPERTY(MprisPlayer, bool, bShuffle, &MprisPlayer::shuffleChanged); QS_DBUS_BINDABLE_PROPERTY_GROUP(MprisPlayer, playerProperties); @@ -459,8 +482,8 @@ private: QS_DBUS_PROPERTY_BINDING(MprisPlayer, qlonglong, pPosition, bpPosition, onPositionUpdated, playerProperties, "Position", false); QS_DBUS_PROPERTY_BINDING(MprisPlayer, pVolume, bVolume, playerProperties, "Volume", false); QS_DBUS_PROPERTY_BINDING(MprisPlayer, pMetadata, bpMetadata, playerProperties, "Metadata"); - QS_DBUS_PROPERTY_BINDING(MprisPlayer, void, pPlaybackStatus, bpPlaybackStatus, onPlaybackStatusUpdated, playerProperties, "PlaybackStatus", true); - QS_DBUS_PROPERTY_BINDING(MprisPlayer, pLoopStatus, bpLoopStatus, playerProperties, "LoopStatus", false); + QS_DBUS_PROPERTY_BINDING(MprisPlayer, void, pPlaybackStatus, bPlaybackState, onPlaybackStatusUpdated, playerProperties, "PlaybackStatus", true); + QS_DBUS_PROPERTY_BINDING(MprisPlayer, pLoopStatus, bLoopState, playerProperties, "LoopStatus", false); QS_DBUS_PROPERTY_BINDING(MprisPlayer, pRate, bRate, playerProperties, "Rate", false); QS_DBUS_PROPERTY_BINDING(MprisPlayer, pMinRate, bMinRate, playerProperties, "MinimumRate", false); QS_DBUS_PROPERTY_BINDING(MprisPlayer, pMaxRate, bMaxRate, playerProperties, "MaximumRate", false); diff --git a/src/services/mpris/watcher.cpp b/src/services/mpris/watcher.cpp index 9461907..fdfe97a 100644 --- a/src/services/mpris/watcher.cpp +++ b/src/services/mpris/watcher.cpp @@ -9,13 +9,14 @@ #include #include +#include "../../core/logcat.hpp" #include "../../core/model.hpp" #include "player.hpp" namespace qs::service::mpris { namespace { -Q_LOGGING_CATEGORY(logMprisWatcher, "quickshell.service.mpris.watcher", QtWarningMsg); +QS_LOGGING_CATEGORY(logMprisWatcher, "quickshell.service.mpris.watcher", QtWarningMsg); } MprisWatcher::MprisWatcher() { diff --git a/src/services/mpris/watcher.hpp b/src/services/mpris/watcher.hpp index bd922cd..cbe9669 100644 --- a/src/services/mpris/watcher.hpp +++ b/src/services/mpris/watcher.hpp @@ -51,7 +51,7 @@ class MprisQml: public QObject { Q_PROPERTY(UntypedObjectModel* players READ players CONSTANT); public: - explicit MprisQml(QObject* parent = nullptr): QObject(parent) {}; + explicit MprisQml(QObject* parent = nullptr): QObject(parent) {} [[nodiscard]] ObjectModel* players(); }; diff --git a/src/services/notifications/CMakeLists.txt b/src/services/notifications/CMakeLists.txt index 0cbb42e..58b6648 100644 --- a/src/services/notifications/CMakeLists.txt +++ b/src/services/notifications/CMakeLists.txt @@ -23,7 +23,6 @@ qt_add_qml_module(quickshell-service-notifications ) qs_add_module_deps_light(quickshell-service-notifications Quickshell) - install_qml_module(quickshell-service-notifications) target_link_libraries(quickshell-service-notifications PRIVATE Qt::Quick Qt::DBus) diff --git a/src/services/notifications/dbusimage.cpp b/src/services/notifications/dbusimage.cpp index 9c10e22..469d08c 100644 --- a/src/services/notifications/dbusimage.cpp +++ b/src/services/notifications/dbusimage.cpp @@ -7,10 +7,12 @@ #include #include +#include "../../core/logcat.hpp" + namespace qs::service::notifications { // NOLINTNEXTLINE(misc-use-internal-linkage) -Q_DECLARE_LOGGING_CATEGORY(logNotifications); // server.cpp +QS_DECLARE_LOGGING_CATEGORY(logNotifications); // server.cpp QImage DBusNotificationImage::createImage() const { auto format = this->hasAlpha ? QImage::Format_RGBA8888 : QImage::Format_RGB888; @@ -40,10 +42,9 @@ const QDBusArgument& operator>>(const QDBusArgument& argument, DBusNotificationI } else if (channels != (pixmap.hasAlpha ? 4 : 3)) { qCWarning(logNotifications) << "Unable to parse pixmap as channel count is incorrect." << "Got " << channels << "expected" << (pixmap.hasAlpha ? 4 : 3); - } else if (rowstride != pixmap.width * sampleBits * channels) { + } else if (rowstride != pixmap.width * channels) { qCWarning(logNotifications) << "Unable to parse pixmap as rowstride is incorrect. Got" - << rowstride << "expected" - << (pixmap.width * sampleBits * channels); + << rowstride << "expected" << (pixmap.width * channels); } return argument; @@ -53,7 +54,7 @@ const QDBusArgument& operator<<(QDBusArgument& argument, const DBusNotificationI argument.beginStructure(); argument << pixmap.width; argument << pixmap.height; - argument << pixmap.width * (pixmap.hasAlpha ? 4 : 3) * 8; + argument << pixmap.width * (pixmap.hasAlpha ? 4 : 3); argument << pixmap.hasAlpha; argument << 8; argument << (pixmap.hasAlpha ? 4 : 3); diff --git a/src/services/notifications/notification.cpp b/src/services/notifications/notification.cpp index 742d607..d048bde 100644 --- a/src/services/notifications/notification.cpp +++ b/src/services/notifications/notification.cpp @@ -12,13 +12,14 @@ #include "../../core/desktopentry.hpp" #include "../../core/iconimageprovider.hpp" +#include "../../core/logcat.hpp" #include "dbusimage.hpp" #include "server.hpp" namespace qs::service::notifications { // NOLINTNEXTLINE(misc-use-internal-linkage) -Q_DECLARE_LOGGING_CATEGORY(logNotifications); // server.cpp +QS_DECLARE_LOGGING_CATEGORY(logNotifications); // server.cpp QString NotificationUrgency::toString(NotificationUrgency::Enum value) { switch (value) { @@ -77,6 +78,29 @@ void Notification::close(NotificationCloseReason::Enum reason) { } } +void Notification::sendInlineReply(const QString& replyText) { + if (!NotificationServer::instance()->support.inlineReply) { + qCritical() << "Inline reply support disabled on server"; + return; + } + + if (!this->bHasInlineReply) { + qCritical() << "Cannot send reply to notification without inline-reply action"; + return; + } + + if (this->isRetained()) { + qCritical() << "Cannot send reply to destroyed notification" << this; + return; + } + + NotificationServer::instance()->NotificationReplied(this->id(), replyText); + + if (!this->bindableResident().value()) { + this->close(NotificationCloseReason::Dismissed); + } +} + void Notification::updateProperties( const QString& appName, QString appIcon, @@ -103,7 +127,7 @@ void Notification::updateProperties( if (appIcon.isEmpty() && !this->bDesktopEntry.value().isEmpty()) { if (auto* entry = DesktopEntryManager::instance()->byId(this->bDesktopEntry.value())) { - appIcon = entry->mIcon; + appIcon = entry->bIcon.value(); } } @@ -146,17 +170,27 @@ void Notification::updateProperties( this->bImage = imagePath; this->bHints = hints; - Qt::endPropertyUpdateGroup(); - bool actionsChanged = false; auto deletedActions = QVector(); if (actions.length() % 2 == 0) { int ai = 0; for (auto i = 0; i != actions.length(); i += 2) { - ai = i / 2; const auto& identifier = actions.at(i); const auto& text = actions.at(i + 1); + + if (identifier == "inline-reply" && NotificationServer::instance()->support.inlineReply) { + if (this->bHasInlineReply) { + qCWarning(logNotifications) << this << '(' << appName << ')' + << "sent an action set with duplicate inline-reply actions."; + } else { + this->bHasInlineReply = true; + this->bInlineReplyPlaceholder = text; + } + // skip inserting this action into action list + continue; + } + auto* action = ai < this->mActions.length() ? this->mActions.at(ai) : nullptr; if (action && identifier == action->identifier()) { @@ -187,6 +221,8 @@ void Notification::updateProperties( << "sent an action set of an invalid length."; } + Qt::endPropertyUpdateGroup(); + if (actionsChanged) emit this->actionsChanged(); for (auto* action: deletedActions) { diff --git a/src/services/notifications/notification.hpp b/src/services/notifications/notification.hpp index f0c65bb..7f5246c 100644 --- a/src/services/notifications/notification.hpp +++ b/src/services/notifications/notification.hpp @@ -107,6 +107,12 @@ class Notification /// /// This image is often something like a profile picture in instant messaging applications. Q_PROPERTY(QString image READ default NOTIFY imageChanged BINDABLE bindableImage); + /// If true, the notification has an inline reply action. + /// + /// A quick reply text field should be displayed and the reply can be sent using @@sendInlineReply(). + Q_PROPERTY(bool hasInlineReply READ default NOTIFY hasInlineReplyChanged BINDABLE bindableHasInlineReply); + /// The placeholder text/button caption for the inline reply. + Q_PROPERTY(QString inlineReplyPlaceholder READ default NOTIFY inlineReplyPlaceholderChanged BINDABLE bindableInlineReplyPlaceholder); /// All hints sent by the client application as a javascript object. /// Many common hints are exposed via other properties. Q_PROPERTY(QVariantMap hints READ default NOTIFY hintsChanged BINDABLE bindableHints); @@ -124,6 +130,12 @@ public: /// explicitly closed by the user. Q_INVOKABLE void dismiss(); + /// Send an inline reply to the notification with an inline reply action. + /// > [!WARNING] This method can only be called if + /// > @@hasInlineReply is true + /// > and the server has @@NotificationServer.inlineReplySupported set to true. + Q_INVOKABLE void sendInlineReply(const QString& replyText); + void updateProperties( const QString& appName, QString appIcon, @@ -142,23 +154,29 @@ public: [[nodiscard]] bool isLastGeneration() const; void setLastGeneration(); - [[nodiscard]] QBindable bindableExpireTimeout() const { return &this->bExpireTimeout; }; - [[nodiscard]] QBindable bindableAppName() const { return &this->bAppName; }; - [[nodiscard]] QBindable bindableAppIcon() const { return &this->bAppIcon; }; - [[nodiscard]] QBindable bindableSummary() const { return &this->bSummary; }; - [[nodiscard]] QBindable bindableBody() const { return &this->bBody; }; + [[nodiscard]] QBindable bindableExpireTimeout() const { return &this->bExpireTimeout; } + [[nodiscard]] QBindable bindableAppName() const { return &this->bAppName; } + [[nodiscard]] QBindable bindableAppIcon() const { return &this->bAppIcon; } + [[nodiscard]] QBindable bindableSummary() const { return &this->bSummary; } + [[nodiscard]] QBindable bindableBody() const { return &this->bBody; } [[nodiscard]] QBindable bindableUrgency() const { return &this->bUrgency; - }; + } [[nodiscard]] QList actions() const; - [[nodiscard]] QBindable bindableHasActionIcons() const { return &this->bHasActionIcons; }; - [[nodiscard]] QBindable bindableResident() const { return &this->bResident; }; - [[nodiscard]] QBindable bindableTransient() const { return &this->bTransient; }; - [[nodiscard]] QBindable bindableDesktopEntry() const { return &this->bDesktopEntry; }; - [[nodiscard]] QBindable bindableImage() const { return &this->bImage; }; - [[nodiscard]] QBindable bindableHints() const { return &this->bHints; }; + [[nodiscard]] QBindable bindableHasActionIcons() const { return &this->bHasActionIcons; } + [[nodiscard]] QBindable bindableResident() const { return &this->bResident; } + [[nodiscard]] QBindable bindableTransient() const { return &this->bTransient; } + [[nodiscard]] QBindable bindableDesktopEntry() const { return &this->bDesktopEntry; } + [[nodiscard]] QBindable bindableImage() const { return &this->bImage; } + [[nodiscard]] QBindable bindableHasInlineReply() const { return &this->bHasInlineReply; } + + [[nodiscard]] QBindable bindableInlineReplyPlaceholder() const { + return &this->bInlineReplyPlaceholder; + } + + [[nodiscard]] QBindable bindableHints() const { return &this->bHints; } [[nodiscard]] NotificationCloseReason::Enum closeReason() const; void setTracked(bool tracked); @@ -182,6 +200,8 @@ signals: void transientChanged(); void desktopEntryChanged(); void imageChanged(); + void hasInlineReplyChanged(); + void inlineReplyPlaceholderChanged(); void hintsChanged(); private: @@ -202,6 +222,8 @@ private: Q_OBJECT_BINDABLE_PROPERTY(Notification, bool, bTransient, &Notification::transientChanged); Q_OBJECT_BINDABLE_PROPERTY(Notification, QString, bDesktopEntry, &Notification::desktopEntryChanged); Q_OBJECT_BINDABLE_PROPERTY(Notification, QString, bImage, &Notification::imageChanged); + Q_OBJECT_BINDABLE_PROPERTY(Notification, bool, bHasInlineReply, &Notification::hasInlineReplyChanged); + Q_OBJECT_BINDABLE_PROPERTY(Notification, QString, bInlineReplyPlaceholder, &Notification::inlineReplyPlaceholderChanged); Q_OBJECT_BINDABLE_PROPERTY(Notification, QVariantMap, bHints, &Notification::hintsChanged); // clang-format on diff --git a/src/services/notifications/org.freedesktop.Notifications.xml b/src/services/notifications/org.freedesktop.Notifications.xml index 1a2001f..3d99db0 100644 --- a/src/services/notifications/org.freedesktop.Notifications.xml +++ b/src/services/notifications/org.freedesktop.Notifications.xml @@ -38,6 +38,11 @@ + + + + + diff --git a/src/services/notifications/qml.cpp b/src/services/notifications/qml.cpp index 9981821..42bb23a 100644 --- a/src/services/notifications/qml.cpp +++ b/src/services/notifications/qml.cpp @@ -115,6 +115,15 @@ void NotificationServerQml::setImageSupported(bool imageSupported) { emit this->imageSupportedChanged(); } +bool NotificationServerQml::inlineReplySupported() const { return this->support.inlineReply; } + +void NotificationServerQml::setInlineReplySupported(bool inlineReplySupported) { + if (inlineReplySupported == this->support.inlineReply) return; + this->support.inlineReply = inlineReplySupported; + this->updateSupported(); + emit this->inlineReplySupportedChanged(); +} + QVector NotificationServerQml::extraHints() const { return this->support.extraHints; } void NotificationServerQml::setExtraHints(QVector extraHints) { diff --git a/src/services/notifications/qml.hpp b/src/services/notifications/qml.hpp index d750210..88132c7 100644 --- a/src/services/notifications/qml.hpp +++ b/src/services/notifications/qml.hpp @@ -21,9 +21,7 @@ namespace qs::service::notifications { /// The server does not advertise most capabilities by default. See the individual properties for details. /// /// [Desktop Notifications Specification]: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html -class NotificationServerQml - : public QObject - , public PostReloadHook { +class NotificationServerQml: public PostReloadHook { Q_OBJECT; // clang-format off /// If notifications should be re-emitted when quickshell reloads. Defaults to true. @@ -67,6 +65,8 @@ class NotificationServerQml Q_PROPERTY(bool actionIconsSupported READ actionIconsSupported WRITE setActionIconsSupported NOTIFY actionIconsSupportedChanged); /// If the notification server should advertise that it supports images. Defaults to false. Q_PROPERTY(bool imageSupported READ imageSupported WRITE setImageSupported NOTIFY imageSupportedChanged); + /// If the notification server should advertise that it supports inline replies. Defaults to false. + Q_PROPERTY(bool inlineReplySupported READ inlineReplySupported WRITE setInlineReplySupported NOTIFY inlineReplySupportedChanged); /// All notifications currently tracked by the server. QSDOC_TYPE_OVERRIDE(ObjectModel*); Q_PROPERTY(UntypedObjectModel* trackedNotifications READ trackedNotifications NOTIFY trackedNotificationsChanged); @@ -105,6 +105,9 @@ public: [[nodiscard]] bool imageSupported() const; void setImageSupported(bool imageSupported); + [[nodiscard]] bool inlineReplySupported() const; + void setInlineReplySupported(bool inlineReplySupported); + [[nodiscard]] QVector extraHints() const; void setExtraHints(QVector extraHints); @@ -125,6 +128,7 @@ signals: void actionsSupportedChanged(); void actionIconsSupportedChanged(); void imageSupportedChanged(); + void inlineReplySupportedChanged(); void extraHintsChanged(); void trackedNotificationsChanged(); diff --git a/src/services/notifications/server.cpp b/src/services/notifications/server.cpp index 0c41fb8..d2b55d0 100644 --- a/src/services/notifications/server.cpp +++ b/src/services/notifications/server.cpp @@ -12,6 +12,7 @@ #include #include +#include "../../core/logcat.hpp" #include "../../core/model.hpp" #include "dbus_notifications.h" #include "dbusimage.hpp" @@ -20,7 +21,7 @@ namespace qs::service::notifications { // NOLINTNEXTLINE(misc-use-internal-linkage) -Q_LOGGING_CATEGORY(logNotifications, "quickshell.service.notifications"); +QS_LOGGING_CATEGORY(logNotifications, "quickshell.service.notifications", QtWarningMsg); NotificationServer::NotificationServer() { qDBusRegisterMetaType(); @@ -116,10 +117,12 @@ void NotificationServer::tryRegister() { if (success) { qCInfo(logNotifications) << "Registered notification server with dbus."; } else { - qCWarning(logNotifications + qCWarning( + logNotifications ) << "Could not register notification server at org.freedesktop.Notifications, presumably " "because one is already registered."; - qCWarning(logNotifications + qCWarning( + logNotifications ) << "Registration will be attempted again if the active service is unregistered."; } } @@ -154,6 +157,7 @@ QStringList NotificationServer::GetCapabilities() const { } if (this->support.image) capabilities += "icon-static"; + if (this->support.inlineReply) capabilities += "inline-reply"; capabilities += this->support.extraHints; diff --git a/src/services/notifications/server.hpp b/src/services/notifications/server.hpp index 8c20943..8bd92a3 100644 --- a/src/services/notifications/server.hpp +++ b/src/services/notifications/server.hpp @@ -23,6 +23,7 @@ struct NotificationServerSupport { bool actions = false; bool actionIcons = false; bool image = false; + bool inlineReply = false; QVector extraHints; }; @@ -60,6 +61,7 @@ signals: // NOLINTBEGIN void NotificationClosed(quint32 id, quint32 reason); void ActionInvoked(quint32 id, QString action); + void NotificationReplied(quint32 id, QString replyText); // NOLINTEND private slots: diff --git a/src/services/pam/conversation.cpp b/src/services/pam/conversation.cpp index 07dbd59..1fb4c04 100644 --- a/src/services/pam/conversation.cpp +++ b/src/services/pam/conversation.cpp @@ -6,11 +6,16 @@ #include #include #include +#include #include +#ifdef __FreeBSD__ +#include +#endif +#include "../../core/logcat.hpp" #include "ipc.hpp" -Q_LOGGING_CATEGORY(logPam, "quickshell.service.pam", QtWarningMsg); +QS_LOGGING_CATEGORY(logPam, "quickshell.service.pam", QtWarningMsg); QString PamError::toString(PamError::Enum value) { switch (value) { diff --git a/src/services/pam/conversation.hpp b/src/services/pam/conversation.hpp index d0f2d97..779e6f4 100644 --- a/src/services/pam/conversation.hpp +++ b/src/services/pam/conversation.hpp @@ -8,9 +8,10 @@ #include #include +#include "../../core/logcat.hpp" #include "ipc.hpp" -Q_DECLARE_LOGGING_CATEGORY(logPam); +QS_DECLARE_LOGGING_CATEGORY(logPam); ///! The result of an authentication. /// See @@PamContext.completed(s). diff --git a/src/services/pam/qml.hpp b/src/services/pam/qml.hpp index 805e04c..a36184e 100644 --- a/src/services/pam/qml.hpp +++ b/src/services/pam/qml.hpp @@ -6,7 +6,11 @@ #include #include #include +#ifdef __FreeBSD__ +#include +#else #include +#endif #include #include "conversation.hpp" @@ -17,6 +21,9 @@ class PamContext : public QObject , public QQmlParserStatus { Q_OBJECT; + QML_ELEMENT; + Q_INTERFACES(QQmlParserStatus); + // clang-format off /// If the pam context is actively performing an authentication. /// @@ -32,6 +39,8 @@ class PamContext /// /// The configuration directory is resolved relative to the current file if not an absolute path. /// + /// On FreeBSD this property is ignored as the pam configuration directory cannot be changed. + /// /// This property may not be set while @@active is true. Q_PROPERTY(QString configDirectory READ configDirectory WRITE setConfigDirectory NOTIFY configDirectoryChanged); /// The user to authenticate as. If unset the current user will be used. @@ -49,7 +58,6 @@ class PamContext /// If the user's response should be visible. Only valid when @@responseRequired is true. Q_PROPERTY(bool responseVisible READ isResponseVisible NOTIFY responseVisibleChanged); // clang-format on - QML_ELEMENT; public: explicit PamContext(QObject* parent = nullptr): QObject(parent) {} diff --git a/src/services/pam/subprocess.cpp b/src/services/pam/subprocess.cpp index f99b279..dc36228 100644 --- a/src/services/pam/subprocess.cpp +++ b/src/services/pam/subprocess.cpp @@ -7,7 +7,11 @@ #include #include #include +#ifdef __FreeBSD__ +#include +#else #include +#endif #include #include @@ -83,7 +87,11 @@ PamIpcExitCode PamSubprocess::exec(const char* configDir, const char* config, co logIf(this->log) << "Starting pam session for user \"" << user << "\" with config \"" << config << "\" in dir \"" << configDir << "\"" << std::endl; +#ifdef __FreeBSD__ + auto result = pam_start(config, user, &conv, &handle); +#else auto result = pam_start_confdir(config, user, &conv, configDir, &handle); +#endif if (result != PAM_SUCCESS) { logIf(true) << "Unable to start pam conversation with error \"" << pam_strerror(handle, result) diff --git a/src/services/pipewire/CMakeLists.txt b/src/services/pipewire/CMakeLists.txt index fddca6f..fe894c9 100644 --- a/src/services/pipewire/CMakeLists.txt +++ b/src/services/pipewire/CMakeLists.txt @@ -3,6 +3,7 @@ pkg_check_modules(pipewire REQUIRED IMPORTED_TARGET libpipewire-0.3) qt_add_library(quickshell-service-pipewire STATIC qml.cpp + peak.cpp core.cpp connection.cpp registry.cpp diff --git a/src/services/pipewire/connection.cpp b/src/services/pipewire/connection.cpp index ac4c5e6..c2f505f 100644 --- a/src/services/pipewire/connection.cpp +++ b/src/services/pipewire/connection.cpp @@ -1,15 +1,137 @@ #include "connection.hpp" +#include +#include +#include +#include +#include #include +#include +#include + +#include "../../core/logcat.hpp" +#include "core.hpp" namespace qs::service::pipewire { +namespace { +QS_LOGGING_CATEGORY(logConnection, "quickshell.service.pipewire.connection", QtWarningMsg); +} + PwConnection::PwConnection(QObject* parent): QObject(parent) { - if (this->core.isValid()) { - this->registry.init(this->core); + this->runtimeDir = PwConnection::resolveRuntimeDir(); + + QObject::connect(&this->core, &PwCore::fatalError, this, &PwConnection::queueFatalError); + + if (!this->tryConnect(false) + && qEnvironmentVariableIntValue("QS_PIPEWIRE_IMMEDIATE_RECONNECT") == 1) + { + this->beginReconnect(); } } +QString PwConnection::resolveRuntimeDir() { + auto runtimeDir = qEnvironmentVariable("PIPEWIRE_RUNTIME_DIR"); + if (runtimeDir.isEmpty()) { + runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + } + + if (runtimeDir.isEmpty()) { + runtimeDir = QString("/run/user/%1").arg(getuid()); + } + + return runtimeDir; +} + +void PwConnection::beginReconnect() { + if (this->core.isValid()) { + this->stopSocketWatcher(); + return; + } + + if (!qEnvironmentVariableIsEmpty("PIPEWIRE_REMOTE")) return; + + if (this->runtimeDir.isEmpty()) { + qCWarning( + logConnection + ) << "Cannot watch runtime dir for pipewire reconnects: runtime dir is empty."; + return; + } + + this->startSocketWatcher(); + this->tryConnect(true); +} + +bool PwConnection::tryConnect(bool retry) { + if (this->core.isValid()) return true; + + qCDebug(logConnection) << "Attempting reconnect..."; + if (!this->core.start(retry)) { + return false; + } + + qCInfo(logConnection) << "Connection established"; + this->stopSocketWatcher(); + + this->registry.init(this->core); + return true; +} + +void PwConnection::startSocketWatcher() { + if (this->socketWatcher != nullptr) return; + if (!qEnvironmentVariableIsEmpty("PIPEWIRE_REMOTE")) return; + + auto dir = QDir(this->runtimeDir); + if (!dir.exists()) { + qCWarning(logConnection) << "Cannot wait for a new pipewire socket, runtime dir does not exist:" + << this->runtimeDir; + return; + } + + this->socketWatcher = new QFileSystemWatcher(this); + this->socketWatcher->addPath(this->runtimeDir); + + QObject::connect( + this->socketWatcher, + &QFileSystemWatcher::directoryChanged, + this, + &PwConnection::onRuntimeDirChanged + ); +} + +void PwConnection::stopSocketWatcher() { + if (this->socketWatcher == nullptr) return; + + this->socketWatcher->deleteLater(); + this->socketWatcher = nullptr; +} + +void PwConnection::queueFatalError() { + if (this->fatalErrorQueued) return; + + this->fatalErrorQueued = true; + QMetaObject::invokeMethod(this, &PwConnection::onFatalError, Qt::QueuedConnection); +} + +void PwConnection::onFatalError() { + this->fatalErrorQueued = false; + + this->defaults.reset(); + this->registry.reset(); + this->core.shutdown(); + + this->beginReconnect(); +} + +void PwConnection::onRuntimeDirChanged(const QString& /*path*/) { + if (this->core.isValid()) { + this->stopSocketWatcher(); + return; + } + + this->tryConnect(true); +} + PwConnection* PwConnection::instance() { static PwConnection* instance = nullptr; // NOLINT diff --git a/src/services/pipewire/connection.hpp b/src/services/pipewire/connection.hpp index 2b3e860..d0374f8 100644 --- a/src/services/pipewire/connection.hpp +++ b/src/services/pipewire/connection.hpp @@ -1,9 +1,13 @@ #pragma once +#include + #include "core.hpp" #include "defaults.hpp" #include "registry.hpp" +class QFileSystemWatcher; + namespace qs::service::pipewire { class PwConnection: public QObject { @@ -18,6 +22,23 @@ public: static PwConnection* instance(); private: + static QString resolveRuntimeDir(); + + void beginReconnect(); + bool tryConnect(bool retry); + void startSocketWatcher(); + void stopSocketWatcher(); + +private slots: + void queueFatalError(); + void onFatalError(); + void onRuntimeDirChanged(const QString& path); + +private: + QString runtimeDir; + QFileSystemWatcher* socketWatcher = nullptr; + bool fatalErrorQueued = false; + // init/destroy order is important. do not rearrange. PwCore core; }; diff --git a/src/services/pipewire/core.cpp b/src/services/pipewire/core.cpp index 9c2a3db..5077abe 100644 --- a/src/services/pipewire/core.cpp +++ b/src/services/pipewire/core.cpp @@ -14,10 +14,12 @@ #include #include +#include "../../core/logcat.hpp" + namespace qs::service::pipewire { namespace { -Q_LOGGING_CATEGORY(logLoop, "quickshell.service.pipewire.loop", QtWarningMsg); +QS_LOGGING_CATEGORY(logLoop, "quickshell.service.pipewire.loop", QtWarningMsg); } const pw_core_events PwCore::EVENTS = { @@ -25,7 +27,7 @@ const pw_core_events PwCore::EVENTS = { .info = nullptr, .done = &PwCore::onSync, .ping = nullptr, - .error = nullptr, + .error = &PwCore::onError, .remove_id = nullptr, .bound_id = nullptr, .add_mem = nullptr, @@ -34,26 +36,46 @@ const pw_core_events PwCore::EVENTS = { }; PwCore::PwCore(QObject* parent): QObject(parent), notifier(QSocketNotifier::Read) { - qCInfo(logLoop) << "Creating pipewire event loop."; pw_init(nullptr, nullptr); +} + +bool PwCore::start(bool retry) { + if (this->core != nullptr) return true; + + qCInfo(logLoop) << "Creating pipewire event loop."; this->loop = pw_loop_new(nullptr); if (this->loop == nullptr) { - qCCritical(logLoop) << "Failed to create pipewire event loop."; - return; + if (retry) { + qCInfo(logLoop) << "Failed to create pipewire event loop."; + } else { + qCCritical(logLoop) << "Failed to create pipewire event loop."; + } + this->shutdown(); + return false; } this->context = pw_context_new(this->loop, nullptr, 0); if (this->context == nullptr) { - qCCritical(logLoop) << "Failed to create pipewire context."; - return; + if (retry) { + qCInfo(logLoop) << "Failed to create pipewire context."; + } else { + qCCritical(logLoop) << "Failed to create pipewire context."; + } + this->shutdown(); + return false; } qCInfo(logLoop) << "Connecting to pipewire server."; this->core = pw_context_connect(this->context, nullptr, 0); if (this->core == nullptr) { - qCCritical(logLoop) << "Failed to connect pipewire context. Errno:" << errno; - return; + if (retry) { + qCInfo(logLoop) << "Failed to connect pipewire context. Errno:" << errno; + } else { + qCCritical(logLoop) << "Failed to connect pipewire context. Errno:" << errno; + } + this->shutdown(); + return false; } pw_core_add_listener(this->core, &this->listener.hook, &PwCore::EVENTS, this); @@ -64,22 +86,34 @@ PwCore::PwCore(QObject* parent): QObject(parent), notifier(QSocketNotifier::Read this->notifier.setSocket(fd); QObject::connect(&this->notifier, &QSocketNotifier::activated, this, &PwCore::poll); this->notifier.setEnabled(true); + + return true; +} + +void PwCore::shutdown() { + if (this->core != nullptr) { + this->listener.remove(); + pw_core_disconnect(this->core); + this->core = nullptr; + } + + if (this->context != nullptr) { + pw_context_destroy(this->context); + this->context = nullptr; + } + + if (this->loop != nullptr) { + pw_loop_destroy(this->loop); + this->loop = nullptr; + } + + this->notifier.setEnabled(false); + QObject::disconnect(&this->notifier, nullptr, this, nullptr); } PwCore::~PwCore() { qCInfo(logLoop) << "Destroying PwCore."; - - if (this->loop != nullptr) { - if (this->context != nullptr) { - if (this->core != nullptr) { - pw_core_disconnect(this->core); - } - - pw_context_destroy(this->context); - } - - pw_loop_destroy(this->loop); - } + this->shutdown(); } bool PwCore::isValid() const { @@ -88,6 +122,7 @@ bool PwCore::isValid() const { } void PwCore::poll() { + if (this->loop == nullptr) return; qCDebug(logLoop) << "Pipewire event loop received new events, iterating."; // Spin pw event loop. pw_loop_iterate(this->loop, 0); @@ -105,6 +140,23 @@ void PwCore::onSync(void* data, quint32 id, qint32 seq) { emit self->synced(id, seq); } +void PwCore::onError(void* data, quint32 id, qint32 /*seq*/, qint32 res, const char* message) { + auto* self = static_cast(data); + + // Pipewire's documentation describes the error event as being fatal, however it isn't. + // We're not sure what causes these ENOENTs on device removal, presumably something in + // the teardown sequence, but they're harmless. Attempting to handle them as a fatal + // error causes unnecessary triggers for shells. + if (res == -ENOENT) { + qCDebug(logLoop) << "Pipewire ENOENT on object" << id << "with code" << res << message; + return; + } + + qCWarning(logLoop) << "Pipewire error on object" << id << "with code" << res << message; + + emit self->fatalError(); +} + SpaHook::SpaHook() { // NOLINT spa_zero(this->hook); } diff --git a/src/services/pipewire/core.hpp b/src/services/pipewire/core.hpp index 262e2d3..967efaf 100644 --- a/src/services/pipewire/core.hpp +++ b/src/services/pipewire/core.hpp @@ -30,6 +30,9 @@ public: ~PwCore() override; Q_DISABLE_COPY_MOVE(PwCore); + bool start(bool retry); + void shutdown(); + [[nodiscard]] bool isValid() const; [[nodiscard]] qint32 sync(quint32 id) const; @@ -40,6 +43,7 @@ public: signals: void polled(); void synced(quint32 id, qint32 seq); + void fatalError(); private slots: void poll(); @@ -48,6 +52,7 @@ private: static const pw_core_events EVENTS; static void onSync(void* data, quint32 id, qint32 seq); + static void onError(void* data, quint32 id, qint32 seq, qint32 res, const char* message); QSocketNotifier notifier; SpaHook listener; diff --git a/src/services/pipewire/defaults.cpp b/src/services/pipewire/defaults.cpp index 9ff37e0..b9c8e35 100644 --- a/src/services/pipewire/defaults.cpp +++ b/src/services/pipewire/defaults.cpp @@ -11,15 +11,18 @@ #include #include -#include "../../core/util.hpp" +#include "../../core/logcat.hpp" #include "metadata.hpp" #include "node.hpp" #include "registry.hpp" +// This and spa_json_init are part of json-core.h, which is missing from older pw versions. +struct spa_json; + namespace qs::service::pipewire { namespace { -Q_LOGGING_CATEGORY(logDefaults, "quickshell.service.pipewire.defaults", QtWarningMsg); +QS_LOGGING_CATEGORY(logDefaults, "quickshell.service.pipewire.defaults", QtWarningMsg); } PwDefaultTracker::PwDefaultTracker(PwRegistry* registry): registry(registry) { @@ -27,6 +30,22 @@ PwDefaultTracker::PwDefaultTracker(PwRegistry* registry): registry(registry) { QObject::connect(registry, &PwRegistry::nodeAdded, this, &PwDefaultTracker::onNodeAdded); } +void PwDefaultTracker::reset() { + if (auto* meta = this->defaultsMetadata.object()) { + QObject::disconnect(meta, nullptr, this, nullptr); + } + + this->defaultsMetadata.setObject(nullptr); + this->setDefaultSink(nullptr); + this->setDefaultSinkName(QString()); + this->setDefaultSource(nullptr); + this->setDefaultSourceName(QString()); + this->setDefaultConfiguredSink(nullptr); + this->setDefaultConfiguredSinkName(QString()); + this->setDefaultConfiguredSource(nullptr); + this->setDefaultConfiguredSourceName(QString()); +} + void PwDefaultTracker::onMetadataAdded(PwMetadata* metadata) { if (metadata->name() == "default") { qCDebug(logDefaults) << "Got new defaults metadata object" << metadata; @@ -72,7 +91,7 @@ void PwDefaultTracker::onMetadataProperty(const char* key, const char* type, con if (type != nullptr && value != nullptr && strcmp(type, "Spa:String:JSON") == 0) { auto failed = true; auto iter = std::array(); - spa_json_init(&iter[0], value, strlen(value)); + spa_json_init(&iter[0], value, strlen(value)); // NOLINT (misc-include-cleaner) if (spa_json_enter_object(&iter[0], &iter[1]) > 0) { auto buf = std::array(); @@ -118,32 +137,6 @@ void PwDefaultTracker::onNodeAdded(PwNode* node) { } } -void PwDefaultTracker::onNodeDestroyed(QObject* node) { - if (node == this->mDefaultSink) { - qCInfo(logDefaults) << "Default sink destroyed."; - this->mDefaultSink = nullptr; - emit this->defaultSinkChanged(); - } - - if (node == this->mDefaultSource) { - qCInfo(logDefaults) << "Default source destroyed."; - this->mDefaultSource = nullptr; - emit this->defaultSourceChanged(); - } - - if (node == this->mDefaultConfiguredSink) { - qCInfo(logDefaults) << "Default configured sink destroyed."; - this->mDefaultConfiguredSink = nullptr; - emit this->defaultConfiguredSinkChanged(); - } - - if (node == this->mDefaultConfiguredSource) { - qCInfo(logDefaults) << "Default configured source destroyed."; - this->mDefaultConfiguredSource = nullptr; - emit this->defaultConfiguredSourceChanged(); - } -} - void PwDefaultTracker::changeConfiguredSink(PwNode* node) { if (node != nullptr) { if (!node->type.testFlags(PwNodeType::AudioSink)) { @@ -197,7 +190,8 @@ bool PwDefaultTracker::setConfiguredDefault(const char* key, const QString& valu } if (!meta->hasSetPermission()) { - qCCritical(logDefaults + qCCritical( + logDefaults ) << "Cannot set default node as write+execute permissions are missing for" << meta; return false; @@ -219,10 +213,34 @@ void PwDefaultTracker::setDefaultSink(PwNode* node) { if (node == this->mDefaultSink) return; qCInfo(logDefaults) << "Default sink changed to" << node; - setSimpleObjectHandle< - &PwDefaultTracker::mDefaultSink, - &PwDefaultTracker::onNodeDestroyed, - &PwDefaultTracker::defaultSinkChanged>(this, node); + if (this->mDefaultSink != nullptr) { + // Targeted disconnect is used because this can also be the default configured sink. + QObject::disconnect( + this->mDefaultSink, + &PwBindableObject::destroying, + this, + &PwDefaultTracker::onDefaultSinkDestroyed + ); + } + + this->mDefaultSink = node; + + if (node != nullptr) { + QObject::connect( + node, + &PwBindableObject::destroying, + this, + &PwDefaultTracker::onDefaultSinkDestroyed + ); + } + + emit this->defaultSinkChanged(); +} + +void PwDefaultTracker::onDefaultSinkDestroyed() { + qCInfo(logDefaults) << "Default sink destroyed."; + this->mDefaultSink = nullptr; + emit this->defaultSinkChanged(); } void PwDefaultTracker::setDefaultSinkName(const QString& name) { @@ -236,10 +254,34 @@ void PwDefaultTracker::setDefaultSource(PwNode* node) { if (node == this->mDefaultSource) return; qCInfo(logDefaults) << "Default source changed to" << node; - setSimpleObjectHandle< - &PwDefaultTracker::mDefaultSource, - &PwDefaultTracker::onNodeDestroyed, - &PwDefaultTracker::defaultSourceChanged>(this, node); + if (this->mDefaultSource != nullptr) { + // Targeted disconnect is used because this can also be the default configured source. + QObject::disconnect( + this->mDefaultSource, + &PwBindableObject::destroying, + this, + &PwDefaultTracker::onDefaultSourceDestroyed + ); + } + + this->mDefaultSource = node; + + if (node != nullptr) { + QObject::connect( + node, + &PwBindableObject::destroying, + this, + &PwDefaultTracker::onDefaultSourceDestroyed + ); + } + + emit this->defaultSourceChanged(); +} + +void PwDefaultTracker::onDefaultSourceDestroyed() { + qCInfo(logDefaults) << "Default source destroyed."; + this->mDefaultSource = nullptr; + emit this->defaultSourceChanged(); } void PwDefaultTracker::setDefaultSourceName(const QString& name) { @@ -253,10 +295,34 @@ void PwDefaultTracker::setDefaultConfiguredSink(PwNode* node) { if (node == this->mDefaultConfiguredSink) return; qCInfo(logDefaults) << "Default configured sink changed to" << node; - setSimpleObjectHandle< - &PwDefaultTracker::mDefaultConfiguredSink, - &PwDefaultTracker::onNodeDestroyed, - &PwDefaultTracker::defaultConfiguredSinkChanged>(this, node); + if (this->mDefaultConfiguredSink != nullptr) { + // Targeted disconnect is used because this can also be the default sink. + QObject::disconnect( + this->mDefaultConfiguredSink, + &PwBindableObject::destroying, + this, + &PwDefaultTracker::onDefaultConfiguredSinkDestroyed + ); + } + + this->mDefaultConfiguredSink = node; + + if (node != nullptr) { + QObject::connect( + node, + &PwBindableObject::destroying, + this, + &PwDefaultTracker::onDefaultConfiguredSinkDestroyed + ); + } + + emit this->defaultConfiguredSinkChanged(); +} + +void PwDefaultTracker::onDefaultConfiguredSinkDestroyed() { + qCInfo(logDefaults) << "Default configured sink destroyed."; + this->mDefaultConfiguredSink = nullptr; + emit this->defaultConfiguredSinkChanged(); } void PwDefaultTracker::setDefaultConfiguredSinkName(const QString& name) { @@ -270,10 +336,34 @@ void PwDefaultTracker::setDefaultConfiguredSource(PwNode* node) { if (node == this->mDefaultConfiguredSource) return; qCInfo(logDefaults) << "Default configured source changed to" << node; - setSimpleObjectHandle< - &PwDefaultTracker::mDefaultConfiguredSource, - &PwDefaultTracker::onNodeDestroyed, - &PwDefaultTracker::defaultConfiguredSourceChanged>(this, node); + if (this->mDefaultConfiguredSource != nullptr) { + // Targeted disconnect is used because this can also be the default source. + QObject::disconnect( + this->mDefaultConfiguredSource, + &PwBindableObject::destroying, + this, + &PwDefaultTracker::onDefaultConfiguredSourceDestroyed + ); + } + + this->mDefaultConfiguredSource = node; + + if (node != nullptr) { + QObject::connect( + node, + &PwBindableObject::destroying, + this, + &PwDefaultTracker::onDefaultConfiguredSourceDestroyed + ); + } + + emit this->defaultConfiguredSourceChanged(); +} + +void PwDefaultTracker::onDefaultConfiguredSourceDestroyed() { + qCInfo(logDefaults) << "Default configured source destroyed."; + this->mDefaultConfiguredSource = nullptr; + emit this->defaultConfiguredSourceChanged(); } void PwDefaultTracker::setDefaultConfiguredSourceName(const QString& name) { diff --git a/src/services/pipewire/defaults.hpp b/src/services/pipewire/defaults.hpp index f3a8e3f..f31669e 100644 --- a/src/services/pipewire/defaults.hpp +++ b/src/services/pipewire/defaults.hpp @@ -12,6 +12,7 @@ class PwDefaultTracker: public QObject { public: explicit PwDefaultTracker(PwRegistry* registry); + void reset(); [[nodiscard]] PwNode* defaultSink() const; [[nodiscard]] PwNode* defaultSource() const; @@ -43,7 +44,10 @@ private slots: void onMetadataAdded(PwMetadata* metadata); void onMetadataProperty(const char* key, const char* type, const char* value); void onNodeAdded(PwNode* node); - void onNodeDestroyed(QObject* node); + void onDefaultSinkDestroyed(); + void onDefaultSourceDestroyed(); + void onDefaultConfiguredSinkDestroyed(); + void onDefaultConfiguredSourceDestroyed(); private: void setDefaultSink(PwNode* node); diff --git a/src/services/pipewire/device.cpp b/src/services/pipewire/device.cpp index 0a1e1b6..61079a1 100644 --- a/src/services/pipewire/device.cpp +++ b/src/services/pipewire/device.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -19,12 +20,14 @@ #include #include +#include "../../core/logcat.hpp" #include "core.hpp" +#include "node.hpp" namespace qs::service::pipewire { namespace { -Q_LOGGING_CATEGORY(logDevice, "quickshell.service.pipewire.device", QtWarningMsg); +QS_LOGGING_CATEGORY(logDevice, "quickshell.service.pipewire.device", QtWarningMsg); } // https://github.com/PipeWire/wireplumber/blob/895c1c7286e8809fad869059179e53ab39c807e9/modules/module-mixer-api.c#L397 @@ -104,30 +107,57 @@ void PwDevice::addDeviceIndexPairs(const spa_pod* param) { qint32 device = 0; qint32 index = 0; + const spa_pod* props = nullptr; + // clang-format off quint32 id = SPA_PARAM_Route; spa_pod_parser_get_object( &parser, SPA_TYPE_OBJECT_ParamRoute, &id, SPA_PARAM_ROUTE_device, SPA_POD_Int(&device), - SPA_PARAM_ROUTE_index, SPA_POD_Int(&index) + SPA_PARAM_ROUTE_index, SPA_POD_Int(&index), + SPA_PARAM_ROUTE_props, SPA_POD_PodObject(&props) ); // clang-format on - this->stagingIndexes.insert(device, index); + auto volumeProps = PwVolumeProps::parseSpaPod(props); + + this->stagingIndexes.append(device); // Insert into the main map as well, staging's purpose is to remove old entries. this->routeDeviceIndexes.insert(device, index); + // Used for initial node volume if the device is bound before the node + // (e.g. multiple nodes pointing to the same device) + this->routeDeviceVolumes.insert(device, volumeProps); + qCDebug(logDevice).nospace() << "Registered device/index pair for " << this << ": [device: " << device << ", index: " << index << ']'; + + emit this->routeVolumesChanged(device, volumeProps); +} + +bool PwDevice::tryLoadVolumeProps(qint32 routeDevice, PwVolumeProps& volumeProps) { + if (!this->routeDeviceVolumes.contains(routeDevice)) return false; + volumeProps = this->routeDeviceVolumes.value(routeDevice); + return true; +} + +bool PwDevice::hasRouteDevice(qint32 routeDevice) const { + return this->routeDeviceIndexes.contains(routeDevice); } void PwDevice::polled() { // It is far more likely that the list content has not come in yet than it having no entries, // and there isn't a way to check in the case that there *aren't* actually any entries. - if (!this->stagingIndexes.isEmpty() && this->stagingIndexes != this->routeDeviceIndexes) { - this->routeDeviceIndexes = this->stagingIndexes; - qCDebug(logDevice) << "Updated device/index pair list for" << this << "to" - << this->routeDeviceIndexes; + if (!this->stagingIndexes.isEmpty()) { + this->routeDeviceIndexes.removeIf([&, this](const std::pair& entry) { + if (!this->stagingIndexes.contains(entry.first)) { + qCDebug(logDevice).nospace() << "Removed device/index pair [device: " << entry.first + << ", index: " << entry.second << "] for" << this; + return true; + } + + return false; + }); } } diff --git a/src/services/pipewire/device.hpp b/src/services/pipewire/device.hpp index 2e14d61..cd61709 100644 --- a/src/services/pipewire/device.hpp +++ b/src/services/pipewire/device.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "core.hpp" @@ -17,6 +18,9 @@ namespace qs::service::pipewire { class PwDevice; +// Forward declare to avoid circular dependency with node.hpp +struct PwVolumeProps; + class PwDevice: public PwBindable { Q_OBJECT; @@ -30,8 +34,12 @@ public: void waitForDevice(); [[nodiscard]] bool waitingForDevice() const; + [[nodiscard]] bool tryLoadVolumeProps(qint32 routeDevice, PwVolumeProps& volumeProps); + [[nodiscard]] bool hasRouteDevice(qint32 routeDevice) const; + signals: void deviceReady(); + void routeVolumesChanged(qint32 routeDevice, const PwVolumeProps& volumeProps); private slots: void polled(); @@ -43,7 +51,8 @@ private: onParam(void* data, qint32 seq, quint32 id, quint32 index, quint32 next, const spa_pod* param); QHash routeDeviceIndexes; - QHash stagingIndexes; + QHash routeDeviceVolumes; + QList stagingIndexes; void addDeviceIndexPairs(const spa_pod* param); bool diff --git a/src/services/pipewire/link.cpp b/src/services/pipewire/link.cpp index c6421af..b2549f6 100644 --- a/src/services/pipewire/link.cpp +++ b/src/services/pipewire/link.cpp @@ -10,12 +10,13 @@ #include #include +#include "../../core/logcat.hpp" #include "registry.hpp" namespace qs::service::pipewire { namespace { -Q_LOGGING_CATEGORY(logLink, "quickshell.service.pipewire.link", QtWarningMsg); +QS_LOGGING_CATEGORY(logLink, "quickshell.service.pipewire.link", QtWarningMsg); } QString PwLinkState::toString(Enum value) { diff --git a/src/services/pipewire/metadata.cpp b/src/services/pipewire/metadata.cpp index ea79611..f2f4ec8 100644 --- a/src/services/pipewire/metadata.cpp +++ b/src/services/pipewire/metadata.cpp @@ -11,12 +11,13 @@ #include #include +#include "../../core/logcat.hpp" #include "registry.hpp" namespace qs::service::pipewire { namespace { -Q_LOGGING_CATEGORY(logMeta, "quickshell.service.pipewire.metadata", QtWarningMsg); +QS_LOGGING_CATEGORY(logMeta, "quickshell.service.pipewire.metadata", QtWarningMsg); } void PwMetadata::bindHooks() { diff --git a/src/services/pipewire/module.md b/src/services/pipewire/module.md index d109f05..e34f77d 100644 --- a/src/services/pipewire/module.md +++ b/src/services/pipewire/module.md @@ -2,6 +2,7 @@ name = "Quickshell.Services.Pipewire" description = "Pipewire API" headers = [ "qml.hpp", + "peak.hpp", "link.hpp", "node.hpp", ] diff --git a/src/services/pipewire/node.cpp b/src/services/pipewire/node.cpp index 2aff595..075a7ec 100644 --- a/src/services/pipewire/node.cpp +++ b/src/services/pipewire/node.cpp @@ -11,7 +11,7 @@ #include #include #include -#include +#include #include #include #include @@ -23,8 +23,10 @@ #include #include #include +#include #include +#include "../../core/logcat.hpp" #include "connection.hpp" #include "core.hpp" #include "device.hpp" @@ -32,7 +34,7 @@ namespace qs::service::pipewire { namespace { -Q_LOGGING_CATEGORY(logNode, "quickshell.service.pipewire.node", QtWarningMsg); +QS_LOGGING_CATEGORY(logNode, "quickshell.service.pipewire.node", QtWarningMsg); } QString PwAudioChannel::toString(Enum value) { @@ -159,6 +161,24 @@ void PwNode::initProps(const spa_dict* props) { this->nick = nodeNick; } + if (const auto* nodeCategory = spa_dict_lookup(props, PW_KEY_MEDIA_CATEGORY)) { + if (strcmp(nodeCategory, "Monitor") == 0 || strcmp(nodeCategory, "Manager") == 0) { + this->isMonitor = true; + } + } + + if (const auto* serial = spa_dict_lookup(props, PW_KEY_OBJECT_SERIAL)) { + auto ok = false; + auto value = QString::fromUtf8(serial).toULongLong(&ok); + if (!ok) { + qCWarning(logNode) << this + << "has an object.serial property but the value is not valid. Value:" + << serial; + } else { + this->objectSerial = value; + } + } + if (const auto* deviceId = spa_dict_lookup(props, PW_KEY_DEVICE_ID)) { auto ok = false; auto id = QString::fromUtf8(deviceId).toInt(&ok); @@ -170,7 +190,8 @@ void PwNode::initProps(const spa_dict* props) { this->device = this->registry->devices.value(id); if (this->device == nullptr) { - qCCritical(logNode + qCCritical( + logNode ) << this << "has a device.id property that does not corrospond to a device object. Id:" << id; } @@ -194,21 +215,36 @@ void PwNode::onInfo(void* data, const pw_node_info* info) { if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) != 0) { auto properties = QMap(); + bool proAudio = false; + if (const auto* proAudioStr = spa_dict_lookup(info->props, "device.profile.pro")) { + proAudio = spa_atob(proAudioStr); + } + + if (proAudio != self->proAudio) { + qCDebug(logNode) << self << "pro audio state changed:" << proAudio; + self->proAudio = proAudio; + } + if (self->device) { if (const auto* routeDevice = spa_dict_lookup(info->props, "card.profile.device")) { auto ok = false; auto id = QString::fromUtf8(routeDevice).toInt(&ok); if (!ok) { - qCCritical(logNode + qCCritical( + logNode ) << self << "has a card.profile.device property but the value is not an integer. Value:" << id; } self->routeDevice = id; + if (self->boundData) self->boundData->onDeviceChanged(); } else { - qCCritical(logNode) << self << "has attached device" << self->device - << "but no card.profile.device property."; + qCDebug( + logNode + ) << self + << "has attached device" << self->device + << "but no card.profile.device property. Node volume control will be used."; } } @@ -252,9 +288,25 @@ void PwNode::onParam( } } -PwNodeBoundAudio::PwNodeBoundAudio(PwNode* node): node(node) { +PwNodeBoundAudio::PwNodeBoundAudio(PwNode* node): QObject(node), node(node) { if (node->device) { QObject::connect(node->device, &PwDevice::deviceReady, this, &PwNodeBoundAudio::onDeviceReady); + + QObject::connect( + node->device, + &PwDevice::routeVolumesChanged, + this, + &PwNodeBoundAudio::onDeviceVolumesChanged + ); + } +} + +void PwNodeBoundAudio::onDeviceChanged() { + PwVolumeProps volumeProps; + if (this->node->device->tryLoadVolumeProps(this->node->routeDevice, volumeProps)) { + qCDebug(logNode) << "Initializing volume props for" << this->node + << "with known values from backing device."; + this->updateVolumeProps(volumeProps); } } @@ -278,13 +330,18 @@ void PwNodeBoundAudio::onInfo(const pw_node_info* info) { void PwNodeBoundAudio::onSpaParam(quint32 id, quint32 index, const spa_pod* param) { if (id == SPA_PARAM_Props && index == 0) { - this->updateVolumeProps(param); + if (this->node->shouldUseDevice()) { + qCDebug(logNode) << "Skipping node volume props update for" << this->node + << "in favor of device updates from routeDevice" << this->node->routeDevice + << "of" << this->node->device; + return; + } + + this->updateVolumeProps(PwVolumeProps::parseSpaPod(param)); } } -void PwNodeBoundAudio::updateVolumeProps(const spa_pod* param) { - auto volumeProps = PwVolumeProps::parseSpaPod(param); - +void PwNodeBoundAudio::updateVolumeProps(const PwVolumeProps& volumeProps) { if (volumeProps.volumes.size() != volumeProps.channels.size()) { qCWarning(logNode) << "Cannot update volume props of" << this->node << "- channelVolumes and channelMap are not the same size. Sizes:" @@ -292,6 +349,8 @@ void PwNodeBoundAudio::updateVolumeProps(const spa_pod* param) { return; } + this->volumeStep = volumeProps.volumeStep; + // It is important that the lengths of channels and volumes stay in sync whenever you read them. auto channelsChanged = false; auto volumesChanged = false; @@ -344,7 +403,7 @@ void PwNodeBoundAudio::setMuted(bool muted) { if (muted == this->mMuted) return; - if (this->node->device) { + if (this->node->shouldUseDevice()) { qCInfo(logNode) << "Changing muted state of" << this->node << "to" << muted << "via device"; if (!this->node->device->setMuted(this->node->routeDevice, muted)) { return; @@ -370,6 +429,10 @@ void PwNodeBoundAudio::setMuted(bool muted) { } float PwNodeBoundAudio::averageVolume() const { + if (this->mVolumes.isEmpty()) { + return 0.0f; + } + float total = 0; for (auto volume: this->mVolumes) { @@ -417,37 +480,41 @@ void PwNodeBoundAudio::setVolumes(const QVector& volumes) { return; } - if (this->node->device) { + if (this->node->shouldUseDevice()) { if (this->node->device->waitingForDevice()) { qCInfo(logNode) << "Waiting to change volumes of" << this->node << "to" << realVolumes << "via device"; this->waitingVolumes = realVolumes; } else { - auto significantChange = this->mServerVolumes.isEmpty(); - for (auto i = 0; i < this->mServerVolumes.length(); i++) { - auto serverVolume = this->mServerVolumes.value(i); - auto targetVolume = realVolumes.value(i); - if (targetVolume == 0 || abs(targetVolume - serverVolume) >= 0.0001) { - significantChange = true; - break; - } - } - - if (significantChange) { - qCInfo(logNode) << "Changing volumes of" << this->node << "to" << realVolumes - << "via device"; - if (!this->node->device->setVolumes(this->node->routeDevice, realVolumes)) { - return; + if (this->volumeStep != -1) { + auto significantChange = this->mServerVolumes.isEmpty(); + for (auto i = 0; i < this->mServerVolumes.length(); i++) { + auto serverVolume = this->mServerVolumes.value(i); + auto targetVolume = realVolumes.value(i); + if (targetVolume == 0 || abs(targetVolume - serverVolume) >= this->volumeStep) { + significantChange = true; + break; + } } - this->mDeviceVolumes = realVolumes; - this->node->device->waitForDevice(); - } else { - // Insignificant changes won't cause an info event on the device, leaving qs hung in the - // "waiting for acknowledgement" state forever. - qCInfo(logNode) << "Ignoring volume change for" << this->node << "to" << realVolumes - << "from" << this->mServerVolumes - << "as it is a device node and the change is too small."; + if (significantChange) { + qCInfo(logNode) << "Changing volumes of" << this->node << "to" << realVolumes + << "via device"; + if (!this->node->device->setVolumes(this->node->routeDevice, realVolumes)) { + return; + } + + this->mDeviceVolumes = realVolumes; + this->node->device->waitForDevice(); + } else { + // Insignificant changes won't cause an info event on the device, leaving qs hung in the + // "waiting for acknowledgement" state forever. + qCInfo(logNode).nospace() + << "Ignoring volume change for " << this->node << " to " << realVolumes << " from " + << this->mServerVolumes + << " as it is a device node and the change is too small (min step: " + << this->volumeStep << ")."; + } } } } else { @@ -489,29 +556,54 @@ void PwNodeBoundAudio::onDeviceReady() { } } +void PwNodeBoundAudio::onDeviceVolumesChanged( + qint32 routeDevice, + const PwVolumeProps& volumeProps +) { + if (this->node->shouldUseDevice() && this->node->routeDevice == routeDevice) { + qCDebug(logNode) << "Got updated device volume props for" << this->node << "via" + << this->node->device; + + this->updateVolumeProps(volumeProps); + } +} + PwVolumeProps PwVolumeProps::parseSpaPod(const spa_pod* param) { auto props = PwVolumeProps(); const auto* volumesProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelVolumes); const auto* channelsProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelMap); const auto* muteProp = spa_pod_find_prop(param, nullptr, SPA_PROP_mute); + const auto* volumeStepProp = spa_pod_find_prop(param, nullptr, SPA_PROP_volumeStep); - const auto* volumes = reinterpret_cast(&volumesProp->value); - const auto* channels = reinterpret_cast(&channelsProp->value); - - spa_pod* iter = nullptr; - SPA_POD_ARRAY_FOREACH(volumes, iter) { - // Cubing behavior found in MPD source, and appears to corrospond to everyone else's measurements correctly. - auto linear = *reinterpret_cast(iter); - auto visual = std::cbrt(linear); - props.volumes.push_back(visual); + if (volumesProp) { + const auto* volumes = reinterpret_cast(&volumesProp->value); + spa_pod* iter = nullptr; + SPA_POD_ARRAY_FOREACH(volumes, iter) { + // Cubing behavior found in MPD source, and appears to corrospond to everyone else's measurements correctly. + auto linear = *reinterpret_cast(iter); + auto visual = std::cbrt(linear); + props.volumes.push_back(visual); + } } - SPA_POD_ARRAY_FOREACH(channels, iter) { - props.channels.push_back(*reinterpret_cast(iter)); + if (channelsProp) { + const auto* channels = reinterpret_cast(&channelsProp->value); + spa_pod* iter = nullptr; + SPA_POD_ARRAY_FOREACH(channels, iter) { + props.channels.push_back(*reinterpret_cast(iter)); + } } - spa_pod_get_bool(&muteProp->value, &props.mute); + if (muteProp) { + spa_pod_get_bool(&muteProp->value, &props.mute); + } + + if (volumeStepProp) { + spa_pod_get_float(&volumeStepProp->value, &props.volumeStep); + } else { + props.volumeStep = -1; + } return props; } diff --git a/src/services/pipewire/node.hpp b/src/services/pipewire/node.hpp index aca949f..efc819c 100644 --- a/src/services/pipewire/node.hpp +++ b/src/services/pipewire/node.hpp @@ -15,6 +15,7 @@ #include #include "core.hpp" +#include "device.hpp" #include "registry.hpp" namespace qs::service::pipewire { @@ -145,8 +146,8 @@ public: // @@PwNodeType.Video and @@PwNodeType.Sink flags. VideoSink = Video | Sink, }; - Q_ENUM(Flag) - Q_DECLARE_FLAGS(Flags, Flag) + Q_ENUM(Flag); + Q_DECLARE_FLAGS(Flags, Flag); Q_INVOKABLE static QString toString(qs::service::pipewire::PwNodeType::Flags type); }; @@ -158,6 +159,7 @@ struct PwVolumeProps { QVector channels; QVector volumes; bool mute = false; + float volumeStep = -1; static PwVolumeProps parseSpaPod(const spa_pod* param); }; @@ -168,6 +170,7 @@ public: virtual ~PwNodeBoundData() = default; Q_DISABLE_COPY_MOVE(PwNodeBoundData); + virtual void onDeviceChanged() {}; virtual void onInfo(const pw_node_info* /*info*/) {} virtual void onSpaParam(quint32 /*id*/, quint32 /*index*/, const spa_pod* /*param*/) {} virtual void onUnbind() {} @@ -181,6 +184,7 @@ class PwNodeBoundAudio public: explicit PwNodeBoundAudio(PwNode* node); + void onDeviceChanged() override; void onInfo(const pw_node_info* info) override; void onSpaParam(quint32 id, quint32 index, const spa_pod* param) override; void onUnbind() override; @@ -196,6 +200,8 @@ public: [[nodiscard]] QVector volumes() const; void setVolumes(const QVector& volumes); + [[nodiscard]] QVector server() const; + signals: void volumesChanged(); void channelsChanged(); @@ -203,9 +209,10 @@ signals: private slots: void onDeviceReady(); + void onDeviceVolumesChanged(qint32 routeDevice, const PwVolumeProps& props); private: - void updateVolumeProps(const spa_pod* param); + void updateVolumeProps(const PwVolumeProps& volumeProps); bool mMuted = false; QVector mChannels; @@ -213,6 +220,7 @@ private: QVector mServerVolumes; QVector mDeviceVolumes; QVector waitingVolumes; + float volumeStep = -1; PwNode* node; }; @@ -228,6 +236,8 @@ public: QString description; QString nick; QMap properties; + quint64 objectSerial = 0; + bool isMonitor = false; PwNodeType::Flags type = PwNodeType::Untracked; @@ -237,6 +247,13 @@ public: PwDevice* device = nullptr; qint32 routeDevice = -1; + bool proAudio = false; + + [[nodiscard]] bool shouldUseDevice() const { + if (!this->device || this->proAudio || this->routeDevice == -1) return false; + // Only use device control if the device actually has route indexes for this routeDevice + return this->device->hasRouteDevice(this->routeDevice); + } signals: void propertiesChanged(); diff --git a/src/services/pipewire/peak.cpp b/src/services/pipewire/peak.cpp new file mode 100644 index 0000000..64b5c42 --- /dev/null +++ b/src/services/pipewire/peak.cpp @@ -0,0 +1,404 @@ +#include "peak.hpp" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "connection.hpp" +#include "core.hpp" +#include "node.hpp" +#include "qml.hpp" + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wmissing-designated-field-initializers" + +namespace qs::service::pipewire { + +namespace { +QS_LOGGING_CATEGORY(logPeak, "quickshell.service.pipewire.peak", QtWarningMsg); +} + +class PwPeakStream { +public: + PwPeakStream(PwNodePeakMonitor* monitor, PwNode* node): monitor(monitor), node(node) {} + ~PwPeakStream() { this->destroy(); } + Q_DISABLE_COPY_MOVE(PwPeakStream); + + bool start(); + void destroy(); + +private: + static const pw_stream_events EVENTS; + static void onProcess(void* data); + static void onParamChanged(void* data, uint32_t id, const spa_pod* param); + static void + onStateChanged(void* data, pw_stream_state oldState, pw_stream_state state, const char* error); + static void onDestroy(void* data); + + void handleProcess(); + void handleParamChanged(uint32_t id, const spa_pod* param); + void handleStateChanged(pw_stream_state oldState, pw_stream_state state, const char* error); + void resetFormat(); + + PwNodePeakMonitor* monitor = nullptr; + PwNode* node = nullptr; + pw_stream* stream = nullptr; + SpaHook listener; + spa_audio_info_raw format = SPA_AUDIO_INFO_RAW_INIT(.format = SPA_AUDIO_FORMAT_UNKNOWN); + bool formatReady = false; + QVector channelPeaks; +}; + +const pw_stream_events PwPeakStream::EVENTS = { + .version = PW_VERSION_STREAM_EVENTS, + .destroy = &PwPeakStream::onDestroy, + .state_changed = &PwPeakStream::onStateChanged, + .param_changed = &PwPeakStream::onParamChanged, + .process = &PwPeakStream::onProcess, +}; + +bool PwPeakStream::start() { + auto* core = PwConnection::instance()->registry.core; + if (core == nullptr || !core->isValid()) { + qCWarning(logPeak) << "Cannot start peak monitor stream: pipewire core is not ready."; + return false; + } + + auto target = + QByteArray::number(this->node->objectSerial ? this->node->objectSerial : this->node->id); + + // clang-format off + auto* props = pw_properties_new( + PW_KEY_MEDIA_TYPE, "Audio", + PW_KEY_MEDIA_CATEGORY, "Monitor", + PW_KEY_MEDIA_NAME, "Peak detect", + PW_KEY_APP_NAME, "Quickshell Peak Detect", + PW_KEY_STREAM_MONITOR, "true", + PW_KEY_STREAM_CAPTURE_SINK, this->node->type.testFlags(PwNodeType::Sink) ? "true" : "false", + PW_KEY_TARGET_OBJECT, target.constData(), + nullptr + ); + // clang-format on + + if (props == nullptr) { + qCWarning(logPeak) << "Failed to create properties for peak monitor stream."; + return false; + } + + this->stream = pw_stream_new(core->core, "quickshell-peak-monitor", props); + if (this->stream == nullptr) { + qCWarning(logPeak) << "Failed to create peak monitor stream."; + return false; + } + + pw_stream_add_listener(this->stream, &this->listener.hook, &PwPeakStream::EVENTS, this); + + auto buffer = std::array {}; + auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); // NOLINT + + auto params = std::array {}; + auto raw = SPA_AUDIO_INFO_RAW_INIT(.format = SPA_AUDIO_FORMAT_F32); + params[0] = spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &raw); + + auto flags = + static_cast(PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS); + auto res = + pw_stream_connect(this->stream, PW_DIRECTION_INPUT, PW_ID_ANY, flags, params.data(), 1); + + if (res < 0) { + qCWarning(logPeak) << "Failed to connect peak monitor stream:" << res; + this->destroy(); + return false; + } + + return true; +} + +void PwPeakStream::destroy() { + if (this->stream == nullptr) return; + this->listener.remove(); + pw_stream_destroy(this->stream); + this->stream = nullptr; + this->resetFormat(); +} + +void PwPeakStream::onProcess(void* data) { + static_cast(data)->handleProcess(); // NOLINT +} + +void PwPeakStream::onParamChanged(void* data, uint32_t id, const spa_pod* param) { + static_cast(data)->handleParamChanged(id, param); // NOLINT +} + +void PwPeakStream::onStateChanged( + void* data, + pw_stream_state oldState, + pw_stream_state state, + const char* error +) { + static_cast(data)->handleStateChanged(oldState, state, error); // NOLINT +} + +void PwPeakStream::onDestroy(void* data) { + auto* self = static_cast(data); // NOLINT + self->stream = nullptr; + self->listener.remove(); + self->resetFormat(); +} + +void PwPeakStream::handleStateChanged( + pw_stream_state oldState, + pw_stream_state state, + const char* error +) { + if (state == PW_STREAM_STATE_ERROR) { + if (error != nullptr) { + qCWarning(logPeak) << "Peak monitor stream error:" << error; + } else { + qCWarning(logPeak) << "Peak monitor stream error."; + } + } + + if (state == PW_STREAM_STATE_PAUSED && oldState != PW_STREAM_STATE_PAUSED) { + auto peakCount = this->monitor->mChannels.length(); + if (peakCount == 0) { + peakCount = this->monitor->mPeaks.length(); + } + if (peakCount == 0 && this->formatReady) { + peakCount = static_cast(this->format.channels); + } + + if (peakCount > 0) { + auto zeros = QVector(peakCount, 0.0f); + this->monitor->updatePeaks(zeros, 0.0f); + } + } +} + +void PwPeakStream::handleParamChanged(uint32_t id, const spa_pod* param) { + if (param == nullptr || id != SPA_PARAM_Format) return; + + auto info = spa_audio_info {}; + if (spa_format_parse(param, &info.media_type, &info.media_subtype) < 0) return; + + if (info.media_type != SPA_MEDIA_TYPE_audio || info.media_subtype != SPA_MEDIA_SUBTYPE_raw) + return; + + auto raw = SPA_AUDIO_INFO_RAW_INIT(.format = SPA_AUDIO_FORMAT_UNKNOWN); // NOLINT + if (spa_format_audio_raw_parse(param, &raw) < 0) return; + + if (raw.format != SPA_AUDIO_FORMAT_F32) { + qCWarning(logPeak) << "Unsupported peak monitor format for" << this->node << ":" << raw.format; + this->resetFormat(); + return; + } + + this->format = raw; + this->formatReady = raw.channels > 0; + + auto channels = QVector(); + channels.reserve(static_cast(raw.channels)); + + for (quint32 i = 0; i < raw.channels; i++) { + if ((raw.flags & SPA_AUDIO_FLAG_UNPOSITIONED) != 0) { + channels.push_back(PwAudioChannel::Unknown); + } else { + channels.push_back(static_cast(raw.position[i])); + } + } + + this->channelPeaks.fill(0.0f, channels.size()); + this->monitor->updateChannels(channels); + this->monitor->updatePeaks(this->channelPeaks, 0.0f); +} + +void PwPeakStream::resetFormat() { + this->format = SPA_AUDIO_INFO_RAW_INIT(.format = SPA_AUDIO_FORMAT_UNKNOWN); + this->formatReady = false; + this->channelPeaks.clear(); + this->monitor->clearPeaks(); +} + +void PwPeakStream::handleProcess() { + if (!this->formatReady || this->stream == nullptr) return; + + auto* buffer = pw_stream_dequeue_buffer(this->stream); + auto requeue = qScopeGuard([&, this] { pw_stream_queue_buffer(this->stream, buffer); }); + + if (buffer == nullptr) { + qCWarning(logPeak) << "Peak monitor ran out of buffers."; + return; + } + + auto* spaBuffer = buffer->buffer; + if (spaBuffer == nullptr || spaBuffer->n_datas < 1) { + return; + } + + auto* data = &spaBuffer->datas[0]; // NOLINT + if (data->data == nullptr || data->chunk == nullptr) { + return; + } + + auto channelCount = static_cast(this->format.channels); + if (channelCount <= 0) { + return; + } + + const auto* base = static_cast(data->data) + data->chunk->offset; // NOLINT + const auto* samples = reinterpret_cast(base); + auto sampleCount = static_cast(data->chunk->size / sizeof(float)); + + if (sampleCount < channelCount) { + return; + } + + QVector volumes; + if (auto* audioData = dynamic_cast(this->node->boundData)) { + if (!this->node->shouldUseDevice()) volumes = audioData->volumes(); + } + + this->channelPeaks.fill(0.0f, channelCount); + + auto maxPeak = 0.0f; + for (auto channel = 0; channel < channelCount; channel++) { + auto peak = 0.0f; + for (auto sample = channel; sample < sampleCount; sample += channelCount) { + peak = std::max(peak, std::abs(samples[sample])); // NOLINT + } + + auto visualPeak = std::cbrt(peak); + if (!volumes.isEmpty() && volumes[channel] != 0.0f) visualPeak *= 1.0f / volumes[channel]; + + this->channelPeaks[channel] = visualPeak; + maxPeak = std::max(maxPeak, visualPeak); + } + + this->monitor->updatePeaks(this->channelPeaks, maxPeak); +} + +PwNodePeakMonitor::PwNodePeakMonitor(QObject* parent): QObject(parent) {} + +PwNodePeakMonitor::~PwNodePeakMonitor() { + delete this->mStream; + this->mStream = nullptr; +} + +PwNodeIface* PwNodePeakMonitor::node() const { return this->mNode; } + +void PwNodePeakMonitor::setNode(PwNodeIface* node) { + if (node == this->mNode) return; + + if (this->mNode != nullptr) { + QObject::disconnect(this->mNode, nullptr, this, nullptr); + } + + if (node != nullptr) { + QObject::connect(node, &QObject::destroyed, this, &PwNodePeakMonitor::onNodeDestroyed); + } + + this->mNode = node; + this->mNodeRef.setObject(node != nullptr ? node->node() : nullptr); + this->rebuildStream(); + emit this->nodeChanged(); +} + +bool PwNodePeakMonitor::isEnabled() const { return this->mEnabled; } + +void PwNodePeakMonitor::setEnabled(bool enabled) { + if (enabled == this->mEnabled) return; + this->mEnabled = enabled; + this->rebuildStream(); + emit this->enabledChanged(); +} + +void PwNodePeakMonitor::onNodeDestroyed() { + this->mNode = nullptr; + this->mNodeRef.setObject(nullptr); + this->rebuildStream(); + emit this->nodeChanged(); +} + +void PwNodePeakMonitor::updatePeaks(const QVector& peaks, float peak) { + if (this->mPeaks != peaks) { + this->mPeaks = peaks; + emit this->peaksChanged(); + } + + if (this->mPeak != peak) { + this->mPeak = peak; + emit this->peakChanged(); + } +} + +void PwNodePeakMonitor::updateChannels(const QVector& channels) { + if (this->mChannels == channels) return; + this->mChannels = channels; + emit this->channelsChanged(); +} + +void PwNodePeakMonitor::clearPeaks() { + if (!this->mPeaks.isEmpty()) { + this->mPeaks.clear(); + emit this->peaksChanged(); + } + + if (!this->mChannels.isEmpty()) { + this->mChannels.clear(); + emit this->channelsChanged(); + } + + if (this->mPeak != 0.0f) { + this->mPeak = 0.0f; + emit this->peakChanged(); + } +} + +void PwNodePeakMonitor::rebuildStream() { + delete this->mStream; + this->mStream = nullptr; + + auto* node = this->mNodeRef.object(); + if (!this->mEnabled || node == nullptr) { + this->clearPeaks(); + return; + } + + if (node == nullptr || !node->type.testFlags(PwNodeType::Audio)) { + this->clearPeaks(); + return; + } + + this->mStream = new PwPeakStream(this, node); + if (!this->mStream->start()) { + delete this->mStream; + this->mStream = nullptr; + this->clearPeaks(); + } +} + +} // namespace qs::service::pipewire + +#pragma GCC diagnostic pop diff --git a/src/services/pipewire/peak.hpp b/src/services/pipewire/peak.hpp new file mode 100644 index 0000000..c4af3c2 --- /dev/null +++ b/src/services/pipewire/peak.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "node.hpp" + +namespace qs::service::pipewire { + +class PwNodeIface; +class PwPeakStream; + +} // namespace qs::service::pipewire + +Q_DECLARE_OPAQUE_POINTER(qs::service::pipewire::PwNodeIface*); + +namespace qs::service::pipewire { + +///! Monitors peak levels of an audio node. +/// Tracks volume peaks for a node across all its channels. +/// +/// The peak monitor binds nodes similarly to @@PwObjectTracker when enabled. +class PwNodePeakMonitor: public QObject { + Q_OBJECT; + // clang-format off + /// The node to monitor. Must be an audio node. + Q_PROPERTY(qs::service::pipewire::PwNodeIface* node READ node WRITE setNode NOTIFY nodeChanged); + /// If true, the monitor is actively capturing and computing peaks. Defaults to true. + Q_PROPERTY(bool enabled READ isEnabled WRITE setEnabled NOTIFY enabledChanged); + /// Per-channel peak noise levels (0.0-1.0). Length matches @@channels. + /// + /// The channel's volume does not affect this property. + Q_PROPERTY(QVector peaks READ peaks NOTIFY peaksChanged); + /// Maximum value of @@peaks. + Q_PROPERTY(float peak READ peak NOTIFY peakChanged); + /// Channel positions for the captured format. Length matches @@peaks. + Q_PROPERTY(QVector channels READ channels NOTIFY channelsChanged); + // clang-format on + QML_ELEMENT; + +public: + explicit PwNodePeakMonitor(QObject* parent = nullptr); + ~PwNodePeakMonitor() override; + Q_DISABLE_COPY_MOVE(PwNodePeakMonitor); + + [[nodiscard]] PwNodeIface* node() const; + void setNode(PwNodeIface* node); + + [[nodiscard]] bool isEnabled() const; + void setEnabled(bool enabled); + + [[nodiscard]] QVector peaks() const { return this->mPeaks; } + [[nodiscard]] float peak() const { return this->mPeak; } + [[nodiscard]] QVector channels() const { return this->mChannels; } + +signals: + void nodeChanged(); + void enabledChanged(); + void peaksChanged(); + void peakChanged(); + void channelsChanged(); + +private slots: + void onNodeDestroyed(); + +private: + friend class PwPeakStream; + + void updatePeaks(const QVector& peaks, float peak); + void updateChannels(const QVector& channels); + void clearPeaks(); + void rebuildStream(); + + PwNodeIface* mNode = nullptr; + PwBindableRef mNodeRef; + bool mEnabled = true; + QVector mPeaks; + float mPeak = 0.0f; + QVector mChannels; + PwPeakStream* mStream = nullptr; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/qml.cpp b/src/services/pipewire/qml.cpp index 5d8c45e..e4424c1 100644 --- a/src/services/pipewire/qml.cpp +++ b/src/services/pipewire/qml.cpp @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -18,6 +17,16 @@ namespace qs::service::pipewire { +PwObjectIface::PwObjectIface(PwBindableObject* object): QObject(object), object(object) { + // We want to destroy the interface before QObject::destroyed is fired, as handlers + // connected before PwObjectIface will run first and emit signals that hit user code, + // which can then try to reference the iface again after ~PwNode() has been called but + // before ~QObject() has finished. + QObject::connect(object, &PwBindableObject::destroying, this, &PwObjectIface::onObjectDestroying); +} + +void PwObjectIface::onObjectDestroying() { delete this; } + void PwObjectIface::ref() { this->refcount++; @@ -89,15 +98,8 @@ Pipewire::Pipewire(QObject* parent): QObject(parent) { &Pipewire::defaultConfiguredAudioSourceChanged ); - if (!connection->registry.isInitialized()) { - QObject::connect( - &connection->registry, - &PwRegistry::initialized, - this, - &Pipewire::readyChanged, - Qt::SingleShotConnection - ); - } + QObject::connect(&connection->registry, &PwRegistry::initialized, this, &Pipewire::readyChanged); + QObject::connect(&connection->registry, &PwRegistry::cleared, this, &Pipewire::readyChanged); } ObjectModel* Pipewire::nodes() { return &this->mNodes; } @@ -211,6 +213,7 @@ void PwNodeLinkTracker::updateLinks() { || (this->mNode->isSink() && link->inputNode() == this->mNode->id())) { auto* iface = PwLinkGroupIface::instance(link); + if (iface->target()->node()->isMonitor) return; // do not connect twice if (!this->mLinkGroups.contains(iface)) { @@ -229,7 +232,7 @@ void PwNodeLinkTracker::updateLinks() { for (auto* iface: this->mLinkGroups) { // only disconnect no longer used nodes - if (!newLinks.contains(iface)) { + if (!newLinks.contains(iface) || iface->target()->node()->isMonitor) { QObject::disconnect(iface, nullptr, this, nullptr); } } @@ -269,6 +272,8 @@ void PwNodeLinkTracker::onLinkGroupCreated(PwLinkGroup* linkGroup) { || (this->mNode->isSink() && linkGroup->inputNode() == this->mNode->id())) { auto* iface = PwLinkGroupIface::instance(linkGroup); + if (iface->target()->node()->isMonitor) return; + QObject::connect(iface, &QObject::destroyed, this, &PwNodeLinkTracker::onLinkGroupDestroyed); this->mLinkGroups.push_back(iface); emit this->linkGroupsChanged(); diff --git a/src/services/pipewire/qml.hpp b/src/services/pipewire/qml.hpp index 2ff7a7a..a43ce19 100644 --- a/src/services/pipewire/qml.hpp +++ b/src/services/pipewire/qml.hpp @@ -36,7 +36,7 @@ class PwObjectIface Q_OBJECT; public: - explicit PwObjectIface(PwBindableObject* object): QObject(object), object(object) {}; + explicit PwObjectIface(PwBindableObject* object); // destructor should ONLY be called by the pw object destructor, making an unref unnecessary ~PwObjectIface() override = default; Q_DISABLE_COPY_MOVE(PwObjectIface); @@ -44,6 +44,9 @@ public: void ref() override; void unref() override; +private slots: + void onObjectDestroying(); + private: quint32 refcount = 0; PwBindableObject* object; @@ -168,13 +171,13 @@ private: ObjectModel mLinkGroups {this}; }; -///! Tracks all link connections to a given node. +///! Tracks non-monitor link connections to a given node. class PwNodeLinkTracker: public QObject { Q_OBJECT; // clang-format off /// The node to track connections to. Q_PROPERTY(qs::service::pipewire::PwNodeIface* node READ node WRITE setNode NOTIFY nodeChanged); - /// Link groups connected to the given node. + /// Link groups connected to the given node, excluding monitors. /// /// If the node is a sink, links which target the node will be tracked. /// If the node is a source, links which source the node will be tracked. @@ -219,22 +222,22 @@ class PwNodeAudioIface: public QObject { // clang-format off /// If the node is currently muted. Setting this property changes the mute state. /// - /// > [!WARNING] This property is invalid unless the node is [bound](../pwobjecttracker). + /// > [!WARNING] This property is invalid unless the node is bound using @@PwObjectTracker. Q_PROPERTY(bool muted READ isMuted WRITE setMuted NOTIFY mutedChanged); /// The average volume over all channels of the node. /// Setting this property modifies the volume of all channels proportionately. /// - /// > [!WARNING] This property is invalid unless the node is [bound](../pwobjecttracker). + /// > [!WARNING] This property is invalid unless the node is bound using @@PwObjectTracker. Q_PROPERTY(float volume READ averageVolume WRITE setAverageVolume NOTIFY volumesChanged); /// The audio channels present on the node. /// - /// > [!WARNING] This property is invalid unless the node is [bound](../pwobjecttracker). + /// > [!WARNING] This property is invalid unless the node is bound using @@PwObjectTracker. Q_PROPERTY(QVector channels READ channels NOTIFY channelsChanged); /// The volumes of each audio channel individually. Each entry corrosponds to /// the volume of the channel at the same index in @@channels. @@volumes and @@channels /// will always be the same length. /// - /// > [!WARNING] This property is invalid unless the node is [bound](../pwobjecttracker). + /// > [!WARNING] This property is invalid unless the node is bound using @@PwObjectTracker. Q_PROPERTY(QVector volumes READ volumes WRITE setVolumes NOTIFY volumesChanged); // clang-format on QML_NAMED_ELEMENT(PwNodeAudio); @@ -300,7 +303,7 @@ class PwNodeIface: public PwObjectIface { /// - `media.title` - The title of the currently playing media. /// - `media.artist` - The artist of the currently playing media. /// - /// > [!WARNING] This property is invalid unless the node is [bound](../pwobjecttracker). + /// > [!WARNING] This property is invalid unless the node is bound using @@PwObjectTracker. Q_PROPERTY(QVariantMap properties READ properties NOTIFY propertiesChanged); /// Extra information present only if the node sends or receives audio. /// @@ -357,7 +360,7 @@ class PwLinkIface: public PwObjectIface { Q_PROPERTY(qs::service::pipewire::PwNodeIface* source READ source CONSTANT); /// The current state of the link. /// - /// > [!WARNING] This property is invalid unless the node is [bound](../pwobjecttracker). + /// > [!WARNING] This property is invalid unless the node is bound using @@PwObjectTracker. Q_PROPERTY(PwLinkState::Enum state READ state NOTIFY stateChanged); QML_NAMED_ELEMENT(PwLink); QML_UNCREATABLE("PwLinks cannot be created directly"); @@ -392,7 +395,7 @@ class PwLinkGroupIface Q_PROPERTY(qs::service::pipewire::PwNodeIface* source READ source CONSTANT); /// The current state of the link group. /// - /// > [!WARNING] This property is invalid unless the node is [bound](../pwobjecttracker). + /// > [!WARNING] This property is invalid unless the node is bound using @@PwObjectTracker. Q_PROPERTY(qs::service::pipewire::PwLinkState::Enum state READ state NOTIFY stateChanged); QML_NAMED_ELEMENT(PwLinkGroup); QML_UNCREATABLE("PwLinkGroups cannot be created directly"); diff --git a/src/services/pipewire/registry.cpp b/src/services/pipewire/registry.cpp index d2967d0..4b670b1 100644 --- a/src/services/pipewire/registry.cpp +++ b/src/services/pipewire/registry.cpp @@ -15,6 +15,7 @@ #include #include +#include "../../core/logcat.hpp" #include "core.hpp" #include "device.hpp" #include "link.hpp" @@ -23,7 +24,7 @@ namespace qs::service::pipewire { -Q_LOGGING_CATEGORY(logRegistry, "quickshell.service.pipewire.registry", QtWarningMsg); +QS_LOGGING_CATEGORY(logRegistry, "quickshell.service.pipewire.registry", QtWarningMsg); PwBindableObject::~PwBindableObject() { if (this->id != 0) { @@ -133,6 +134,46 @@ void PwRegistry::init(PwCore& core) { this->coreSyncSeq = this->core->sync(PW_ID_CORE); } +void PwRegistry::reset() { + if (this->core != nullptr) { + QObject::disconnect(this->core, nullptr, this, nullptr); + } + + this->listener.remove(); + + if (this->object != nullptr) { + pw_proxy_destroy(reinterpret_cast(this->object)); + this->object = nullptr; + } + + for (auto* meta: this->metadata.values()) { + meta->safeDestroy(); + } + this->metadata.clear(); + + for (auto* link: this->links.values()) { + link->safeDestroy(); + } + this->links.clear(); + + for (auto* node: this->nodes.values()) { + node->safeDestroy(); + } + this->nodes.clear(); + + for (auto* device: this->devices.values()) { + device->safeDestroy(); + } + this->devices.clear(); + + this->linkGroups.clear(); + this->initState = InitState::SendingObjects; + this->coreSyncSeq = 0; + this->core = nullptr; + + emit this->cleared(); +} + void PwRegistry::onCoreSync(quint32 id, qint32 seq) { if (id != PW_ID_CORE || seq != this->coreSyncSeq) return; diff --git a/src/services/pipewire/registry.hpp b/src/services/pipewire/registry.hpp index f1ba961..bb2db8c 100644 --- a/src/services/pipewire/registry.hpp +++ b/src/services/pipewire/registry.hpp @@ -12,12 +12,13 @@ #include #include +#include "../../core/logcat.hpp" #include "../../core/util.hpp" #include "core.hpp" namespace qs::service::pipewire { -Q_DECLARE_LOGGING_CATEGORY(logRegistry); +QS_DECLARE_LOGGING_CATEGORY(logRegistry); class PwRegistry; class PwMetadata; @@ -54,8 +55,8 @@ protected: void registryBind(const char* interface, quint32 version); virtual void bind(); void unbind(); - virtual void bindHooks() {}; - virtual void unbindHooks() {}; + virtual void bindHooks() {} + virtual void unbindHooks() {} quint32 refcount = 0; pw_proxy* object = nullptr; @@ -115,6 +116,7 @@ class PwRegistry public: void init(PwCore& core); + void reset(); [[nodiscard]] bool isInitialized() const { return this->initState == InitState::Done; } @@ -135,6 +137,7 @@ signals: void linkGroupAdded(PwLinkGroup* group); void metadataAdded(PwMetadata* metadata); void initialized(); + void cleared(); private slots: void onLinkGroupDestroyed(QObject* object); diff --git a/src/services/polkit/CMakeLists.txt b/src/services/polkit/CMakeLists.txt new file mode 100644 index 0000000..51791d8 --- /dev/null +++ b/src/services/polkit/CMakeLists.txt @@ -0,0 +1,35 @@ +find_package(PkgConfig REQUIRED) +pkg_check_modules(glib REQUIRED IMPORTED_TARGET glib-2.0>=2.36) +pkg_check_modules(gobject REQUIRED IMPORTED_TARGET gobject-2.0) +pkg_check_modules(polkit_agent REQUIRED IMPORTED_TARGET polkit-agent-1) +pkg_check_modules(polkit REQUIRED IMPORTED_TARGET polkit-gobject-1) + +qt_add_library(quickshell-service-polkit STATIC + agentimpl.cpp + flow.cpp + identity.cpp + listener.cpp + session.cpp + qml.cpp +) + +qt_add_qml_module(quickshell-service-polkit + URI Quickshell.Services.Polkit + VERSION 0.1 + DEPENDENCIES QtQml +) + +install_qml_module(quickshell-service-polkit) + +target_link_libraries(quickshell-service-polkit PRIVATE + Qt::Qml + Qt::Quick + PkgConfig::glib + PkgConfig::gobject + PkgConfig::polkit_agent + PkgConfig::polkit +) + +qs_module_pch(quickshell-service-polkit) + +target_link_libraries(quickshell PRIVATE quickshell-service-polkitplugin) diff --git a/src/services/polkit/agentimpl.cpp b/src/services/polkit/agentimpl.cpp new file mode 100644 index 0000000..85c62b7 --- /dev/null +++ b/src/services/polkit/agentimpl.cpp @@ -0,0 +1,180 @@ +#include "agentimpl.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "../../core/generation.hpp" +#include "../../core/logcat.hpp" +#include "gobjectref.hpp" +#include "listener.hpp" +#include "qml.hpp" + +namespace { +QS_LOGGING_CATEGORY(logPolkit, "quickshell.service.polkit", QtWarningMsg); +} + +namespace qs::service::polkit { +PolkitAgentImpl* PolkitAgentImpl::instance = nullptr; + +PolkitAgentImpl::PolkitAgentImpl(PolkitAgent* agent) + : QObject(nullptr) + , listener(qs_polkit_agent_new(this), G_OBJECT_NO_REF) + , qmlAgent(agent) + , path(this->qmlAgent->path()) { + auto utf8Path = this->path.toUtf8(); + qs_polkit_agent_register(this->listener.get(), utf8Path.constData()); +} + +PolkitAgentImpl::~PolkitAgentImpl() { this->cancelAllRequests("PolkitAgent is being destroyed"); } + +void PolkitAgentImpl::cancelAllRequests(const QString& reason) { + for (; !this->queuedRequests.empty(); this->queuedRequests.pop_back()) { + AuthRequest* req = this->queuedRequests.back(); + qCDebug(logPolkit) << "destroying queued authentication request for action" << req->actionId; + req->cancel(reason); + delete req; + } + + auto* flow = this->bActiveFlow.value(); + if (flow) { + flow->cancelAuthenticationRequest(); + flow->deleteLater(); + } + + if (this->bIsRegistered.value()) qs_polkit_agent_unregister(this->listener.get()); +} + +PolkitAgentImpl* PolkitAgentImpl::tryGetOrCreate(PolkitAgent* agent) { + if (instance == nullptr) instance = new PolkitAgentImpl(agent); + if (instance->qmlAgent == agent) return instance; + return nullptr; +} + +PolkitAgentImpl* PolkitAgentImpl::tryGet(const PolkitAgent* agent) { + if (instance == nullptr) return nullptr; + if (instance->qmlAgent == agent) return instance; + return nullptr; +} + +PolkitAgentImpl* PolkitAgentImpl::tryTakeoverOrCreate(PolkitAgent* agent) { + if (auto* impl = tryGetOrCreate(agent); impl != nullptr) return impl; + + auto* prevGen = EngineGeneration::findObjectGeneration(instance->qmlAgent); + auto* myGen = EngineGeneration::findObjectGeneration(agent); + if (prevGen == myGen) return nullptr; + + qCDebug(logPolkit) << "taking over listener from previous generation"; + instance->qmlAgent = agent; + instance->setPath(agent->path()); + + return instance; +} + +void PolkitAgentImpl::onEndOfQmlAgent(PolkitAgent* agent) { + if (instance != nullptr && instance->qmlAgent == agent) { + delete instance; + instance = nullptr; + } +} + +void PolkitAgentImpl::setPath(const QString& path) { + if (this->path == path) return; + + this->path = path; + auto utf8Path = path.toUtf8(); + + this->cancelAllRequests("PolkitAgent path changed"); + qs_polkit_agent_unregister(this->listener.get()); + this->bIsRegistered = false; + + qs_polkit_agent_register(this->listener.get(), utf8Path.constData()); +} + +void PolkitAgentImpl::registerComplete(bool success) { + if (success) this->bIsRegistered = true; + else qCWarning(logPolkit) << "failed to register listener on path" << this->qmlAgent->path(); +} + +void PolkitAgentImpl::initiateAuthentication(AuthRequest* request) { + qCDebug(logPolkit) << "incoming authentication request for action" << request->actionId; + + this->queuedRequests.emplace_back(request); + + if (this->queuedRequests.size() == 1) { + this->activateAuthenticationRequest(); + } +} + +void PolkitAgentImpl::cancelAuthentication(AuthRequest* request) { + qCDebug(logPolkit) << "cancelling authentication request from agent"; + + auto* flow = this->bActiveFlow.value(); + if (flow && flow->authRequest() == request) { + flow->cancelFromAgent(); + } else if (auto it = std::ranges::find(this->queuedRequests, request); + it != this->queuedRequests.end()) + { + qCDebug(logPolkit) << "removing queued authentication request for action" << (*it)->actionId; + (*it)->cancel("Authentication request was cancelled"); + delete (*it); + this->queuedRequests.erase(it); + } else { + qCWarning(logPolkit) << "the cancelled request was not found in the queue."; + } +} + +void PolkitAgentImpl::activateAuthenticationRequest() { + if (this->queuedRequests.empty()) return; + + AuthRequest* req = this->queuedRequests.front(); + this->queuedRequests.pop_front(); + qCDebug(logPolkit) << "activating authentication request for action" << req->actionId + << ", cookie: " << req->cookie; + + QList identities; + for (auto& identity: req->identities) { + auto* obj = Identity::fromPolkitIdentity(identity); + if (obj) identities.append(obj); + } + if (identities.isEmpty()) { + qCWarning( + logPolkit + ) << "no supported identities available for authentication request, cancelling."; + req->cancel("Error requesting authentication: no supported identities available."); + delete req; + return; + } + + this->bActiveFlow = new AuthFlow(req, std::move(identities)); + + QObject::connect( + this->bActiveFlow.value(), + &AuthFlow::isCompletedChanged, + this, + &PolkitAgentImpl::finishAuthenticationRequest + ); + + emit this->qmlAgent->authenticationRequestStarted(); +} + +void PolkitAgentImpl::finishAuthenticationRequest() { + if (!this->bActiveFlow.value()) return; + + qCDebug(logPolkit) << "finishing authentication request for action" + << this->bActiveFlow.value()->actionId(); + + this->bActiveFlow.value()->deleteLater(); + + if (!this->queuedRequests.empty()) { + this->activateAuthenticationRequest(); + } else { + this->bActiveFlow = nullptr; + } +} +} // namespace qs::service::polkit diff --git a/src/services/polkit/agentimpl.hpp b/src/services/polkit/agentimpl.hpp new file mode 100644 index 0000000..65ae11a --- /dev/null +++ b/src/services/polkit/agentimpl.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include +#include + +#include "flow.hpp" +#include "gobjectref.hpp" +#include "listener.hpp" + +namespace qs::service::polkit { +class PolkitAgent; + +class PolkitAgentImpl + : public QObject + , public ListenerCb { + Q_OBJECT; + Q_DISABLE_COPY_MOVE(PolkitAgentImpl); + +public: + ~PolkitAgentImpl() override; + + static PolkitAgentImpl* tryGetOrCreate(PolkitAgent* agent); + static PolkitAgentImpl* tryGet(const PolkitAgent* agent); + static PolkitAgentImpl* tryTakeoverOrCreate(PolkitAgent* agent); + static void onEndOfQmlAgent(PolkitAgent* agent); + + [[nodiscard]] QBindable activeFlow() { return &this->bActiveFlow; }; + [[nodiscard]] QBindable isRegistered() { return &this->bIsRegistered; }; + + [[nodiscard]] const QString& getPath() const { return this->path; } + void setPath(const QString& path); + + void initiateAuthentication(AuthRequest* request) override; + void cancelAuthentication(AuthRequest* request) override; + void registerComplete(bool success) override; + + void cancelAllRequests(const QString& reason); + +signals: + void activeFlowChanged(); + void isRegisteredChanged(); + +private: + PolkitAgentImpl(PolkitAgent* agent); + + static PolkitAgentImpl* instance; + + /// Start handling of the next authentication request in the queue. + void activateAuthenticationRequest(); + /// Finalize and remove the current authentication request. + void finishAuthenticationRequest(); + + GObjectRef listener; + PolkitAgent* qmlAgent = nullptr; + QString path; + + std::deque queuedRequests; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(PolkitAgentImpl, AuthFlow*, bActiveFlow, &PolkitAgentImpl::activeFlowChanged); + Q_OBJECT_BINDABLE_PROPERTY(PolkitAgentImpl, bool, bIsRegistered, &PolkitAgentImpl::isRegisteredChanged); + // clang-format on +}; +} // namespace qs::service::polkit diff --git a/src/services/polkit/flow.cpp b/src/services/polkit/flow.cpp new file mode 100644 index 0000000..2a709eb --- /dev/null +++ b/src/services/polkit/flow.cpp @@ -0,0 +1,163 @@ +#include "flow.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "identity.hpp" +#include "qml.hpp" +#include "session.hpp" + +namespace { +QS_LOGGING_CATEGORY(logPolkitState, "quickshell.service.polkit.state", QtWarningMsg); +} + +namespace qs::service::polkit { +AuthFlow::AuthFlow(AuthRequest* request, QList&& identities, QObject* parent) + : QObject(parent) + , mRequest(request) + , mIdentities(std::move(identities)) + , bSelectedIdentity(this->mIdentities.isEmpty() ? nullptr : this->mIdentities.first()) { + // We reject auth requests with no identities before a flow is created. + // This should never happen. + if (!this->bSelectedIdentity.value()) + qCFatal(logPolkitState) << "AuthFlow created with no valid identities!"; + + for (auto* identity: this->mIdentities) { + identity->setParent(this); + } + + this->setupSession(); +} + +AuthFlow::~AuthFlow() { delete this->mRequest; }; + +void AuthFlow::setSelectedIdentity(Identity* identity) { + if (this->bSelectedIdentity.value() == identity) return; + if (!identity) { + qmlWarning(this) << "Cannot set selected identity to null."; + return; + } + this->bSelectedIdentity = identity; + this->currentSession->cancel(); + this->setupSession(); +} + +void AuthFlow::cancelFromAgent() { + if (!this->currentSession) return; + + qCDebug(logPolkitState) << "cancelling authentication request from agent"; + + // Session cancel can immediately call the cancel handler, which also + // performs property updates. + Qt::beginPropertyUpdateGroup(); + this->bIsCancelled = true; + this->currentSession->cancel(); + Qt::endPropertyUpdateGroup(); + + emit this->authenticationRequestCancelled(); + + this->mRequest->cancel("Authentication request cancelled by agent."); +} + +void AuthFlow::submit(const QString& value) { + if (!this->currentSession) return; + + qCDebug(logPolkitState) << "submitting response to authentication request"; + + this->currentSession->respond(value); + + Qt::beginPropertyUpdateGroup(); + this->bIsResponseRequired = false; + this->bInputPrompt = QString(); + this->bResponseVisible = false; + Qt::endPropertyUpdateGroup(); +} + +void AuthFlow::cancelAuthenticationRequest() { + if (!this->currentSession) return; + + qCDebug(logPolkitState) << "cancelling authentication request by user request"; + + // Session cancel can immediately call the cancel handler, which also + // performs property updates. + Qt::beginPropertyUpdateGroup(); + this->bIsCancelled = true; + this->currentSession->cancel(); + Qt::endPropertyUpdateGroup(); + + this->mRequest->cancel("Authentication request cancelled by user."); +} + +void AuthFlow::setupSession() { + delete this->currentSession; + + qCDebug(logPolkitState) << "setting up session for identity" + << this->bSelectedIdentity.value()->name(); + + this->currentSession = new Session( + this->bSelectedIdentity.value()->polkitIdentity.get(), + this->mRequest->cookie, + this + ); + QObject::connect(this->currentSession, &Session::request, this, &AuthFlow::request); + QObject::connect(this->currentSession, &Session::completed, this, &AuthFlow::completed); + QObject::connect(this->currentSession, &Session::showError, this, &AuthFlow::showError); + QObject::connect(this->currentSession, &Session::showInfo, this, &AuthFlow::showInfo); + this->currentSession->initiate(); +} + +void AuthFlow::request(const QString& message, bool echo) { + Qt::beginPropertyUpdateGroup(); + this->bIsResponseRequired = true; + this->bInputPrompt = message; + this->bResponseVisible = echo; + Qt::endPropertyUpdateGroup(); +} + +void AuthFlow::completed(bool gainedAuthorization) { + qCDebug(logPolkitState) << "authentication session completed, gainedAuthorization =" + << gainedAuthorization << ", isCancelled =" << this->bIsCancelled.value(); + + if (gainedAuthorization) { + Qt::beginPropertyUpdateGroup(); + this->bIsCompleted = true; + this->bIsSuccessful = true; + Qt::endPropertyUpdateGroup(); + + this->mRequest->complete(); + + emit this->authenticationSucceeded(); + } else if (this->bIsCancelled.value()) { + Qt::beginPropertyUpdateGroup(); + this->bIsCompleted = true; + this->bIsSuccessful = false; + Qt::endPropertyUpdateGroup(); + } else { + this->bFailed = true; + emit this->authenticationFailed(); + + this->setupSession(); + } +} + +void AuthFlow::showError(const QString& message) { + Qt::beginPropertyUpdateGroup(); + this->bSupplementaryMessage = message; + this->bSupplementaryIsError = true; + Qt::endPropertyUpdateGroup(); +} + +void AuthFlow::showInfo(const QString& message) { + Qt::beginPropertyUpdateGroup(); + this->bSupplementaryMessage = message; + this->bSupplementaryIsError = false; + Qt::endPropertyUpdateGroup(); +} +} // namespace qs::service::polkit diff --git a/src/services/polkit/flow.hpp b/src/services/polkit/flow.hpp new file mode 100644 index 0000000..0b7e845 --- /dev/null +++ b/src/services/polkit/flow.hpp @@ -0,0 +1,179 @@ +#pragma once + +#include +#include +#include + +#include "../../core/retainable.hpp" +#include "identity.hpp" +#include "listener.hpp" + +namespace qs::service::polkit { +class Session; + +class AuthFlow + : public QObject + , public Retainable { + Q_OBJECT; + QML_ELEMENT; + Q_DISABLE_COPY_MOVE(AuthFlow); + QML_UNCREATABLE("AuthFlow can only be obtained from PolkitAgent."); + + // clang-format off + /// The main message to present to the user. + Q_PROPERTY(QString message READ message CONSTANT); + + /// The icon to present to the user in association with the message. + /// + /// The icon name follows the [FreeDesktop icon naming specification](https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html). + /// Use @@Quickshell.Quickshell.iconPath() to resolve the icon name to an + /// actual file path for display. + Q_PROPERTY(QString iconName READ iconName CONSTANT); + + /// The action ID represents the action that is being authorized. + /// + /// This is a machine-readable identifier. + Q_PROPERTY(QString actionId READ actionId CONSTANT); + + /// A cookie that identifies this authentication request. + /// + /// This is an internal identifier and not recommended to show to users. + Q_PROPERTY(QString cookie READ cookie CONSTANT); + + /// The list of identities that may be used to authenticate. + /// + /// Each identity may be a user or a group. You may select any of them to + /// authenticate by setting @@selectedIdentity. By default, the first identity + /// in the list is selected. + Q_PROPERTY(QList identities READ identities CONSTANT); + + /// The identity that will be used to authenticate. + /// + /// Changing this will abort any ongoing authentication conversations and start a new one. + Q_PROPERTY(Identity* selectedIdentity READ default WRITE setSelectedIdentity NOTIFY selectedIdentityChanged BINDABLE selectedIdentity); + + /// Indicates that a response from the user is required from the user, + /// typically a password. + Q_PROPERTY(bool isResponseRequired READ default NOTIFY isResponseRequiredChanged BINDABLE isResponseRequired); + + /// This message is used to prompt the user for required input. + Q_PROPERTY(QString inputPrompt READ default NOTIFY inputPromptChanged BINDABLE inputPrompt); + + /// Indicates whether the user's response should be visible. (e.g. for passwords this should be false) + Q_PROPERTY(bool responseVisible READ default NOTIFY responseVisibleChanged BINDABLE responseVisible); + + /// An additional message to present to the user. + /// + /// This may be used to show errors or supplementary information. + /// See @@supplementaryIsError to determine if this is an error message. + Q_PROPERTY(QString supplementaryMessage READ default NOTIFY supplementaryMessageChanged BINDABLE supplementaryMessage); + + /// Indicates whether the supplementary message is an error. + Q_PROPERTY(bool supplementaryIsError READ default NOTIFY supplementaryIsErrorChanged BINDABLE supplementaryIsError); + + /// Has the authentication request been completed. + Q_PROPERTY(bool isCompleted READ default NOTIFY isCompletedChanged BINDABLE isCompleted); + + /// Indicates whether the authentication request was successful. + Q_PROPERTY(bool isSuccessful READ default NOTIFY isSuccessfulChanged BINDABLE isSuccessful); + + /// Indicates whether the current authentication request was cancelled. + Q_PROPERTY(bool isCancelled READ default NOTIFY isCancelledChanged BINDABLE isCancelled); + + /// Indicates whether an authentication attempt has failed at least once during this authentication flow. + Q_PROPERTY(bool failed READ default NOTIFY failedChanged BINDABLE failed); + // clang-format on + +public: + explicit AuthFlow(AuthRequest* request, QList&& identities, QObject* parent = nullptr); + ~AuthFlow() override; + + /// Cancel the ongoing authentication request from the agent side. + void cancelFromAgent(); + + /// Submit a response to a request that was previously emitted. Typically the password. + Q_INVOKABLE void submit(const QString& value); + /// Cancel the ongoing authentication request from the user side. + Q_INVOKABLE void cancelAuthenticationRequest(); + + [[nodiscard]] const QString& message() const { return this->mRequest->message; }; + [[nodiscard]] const QString& iconName() const { return this->mRequest->iconName; }; + [[nodiscard]] const QString& actionId() const { return this->mRequest->actionId; }; + [[nodiscard]] const QString& cookie() const { return this->mRequest->cookie; }; + [[nodiscard]] const QList& identities() const { return this->mIdentities; }; + + [[nodiscard]] QBindable selectedIdentity() { return &this->bSelectedIdentity; }; + void setSelectedIdentity(Identity* identity); + + [[nodiscard]] QBindable isResponseRequired() { return &this->bIsResponseRequired; }; + [[nodiscard]] QBindable inputPrompt() { return &this->bInputPrompt; }; + [[nodiscard]] QBindable responseVisible() { return &this->bResponseVisible; }; + + [[nodiscard]] QBindable supplementaryMessage() { return &this->bSupplementaryMessage; }; + [[nodiscard]] QBindable supplementaryIsError() { return &this->bSupplementaryIsError; }; + + [[nodiscard]] QBindable isCompleted() { return &this->bIsCompleted; }; + [[nodiscard]] QBindable isSuccessful() { return &this->bIsSuccessful; }; + [[nodiscard]] QBindable isCancelled() { return &this->bIsCancelled; }; + [[nodiscard]] QBindable failed() { return &this->bFailed; }; + + [[nodiscard]] AuthRequest* authRequest() const { return this->mRequest; }; + +signals: + /// Emitted whenever an authentication request completes successfully. + void authenticationSucceeded(); + + /// Emitted whenever an authentication request completes unsuccessfully. + /// + /// This may be because the user entered the wrong password or otherwise + /// failed to authenticate. + /// This signal is not emmitted when the user canceled the request or it + /// was cancelled by the PolKit daemon. + /// + /// After this signal, a new session is automatically started for the same + /// identity. + void authenticationFailed(); + + /// Emmitted when on ongoing authentication request is cancelled by the PolKit daemon. + void authenticationRequestCancelled(); + + void selectedIdentityChanged(); + void isResponseRequiredChanged(); + void inputPromptChanged(); + void responseVisibleChanged(); + void supplementaryMessageChanged(); + void supplementaryIsErrorChanged(); + void isCompletedChanged(); + void isSuccessfulChanged(); + void isCancelledChanged(); + void failedChanged(); + +private slots: + // Signals received from session objects. + void request(const QString& message, bool echo); + void completed(bool gainedAuthorization); + void showError(const QString& message); + void showInfo(const QString& message); + +private: + /// Start a session for the currently selected identity and the current request. + void setupSession(); + + Session* currentSession = nullptr; + AuthRequest* mRequest = nullptr; + QList mIdentities; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, Identity*, bSelectedIdentity, &AuthFlow::selectedIdentityChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, bool, bIsResponseRequired, &AuthFlow::isResponseRequiredChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, QString, bInputPrompt, &AuthFlow::inputPromptChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, bool, bResponseVisible, &AuthFlow::responseVisibleChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, QString, bSupplementaryMessage, &AuthFlow::supplementaryMessageChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, bool, bSupplementaryIsError, &AuthFlow::supplementaryIsErrorChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, bool, bIsCompleted, &AuthFlow::isCompletedChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, bool, bIsSuccessful, &AuthFlow::isSuccessfulChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, bool, bIsCancelled, &AuthFlow::isCancelledChanged); + Q_OBJECT_BINDABLE_PROPERTY(AuthFlow, bool, bFailed, &AuthFlow::failedChanged); + // clang-format on +}; +} // namespace qs::service::polkit diff --git a/src/services/polkit/gobjectref.hpp b/src/services/polkit/gobjectref.hpp new file mode 100644 index 0000000..cd29a9d --- /dev/null +++ b/src/services/polkit/gobjectref.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include + +namespace qs::service::polkit { + +struct GObjectNoRefTag {}; +constexpr GObjectNoRefTag G_OBJECT_NO_REF; + +template +class GObjectRef { +public: + explicit GObjectRef(T* ptr = nullptr): ptr(ptr) { + if (this->ptr) { + g_object_ref(this->ptr); + } + } + + explicit GObjectRef(T* ptr, GObjectNoRefTag /*tag*/): ptr(ptr) {} + + ~GObjectRef() { + if (this->ptr) { + g_object_unref(this->ptr); + } + } + + // We do handle self-assignment in a more general case by checking the + // included pointers rather than the wrapper objects themselves. + // NOLINTBEGIN(bugprone-unhandled-self-assignment) + + GObjectRef(const GObjectRef& other): GObjectRef(other.ptr) {} + GObjectRef& operator=(const GObjectRef& other) { + if (*this == other) return *this; + if (this->ptr) { + g_object_unref(this->ptr); + } + this->ptr = other.ptr; + if (this->ptr) { + g_object_ref(this->ptr); + } + return *this; + } + + GObjectRef(GObjectRef&& other) noexcept: ptr(other.ptr) { other.ptr = nullptr; } + GObjectRef& operator=(GObjectRef&& other) noexcept { + if (*this == other) return *this; + if (this->ptr) { + g_object_unref(this->ptr); + } + this->ptr = other.ptr; + other.ptr = nullptr; + return *this; + } + + // NOLINTEND(bugprone-unhandled-self-assignment) + + [[nodiscard]] T* get() const { return this->ptr; } + T* operator->() const { return this->ptr; } + + bool operator==(const GObjectRef& other) const { return this->ptr == other.ptr; } + +private: + T* ptr; +}; +} // namespace qs::service::polkit \ No newline at end of file diff --git a/src/services/polkit/identity.cpp b/src/services/polkit/identity.cpp new file mode 100644 index 0000000..7be5f39 --- /dev/null +++ b/src/services/polkit/identity.cpp @@ -0,0 +1,84 @@ +#include "identity.hpp" +#include +#include +#include + +#include +#include +#include + +#define POLKIT_AGENT_I_KNOW_API_IS_SUBJECT_TO_CHANGE +// Workaround macro collision with glib 'signals' struct member. +#undef signals +#include +#define signals Q_SIGNALS +#include +#include +#include + +#include "gobjectref.hpp" + +namespace qs::service::polkit { +Identity::Identity( + id_t id, + QString name, + QString displayName, + bool isGroup, + GObjectRef polkitIdentity, + QObject* parent +) + : QObject(parent) + , polkitIdentity(std::move(polkitIdentity)) + , mId(id) + , mName(std::move(name)) + , mDisplayName(std::move(displayName)) + , mIsGroup(isGroup) {} + +Identity* Identity::fromPolkitIdentity(GObjectRef identity) { + if (POLKIT_IS_UNIX_USER(identity.get())) { + auto uid = polkit_unix_user_get_uid(POLKIT_UNIX_USER(identity.get())); + + auto bufSize = sysconf(_SC_GETPW_R_SIZE_MAX); + // The call can fail with -1, in this case choose a default that is + // big enough. + if (bufSize == -1) bufSize = 16384; + auto buffer = std::vector(bufSize); + + std::aligned_storage_t pwBuf; + passwd* pw = nullptr; + getpwuid_r(uid, reinterpret_cast(&pwBuf), buffer.data(), bufSize, &pw); + + auto name = + (pw && pw->pw_name && *pw->pw_name) ? QString::fromUtf8(pw->pw_name) : QString::number(uid); + + return new Identity( + uid, + name, + (pw && pw->pw_gecos && *pw->pw_gecos) ? QString::fromUtf8(pw->pw_gecos) : name, + false, + std::move(identity) + ); + } + + if (POLKIT_IS_UNIX_GROUP(identity.get())) { + auto gid = polkit_unix_group_get_gid(POLKIT_UNIX_GROUP(identity.get())); + + auto bufSize = sysconf(_SC_GETGR_R_SIZE_MAX); + // The call can fail with -1, in this case choose a default that is + // big enough. + if (bufSize == -1) bufSize = 16384; + auto buffer = std::vector(bufSize); + + std::aligned_storage_t grBuf; + group* gr = nullptr; + getgrgid_r(gid, reinterpret_cast(&grBuf), buffer.data(), bufSize, &gr); + + auto name = + (gr && gr->gr_name && *gr->gr_name) ? QString::fromUtf8(gr->gr_name) : QString::number(gid); + return new Identity(gid, name, name, true, std::move(identity)); + } + + // A different type of identity is netgroup. + return nullptr; +} +} // namespace qs::service::polkit diff --git a/src/services/polkit/identity.hpp b/src/services/polkit/identity.hpp new file mode 100644 index 0000000..27f3c1c --- /dev/null +++ b/src/services/polkit/identity.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include + +#include "gobjectref.hpp" + +// _PolkitIdentity is considered a reserved identifier, but I am specifically +// forward declaring this reserved name. +using PolkitIdentity = struct _PolkitIdentity; // NOLINT(bugprone-reserved-identifier) + +namespace qs::service::polkit { +//! Represents a user or group that can be used to authenticate. +class Identity: public QObject { + Q_OBJECT; + Q_DISABLE_COPY_MOVE(Identity); + + // clang-format off + /// The Id of the identity. If the identity is a user, this is the user's uid. See @@isGroup. + Q_PROPERTY(quint32 id READ id CONSTANT); + + /// The name of the user or group. + /// + /// If available, this is the actual username or group name, but may fallback to the ID. + Q_PROPERTY(QString string READ name CONSTANT); + + /// The full name of the user or group, if available. Otherwise the same as @@name. + Q_PROPERTY(QString displayName READ displayName CONSTANT); + + /// Indicates if this identity is a group or a user. + /// + /// If true, @@id is a gid, otherwise it is a uid. + Q_PROPERTY(bool isGroup READ isGroup CONSTANT); + + QML_UNCREATABLE("Identities cannot be created directly."); + // clang-format on + +public: + explicit Identity( + id_t id, + QString name, + QString displayName, + bool isGroup, + GObjectRef polkitIdentity, + QObject* parent = nullptr + ); + ~Identity() override = default; + + static Identity* fromPolkitIdentity(GObjectRef identity); + + [[nodiscard]] quint32 id() const { return static_cast(this->mId); }; + [[nodiscard]] const QString& name() const { return this->mName; }; + [[nodiscard]] const QString& displayName() const { return this->mDisplayName; }; + [[nodiscard]] bool isGroup() const { return this->mIsGroup; }; + + GObjectRef polkitIdentity; + +private: + id_t mId; + QString mName; + QString mDisplayName; + bool mIsGroup; +}; +} // namespace qs::service::polkit diff --git a/src/services/polkit/listener.cpp b/src/services/polkit/listener.cpp new file mode 100644 index 0000000..e4bca4c --- /dev/null +++ b/src/services/polkit/listener.cpp @@ -0,0 +1,232 @@ +#include "listener.hpp" +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "gobjectref.hpp" +#include "qml.hpp" + +namespace { +QS_LOGGING_CATEGORY(logPolkitListener, "quickshell.service.polkit.listener", QtWarningMsg); +} + +using qs::service::polkit::GObjectRef; + +// This is mostly GObject code, we follow their naming conventions for improved +// clarity and to mark it as such. Additionally, many methods need to be static +// to conform with the expected declarations. +// NOLINTBEGIN(readability-identifier-naming,misc-use-anonymous-namespace) + +using QsPolkitAgent = struct _QsPolkitAgent { + PolkitAgentListener parent_instance; + + qs::service::polkit::ListenerCb* cb; + gpointer registration_handle; +}; + +G_DEFINE_TYPE(QsPolkitAgent, qs_polkit_agent, POLKIT_AGENT_TYPE_LISTENER) + +static void initiate_authentication( + PolkitAgentListener* listener, + const gchar* actionId, + const gchar* message, + const gchar* iconName, + PolkitDetails* details, + const gchar* cookie, + GList* identities, + GCancellable* cancellable, + GAsyncReadyCallback callback, + gpointer userData +); + +static gboolean +initiate_authentication_finish(PolkitAgentListener* listener, GAsyncResult* result, GError** error); + +static void qs_polkit_agent_init(QsPolkitAgent* self) { + self->cb = nullptr; + self->registration_handle = nullptr; +} + +static void qs_polkit_agent_finalize(GObject* object) { + if (G_OBJECT_CLASS(qs_polkit_agent_parent_class)) + G_OBJECT_CLASS(qs_polkit_agent_parent_class)->finalize(object); +} + +static void qs_polkit_agent_class_init(QsPolkitAgentClass* klass) { + GObjectClass* gobject_class = G_OBJECT_CLASS(klass); + gobject_class->finalize = qs_polkit_agent_finalize; + + PolkitAgentListenerClass* listener_class = POLKIT_AGENT_LISTENER_CLASS(klass); + listener_class->initiate_authentication = initiate_authentication; + listener_class->initiate_authentication_finish = initiate_authentication_finish; +} + +QsPolkitAgent* qs_polkit_agent_new(qs::service::polkit::ListenerCb* cb) { + QsPolkitAgent* self = QS_POLKIT_AGENT(g_object_new(QS_TYPE_POLKIT_AGENT, nullptr)); + self->cb = cb; + return self; +} + +struct RegisterCbData { + GObjectRef agent; + std::string path; +}; + +static void qs_polkit_agent_register_cb(GObject* /*unused*/, GAsyncResult* res, gpointer userData); +void qs_polkit_agent_register(QsPolkitAgent* agent, const char* path) { + if (path == nullptr || *path == '\0') { + qCWarning(logPolkitListener) << "cannot register listener without a path set."; + agent->cb->registerComplete(false); + return; + } + + auto* data = new RegisterCbData {.agent = GObjectRef(agent), .path = path}; + polkit_unix_session_new_for_process(getpid(), nullptr, &qs_polkit_agent_register_cb, data); +} + +static void qs_polkit_agent_register_cb(GObject* /*unused*/, GAsyncResult* res, gpointer userData) { + std::unique_ptr data(reinterpret_cast(userData)); + + GError* error = nullptr; + auto* subject = polkit_unix_session_new_for_process_finish(res, &error); + + if (subject == nullptr || error != nullptr) { + qCWarning(logPolkitListener) << "failed to create subject for listener:" + << (error ? error->message : ""); + g_clear_error(&error); + data->agent->cb->registerComplete(false); + return; + } + + data->agent->registration_handle = polkit_agent_listener_register( + POLKIT_AGENT_LISTENER(data->agent.get()), + POLKIT_AGENT_REGISTER_FLAGS_NONE, + subject, + data->path.c_str(), + nullptr, + &error + ); + + g_object_unref(subject); + + if (error != nullptr) { + qCWarning(logPolkitListener) << "failed to register listener:" << error->message; + g_clear_error(&error); + data->agent->cb->registerComplete(false); + return; + } + + data->agent->cb->registerComplete(true); +} + +void qs_polkit_agent_unregister(QsPolkitAgent* agent) { + if (agent->registration_handle != nullptr) { + polkit_agent_listener_unregister(agent->registration_handle); + agent->registration_handle = nullptr; + } +} + +static void authentication_cancelled_cb(GCancellable* /*unused*/, gpointer userData) { + auto* request = static_cast(userData); + request->cb->cancelAuthentication(request); +} + +static void initiate_authentication( + PolkitAgentListener* listener, + const gchar* actionId, + const gchar* message, + const gchar* iconName, + PolkitDetails* /*unused*/, + const gchar* cookie, + GList* identities, + GCancellable* cancellable, + GAsyncReadyCallback callback, + gpointer userData +) { + auto* self = QS_POLKIT_AGENT(listener); + + auto* asyncResult = g_task_new(reinterpret_cast(self), nullptr, callback, userData); + + // Identities may be duplicated, so we use the hash to filter them out. + std::unordered_set identitySet; + std::vector> identityVector; + for (auto* item = g_list_first(identities); item != nullptr; item = g_list_next(item)) { + auto* identity = static_cast(item->data); + if (identitySet.contains(polkit_identity_hash(identity))) continue; + + identitySet.insert(polkit_identity_hash(identity)); + // The caller unrefs all identities after we return, therefore we need to + // take our own reference for the identities we keep. Our wrapper does + // this automatically. + identityVector.emplace_back(identity); + } + + // The original strings are freed by the caller after we return, so we + // copy them into QStrings. + auto* request = new qs::service::polkit::AuthRequest { + .actionId = QString::fromUtf8(actionId), + .message = QString::fromUtf8(message), + .iconName = QString::fromUtf8(iconName), + .cookie = QString::fromUtf8(cookie), + .identities = std::move(identityVector), + + .task = asyncResult, + .cancellable = cancellable, + .handlerId = 0, + .cb = self->cb + }; + + if (cancellable != nullptr) { + request->handlerId = g_cancellable_connect( + cancellable, + reinterpret_cast(authentication_cancelled_cb), + request, + nullptr + ); + } + + self->cb->initiateAuthentication(request); +} + +static gboolean initiate_authentication_finish( + PolkitAgentListener* /*unused*/, + GAsyncResult* result, + GError** error +) { + return g_task_propagate_boolean(G_TASK(result), error); +} + +namespace qs::service::polkit { +// While these functions can be const since they do not modify member variables, +// they are logically non-const since they modify the state of the +// authentication request. Therefore, we do not mark them as const. +// NOLINTBEGIN(readability-make-member-function-const) +void AuthRequest::complete() { g_task_return_boolean(this->task, true); } + +void AuthRequest::cancel(const QString& reason) { + auto utf8Reason = reason.toUtf8(); + g_task_return_new_error( + this->task, + POLKIT_ERROR, + POLKIT_ERROR_CANCELLED, + "%s", + utf8Reason.constData() + ); +} +// NOLINTEND(readability-make-member-function-const) +} // namespace qs::service::polkit + +// NOLINTEND(readability-identifier-naming,misc-use-anonymous-namespace) diff --git a/src/services/polkit/listener.hpp b/src/services/polkit/listener.hpp new file mode 100644 index 0000000..996fa23 --- /dev/null +++ b/src/services/polkit/listener.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include + +#define POLKIT_AGENT_I_KNOW_API_IS_SUBJECT_TO_CHANGE +// This causes a problem with variables of the name. +#undef signals + +#include +#include + +#define signals Q_SIGNALS + +#include "gobjectref.hpp" + +namespace qs::service::polkit { +class ListenerCb; +//! All state that comes in from PolKit about an authentication request. +struct AuthRequest { + //! The action ID that this session is for. + QString actionId; + //! Message to present to the user. + QString message; + //! Icon name according to the FreeDesktop specification. May be empty. + QString iconName; + // Details intentionally omitted because nothing seems to use them. + QString cookie; + //! List of users/groups that can be used for authentication. + std::vector> identities; + + //! Implementation detail to mark authentication done. + GTask* task; + //! Implementation detail for requests cancelled by agent. + GCancellable* cancellable; + //! Callback handler ID for the cancellable. + gulong handlerId; + //! Callbacks for the listener + ListenerCb* cb; + + void complete(); + void cancel(const QString& reason); +}; + +//! Callback interface for PolkitAgent listener events. +class ListenerCb { +public: + ListenerCb() = default; + virtual ~ListenerCb() = default; + Q_DISABLE_COPY_MOVE(ListenerCb); + + //! Called when the agent registration is complete. + virtual void registerComplete(bool success) = 0; + //! Called when an authentication request is initiated by PolKit. + virtual void initiateAuthentication(AuthRequest* request) = 0; + //! Called when an authentication request is cancelled by PolKit before completion. + virtual void cancelAuthentication(AuthRequest* request) = 0; +}; +} // namespace qs::service::polkit + +G_BEGIN_DECLS + +// This is GObject code. By using their naming conventions, we clearly mark it +// as such for the rest of the project. +// NOLINTBEGIN(readability-identifier-naming) + +#define QS_TYPE_POLKIT_AGENT (qs_polkit_agent_get_type()) +G_DECLARE_FINAL_TYPE(QsPolkitAgent, qs_polkit_agent, QS, POLKIT_AGENT, PolkitAgentListener) + +QsPolkitAgent* qs_polkit_agent_new(qs::service::polkit::ListenerCb* cb); +void qs_polkit_agent_register(QsPolkitAgent* agent, const char* path); +void qs_polkit_agent_unregister(QsPolkitAgent* agent); + +// NOLINTEND(readability-identifier-naming) + +G_END_DECLS diff --git a/src/services/polkit/module.md b/src/services/polkit/module.md new file mode 100644 index 0000000..b306ecb --- /dev/null +++ b/src/services/polkit/module.md @@ -0,0 +1,52 @@ +name = "Quickshell.Services.Polkit" +description = "Polkit Agent" +headers = [ + "agentimpl.hpp", + "flow.hpp", + "identity.hpp", + "listener.hpp", + "qml.hpp", + "session.hpp", +] +----- +## Purpose of a Polkit Agent + +PolKit is a system for privileged applications to query if a user is permitted to execute an action. +You have probably seen it in the form of a "Please enter your password to continue with X" dialog box before. +This dialog box is presented by your *PolKit agent*, it is a process running as your user that accepts authentication requests from the *daemon* and presents them to you to accept or deny. + +This service enables writing a PolKit agent in Quickshell. + +## Implementing a Polkit Agent + +The backend logic of communicating with the daemon is handled by the @@Quickshell.Services.Polkit.PolkitAgent object. +It exposes incoming requests via @@Quickshell.Services.Polkit.PolkitAgent.flow and provides appropriate signals. + +### Flow of an authentication request + +Incoming authentication requests are queued in the order that they arrive. +If none is queued, a request starts processing right away. +Otherwise, it will wait until prior requests are done. + +A request starts by emitting the @@Quickshell.Services.Polkit.PolkitAgent.authenticationRequestStarted signal. +At this point, information like the action to be performed and permitted users that can authenticate is available. + +An authentication *session* for the request is immediately started, which internally starts a PAM conversation that is likely to prompt for user input. +* Additional prompts may be shared with the user by way of the @@Quickshell.Services.Polkit.AuthFlow.supplementaryMessageChanged / @@Quickshell.Services.Polkit.AuthFlow.supplementaryIsErrorChanged signals and the @@Quickshell.Services.Polkit.AuthFlow.supplementaryMessage and @@Quickshell.Services.Polkit.AuthFlow.supplementaryIsError properties. A common message might be 'Please input your password'. +* An input request is forwarded via the @@Quickshell.Services.Polkit.AuthFlow.isResponseRequiredChanged / @@Quickshell.Services.Polkit.AuthFlow.inputPromptChanged / @@Quickshell.Services.Polkit.AuthFlow.responseVisibleChanged signals and the corresponding properties. Note that the request specifies whether the text box should show the typed input on screen or replace it with placeholders. + +User replies can be submitted via the @@Quickshell.Services.Polkit.AuthFlow.submit method. +A conversation can take multiple turns, for example if second factors are involved. + +If authentication fails, we automatically create a fresh session so the user can try again. +The @@Quickshell.Services.Polkit.AuthFlow.authenticationFailed signal is emitted in this case. + +If authentication is successful, you receive the @@Quickshell.Services.Polkit.AuthFlow.authenticationSucceeeded signal. At this point, the dialog can be closed. +If additional requests are queued, you will receive the @@Quickshell.Services.Polkit.PolkitAgent.authenticationRequestStarted signal again. + +#### Cancelled requests + +Requests may either be canceled by the user or the PolKit daemon. +In this case, we clean up any state and proceed to the next request, if any. + +If the request was cancelled by the daemon and not the user, you also receive the @@Quickshell.Services.Polkit.AuthFlow.authenticationRequestCancelled signal. diff --git a/src/services/polkit/qml.cpp b/src/services/polkit/qml.cpp new file mode 100644 index 0000000..9a08e5d --- /dev/null +++ b/src/services/polkit/qml.cpp @@ -0,0 +1,35 @@ +#include "qml.hpp" + +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "agentimpl.hpp" + +namespace { +QS_LOGGING_CATEGORY(logPolkit, "quickshell.service.polkit", QtWarningMsg); +} + +namespace qs::service::polkit { +PolkitAgent::~PolkitAgent() { PolkitAgentImpl::onEndOfQmlAgent(this); }; + +void PolkitAgent::componentComplete() { + if (this->mPath.isEmpty()) this->mPath = "/org/quickshell/PolkitAgent"; + + auto* impl = PolkitAgentImpl::tryTakeoverOrCreate(this); + if (impl == nullptr) return; + + this->bFlow.setBinding([impl]() { return impl->activeFlow().value(); }); + this->bIsActive.setBinding([impl]() { return impl->activeFlow().value() != nullptr; }); + this->bIsRegistered.setBinding([impl]() { return impl->isRegistered().value(); }); +} + +void PolkitAgent::setPath(const QString& path) { + if (this->mPath.isEmpty()) { + this->mPath = path; + } else if (this->mPath != path) { + qCWarning(logPolkit) << "cannot change path after it has been set."; + } +} +} // namespace qs::service::polkit diff --git a/src/services/polkit/qml.hpp b/src/services/polkit/qml.hpp new file mode 100644 index 0000000..5343bcd --- /dev/null +++ b/src/services/polkit/qml.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "flow.hpp" + +// The reserved identifier is exactly the struct I mean. +using PolkitIdentity = struct _PolkitIdentity; // NOLINT(bugprone-reserved-identifier) +using QsPolkitAgent = struct _QsPolkitAgent; + +namespace qs::service::polkit { + +struct AuthRequest; +class Session; +class Identity; +class AuthFlow; + +//! Contains interface to instantiate a PolKit agent listener. +class PolkitAgent + : public QObject + , public QQmlParserStatus { + Q_OBJECT; + QML_ELEMENT; + Q_INTERFACES(QQmlParserStatus); + Q_DISABLE_COPY_MOVE(PolkitAgent); + + /// The D-Bus path that this agent listener will use. + /// + /// If not set, a default of /org/quickshell/Polkit will be used. + Q_PROPERTY(QString path READ path WRITE setPath); + + /// Indicates whether the agent registered successfully and is in use. + Q_PROPERTY(bool isRegistered READ default NOTIFY isRegisteredChanged BINDABLE isRegistered); + + /// Indicates an ongoing authentication request. + /// + /// If this is true, other properties such as @@message and @@iconName will + /// also be populated with relevant information. + Q_PROPERTY(bool isActive READ default NOTIFY isActiveChanged BINDABLE isActive); + + /// The current authentication state if an authentication request is active. + /// + /// Null when no authentication request is active. + Q_PROPERTY(AuthFlow* flow READ default NOTIFY flowChanged BINDABLE flow); + +public: + explicit PolkitAgent(QObject* parent = nullptr): QObject(parent) {}; + ~PolkitAgent() override; + + void classBegin() override {}; + void componentComplete() override; + + [[nodiscard]] QString path() const { return this->mPath; }; + void setPath(const QString& path); + + [[nodiscard]] QBindable flow() { return &this->bFlow; }; + [[nodiscard]] QBindable isActive() { return &this->bIsActive; }; + [[nodiscard]] QBindable isRegistered() { return &this->bIsRegistered; }; + +signals: + /// Emitted when an application makes a request that requires authentication. + /// + /// At this point, @@state will be populated with relevant information. + /// Note that signals for conversation outcome are emitted from the @@AuthFlow instance. + void authenticationRequestStarted(); + + void isRegisteredChanged(); + void isActiveChanged(); + void flowChanged(); + +private: + QString mPath = ""; + + Q_OBJECT_BINDABLE_PROPERTY(PolkitAgent, AuthFlow*, bFlow, &PolkitAgent::flowChanged); + Q_OBJECT_BINDABLE_PROPERTY(PolkitAgent, bool, bIsActive, &PolkitAgent::isActiveChanged); + Q_OBJECT_BINDABLE_PROPERTY(PolkitAgent, bool, bIsRegistered, &PolkitAgent::isRegisteredChanged); +}; +} // namespace qs::service::polkit diff --git a/src/services/polkit/session.cpp b/src/services/polkit/session.cpp new file mode 100644 index 0000000..71def68 --- /dev/null +++ b/src/services/polkit/session.cpp @@ -0,0 +1,68 @@ +#include "session.hpp" + +#include +#include +#include +#include + +#define POLKIT_AGENT_I_KNOW_API_IS_SUBJECT_TO_CHANGE +// This causes a problem with variables of the name. +#undef signals +#include +#define signals Q_SIGNALS + +namespace qs::service::polkit { + +namespace { +void completedCb(PolkitAgentSession* /*session*/, gboolean gainedAuthorization, gpointer userData) { + auto* self = static_cast(userData); + emit self->completed(gainedAuthorization); +} + +void requestCb( + PolkitAgentSession* /*session*/, + const char* message, + gboolean echo, + gpointer userData +) { + auto* self = static_cast(userData); + emit self->request(QString::fromUtf8(message), echo); +} + +void showErrorCb(PolkitAgentSession* /*session*/, const char* message, gpointer userData) { + auto* self = static_cast(userData); + emit self->showError(QString::fromUtf8(message)); +} + +void showInfoCb(PolkitAgentSession* /*session*/, const char* message, gpointer userData) { + auto* self = static_cast(userData); + emit self->showInfo(QString::fromUtf8(message)); +} +} // namespace + +Session::Session(PolkitIdentity* identity, const QString& cookie, QObject* parent) + : QObject(parent) { + this->session = polkit_agent_session_new(identity, cookie.toUtf8().constData()); + + g_signal_connect(G_OBJECT(this->session), "completed", G_CALLBACK(completedCb), this); + g_signal_connect(G_OBJECT(this->session), "request", G_CALLBACK(requestCb), this); + g_signal_connect(G_OBJECT(this->session), "show-error", G_CALLBACK(showErrorCb), this); + g_signal_connect(G_OBJECT(this->session), "show-info", G_CALLBACK(showInfoCb), this); +} + +Session::~Session() { + // Signals do not need to be disconnected explicitly. This happens during + // destruction of the gobject. Since we own the session object, we can be + // sure it is being destroyed after the unref. + g_object_unref(this->session); +} + +void Session::initiate() { polkit_agent_session_initiate(this->session); } + +void Session::cancel() { polkit_agent_session_cancel(this->session); } + +void Session::respond(const QString& response) { + polkit_agent_session_response(this->session, response.toUtf8().constData()); +} + +} // namespace qs::service::polkit diff --git a/src/services/polkit/session.hpp b/src/services/polkit/session.hpp new file mode 100644 index 0000000..29331b1 --- /dev/null +++ b/src/services/polkit/session.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include + +// _PolkitIdentity and _PolkitAgentSession are considered reserved identifiers, +// but I am specifically forward declaring those reserved names. + +// NOLINTBEGIN(bugprone-reserved-identifier) +using PolkitIdentity = struct _PolkitIdentity; +using PolkitAgentSession = struct _PolkitAgentSession; +// NOLINTEND(bugprone-reserved-identifier) + +namespace qs::service::polkit { +//! Represents an authentication session for a specific identity. +class Session: public QObject { + Q_OBJECT; + Q_DISABLE_COPY_MOVE(Session); + +public: + explicit Session(PolkitIdentity* identity, const QString& cookie, QObject* parent = nullptr); + ~Session() override; + + /// Call this after connecting to the relevant signals. + void initiate(); + /// Call this to abort a running authentication session. + void cancel(); + /// Provide a response to an input request. + void respond(const QString& response); + +Q_SIGNALS: + /// Emitted when the session wants to request input from the user. + /// + /// The message is a prompt to present to the user. + /// If echo is false, the user's response should not be displayed (e.g. for passwords). + void request(const QString& message, bool echo); + + /// Emitted when the authentication session completes. + /// + /// If success is true, authentication was successful. + /// Otherwise it failed (e.g. wrong password). + void completed(bool success); + + /// Emitted when an error message should be shown to the user. + void showError(const QString& message); + + /// Emitted when an informational message should be shown to the user. + void showInfo(const QString& message); + +private: + PolkitAgentSession* session = nullptr; +}; +} // namespace qs::service::polkit diff --git a/src/services/polkit/test/manual/agent.qml b/src/services/polkit/test/manual/agent.qml new file mode 100644 index 0000000..4588e4b --- /dev/null +++ b/src/services/polkit/test/manual/agent.qml @@ -0,0 +1,97 @@ +import Quickshell +import Quickshell.Services.Polkit +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +Scope { + id: root + + FloatingWindow { + title: "Authentication Required" + + visible: polkitAgent.isActive + color: contentItem.palette.window + + ColumnLayout { + id: contentColumn + anchors.fill: parent + anchors.margins: 18 + spacing: 12 + + Item { Layout.fillHeight: true } + + Label { + Layout.fillWidth: true + text: polkitAgent.flow?.message || "" + wrapMode: Text.Wrap + font.bold: true + } + + Label { + Layout.fillWidth: true + text: polkitAgent.flow?.supplementaryMessage || "" + wrapMode: Text.Wrap + opacity: 0.8 + } + + Label { + Layout.fillWidth: true + text: polkitAgent.flow?.inputPrompt || "" + wrapMode: Text.Wrap + } + + Label { + text: "Authentication failed, try again" + color: "red" + visible: polkitAgent.flow?.failed + } + + TextField { + id: passwordInput + echoMode: polkitAgent.flow?.responseVisible + ? TextInput.Normal : TextInput.Password + selectByMouse: true + Layout.fillWidth: true + onAccepted: okButton.clicked() + } + + RowLayout { + spacing: 8 + Button { + id: okButton + text: "OK" + enabled: passwordInput.text.length > 0 || !!polkitAgent.flow?.isResponseRequired + onClicked: { + polkitAgent.flow.submit(passwordInput.text) + passwordInput.text = "" + passwordInput.forceActiveFocus() + } + } + Button { + text: "Cancel" + visible: polkitAgent.isActive + onClicked: { + polkitAgent.flow.cancelAuthenticationRequest() + passwordInput.text = "" + } + } + } + + Item { Layout.fillHeight: true } + } + + Connections { + target: polkitAgent.flow + function onIsResponseRequiredChanged() { + passwordInput.text = "" + if (polkitAgent.flow.isResponseRequired) + passwordInput.forceActiveFocus() + } + } + } + + PolkitAgent { + id: polkitAgent + } +} diff --git a/src/services/status_notifier/dbus_item_types.cpp b/src/services/status_notifier/dbus_item_types.cpp index c751ca2..6678e94 100644 --- a/src/services/status_notifier/dbus_item_types.cpp +++ b/src/services/status_notifier/dbus_item_types.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include bool DBusSniIconPixmap::operator==(const DBusSniIconPixmap& other) const { @@ -122,10 +121,3 @@ QDebug operator<<(QDebug debug, const DBusSniTooltip& tooltip) { return debug; } - -#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) -QDebug operator<<(QDebug debug, const QDBusObjectPath& path) { - debug.nospace() << "QDBusObjectPath(" << path.path() << ")"; - return debug; -} -#endif diff --git a/src/services/status_notifier/dbus_item_types.hpp b/src/services/status_notifier/dbus_item_types.hpp index e81a2ac..cef38f3 100644 --- a/src/services/status_notifier/dbus_item_types.hpp +++ b/src/services/status_notifier/dbus_item_types.hpp @@ -35,7 +35,3 @@ const QDBusArgument& operator<<(QDBusArgument& argument, const DBusSniTooltip& t QDebug operator<<(QDebug debug, const DBusSniIconPixmap& pixmap); QDebug operator<<(QDebug debug, const DBusSniTooltip& tooltip); - -#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) -QDebug operator<<(QDebug debug, const QDBusObjectPath& path); -#endif diff --git a/src/services/status_notifier/host.cpp b/src/services/status_notifier/host.cpp index 5fa9af0..0e4530a 100644 --- a/src/services/status_notifier/host.cpp +++ b/src/services/status_notifier/host.cpp @@ -12,12 +12,13 @@ #include #include "../../core/common.hpp" +#include "../../core/logcat.hpp" #include "../../dbus/properties.hpp" #include "dbus_watcher_interface.h" #include "item.hpp" #include "watcher.hpp" -Q_LOGGING_CATEGORY(logStatusNotifierHost, "quickshell.service.sni.host", QtWarningMsg); +QS_LOGGING_CATEGORY(logStatusNotifierHost, "quickshell.service.sni.host", QtWarningMsg); namespace qs::service::sni { diff --git a/src/services/status_notifier/host.hpp b/src/services/status_notifier/host.hpp index 9d823e5..87b3137 100644 --- a/src/services/status_notifier/host.hpp +++ b/src/services/status_notifier/host.hpp @@ -8,10 +8,11 @@ #include #include +#include "../../core/logcat.hpp" #include "dbus_watcher_interface.h" #include "item.hpp" -Q_DECLARE_LOGGING_CATEGORY(logStatusNotifierHost); +QS_DECLARE_LOGGING_CATEGORY(logStatusNotifierHost); namespace qs::service::sni { diff --git a/src/services/status_notifier/item.cpp b/src/services/status_notifier/item.cpp index c84dfe6..17404e1 100644 --- a/src/services/status_notifier/item.cpp +++ b/src/services/status_notifier/item.cpp @@ -16,12 +16,12 @@ #include #include #include -#include #include #include #include "../../core/iconimageprovider.hpp" #include "../../core/imageprovider.hpp" +#include "../../core/logcat.hpp" #include "../../core/platformmenu.hpp" #include "../../dbus/dbusmenu/dbusmenu.hpp" #include "../../dbus/properties.hpp" @@ -34,7 +34,7 @@ using namespace qs::dbus; using namespace qs::dbus::dbusmenu; using namespace qs::menu::platform; -Q_LOGGING_CATEGORY(logStatusNotifierItem, "quickshell.service.sni.item", QtWarningMsg); +QS_LOGGING_CATEGORY(logStatusNotifierItem, "quickshell.service.sni.item", QtWarningMsg); namespace qs::service::sni { @@ -162,6 +162,10 @@ QPixmap StatusNotifierItem::createPixmap(const QSize& size) const { } else { const auto* icon = closestPixmap(size, this->bAttentionIconPixmaps.value()); + if (icon == nullptr) { + icon = closestPixmap(size, this->bIconPixmaps.value()); + } + if (icon != nullptr) { const auto image = icon->createImage().scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); @@ -217,9 +221,14 @@ void StatusNotifierItem::activate() { const QDBusPendingReply<> reply = *call; if (reply.isError()) { - qCWarning(logStatusNotifierItem).noquote() - << "Error calling Activate method of StatusNotifierItem" << this->properties.toString(); - qCWarning(logStatusNotifierItem) << reply.error(); + if (reply.error().type() == QDBusError::UnknownMethod) { + qCDebug(logStatusNotifierItem) << "Tried to call Activate method of StatusNotifierItem" + << this->properties.toString() << "but it does not exist."; + } else { + qCWarning(logStatusNotifierItem).noquote() + << "Error calling Activate method of StatusNotifierItem" << this->properties.toString(); + qCWarning(logStatusNotifierItem) << reply.error(); + } } delete call; @@ -236,10 +245,16 @@ void StatusNotifierItem::secondaryActivate() { const QDBusPendingReply<> reply = *call; if (reply.isError()) { - qCWarning(logStatusNotifierItem).noquote() - << "Error calling SecondaryActivate method of StatusNotifierItem" - << this->properties.toString(); - qCWarning(logStatusNotifierItem) << reply.error(); + if (reply.error().type() == QDBusError::UnknownMethod) { + qCDebug(logStatusNotifierItem) + << "Tried to call SecondaryActivate method of StatusNotifierItem" + << this->properties.toString() << "but it does not exist."; + } else { + qCWarning(logStatusNotifierItem).noquote() + << "Error calling SecondaryActivate method of StatusNotifierItem" + << this->properties.toString(); + qCWarning(logStatusNotifierItem) << reply.error(); + } } delete call; diff --git a/src/services/status_notifier/item.hpp b/src/services/status_notifier/item.hpp index 321b73d..2eff95d 100644 --- a/src/services/status_notifier/item.hpp +++ b/src/services/status_notifier/item.hpp @@ -11,12 +11,13 @@ #include #include "../../core/imageprovider.hpp" +#include "../../core/logcat.hpp" #include "../../dbus/dbusmenu/dbusmenu.hpp" #include "../../dbus/properties.hpp" #include "dbus_item.h" #include "dbus_item_types.hpp" -Q_DECLARE_LOGGING_CATEGORY(logStatusNotifierItem); +QS_DECLARE_LOGGING_CATEGORY(logStatusNotifierItem); namespace qs::service::sni { @@ -125,13 +126,6 @@ class StatusNotifierItem: public QObject { public: explicit StatusNotifierItem(const QString& address, QObject* parent = nullptr); - [[nodiscard]] bool isValid() const; - [[nodiscard]] bool isReady() const; - [[nodiscard]] QBindable bindableIcon() const { return &this->bIcon; }; - [[nodiscard]] QPixmap createPixmap(const QSize& size) const; - - [[nodiscard]] dbus::dbusmenu::DBusMenuHandle* menuHandle(); - /// Primary activation action, generally triggered via a left click. Q_INVOKABLE void activate(); /// Secondary activation action, generally triggered via a middle click. @@ -141,14 +135,21 @@ public: /// Display a platform menu at the given location relative to the parent window. Q_INVOKABLE void display(QObject* parentWindow, qint32 relativeX, qint32 relativeY); - [[nodiscard]] QBindable bindableId() const { return &this->bId; }; - [[nodiscard]] QBindable bindableTitle() const { return &this->bTitle; }; - [[nodiscard]] QBindable bindableStatus() const { return &this->bStatus; }; - [[nodiscard]] QBindable bindableCategory() const { return &this->bCategory; }; - [[nodiscard]] QString tooltipTitle() const { return this->bTooltip.value().title; }; - [[nodiscard]] QString tooltipDescription() const { return this->bTooltip.value().description; }; - [[nodiscard]] QBindable bindableHasMenu() const { return &this->bHasMenu; }; - [[nodiscard]] QBindable bindableOnlyMenu() const { return &this->bIsMenu; }; + [[nodiscard]] bool isValid() const; + [[nodiscard]] bool isReady() const; + [[nodiscard]] QBindable bindableIcon() const { return &this->bIcon; } + [[nodiscard]] QPixmap createPixmap(const QSize& size) const; + + [[nodiscard]] dbus::dbusmenu::DBusMenuHandle* menuHandle(); + + [[nodiscard]] QBindable bindableId() const { return &this->bId; } + [[nodiscard]] QBindable bindableTitle() const { return &this->bTitle; } + [[nodiscard]] QBindable bindableStatus() const { return &this->bStatus; } + [[nodiscard]] QBindable bindableCategory() const { return &this->bCategory; } + [[nodiscard]] QString tooltipTitle() const { return this->bTooltip.value().title; } + [[nodiscard]] QString tooltipDescription() const { return this->bTooltip.value().description; } + [[nodiscard]] QBindable bindableHasMenu() const { return &this->bHasMenu; } + [[nodiscard]] QBindable bindableOnlyMenu() const { return &this->bIsMenu; } signals: void ready(); @@ -206,6 +207,8 @@ private: QS_BINDING_SUBSCRIBE_METHOD(StatusNotifierItem, bOverlayIconPixmaps, updatePixmapIndex, onValueChanged); QS_BINDING_SUBSCRIBE_METHOD(StatusNotifierItem, bAttentionIconPixmaps, updatePixmapIndex, onValueChanged); QS_BINDING_SUBSCRIBE_METHOD(StatusNotifierItem, bMenuPath, onMenuPathChanged, onValueChanged); + QS_BINDING_SUBSCRIBE_METHOD(StatusNotifierItem, bTooltip, tooltipTitleChanged, onValueChanged); + QS_BINDING_SUBSCRIBE_METHOD(StatusNotifierItem, bTooltip, tooltipDescriptionChanged, onValueChanged); Q_OBJECT_BINDABLE_PROPERTY(StatusNotifierItem, quint32, pixmapIndex); Q_OBJECT_BINDABLE_PROPERTY(StatusNotifierItem, QString, bIcon, &StatusNotifierItem::iconChanged); diff --git a/src/services/status_notifier/watcher.cpp b/src/services/status_notifier/watcher.cpp index 4917077..e5b841d 100644 --- a/src/services/status_notifier/watcher.cpp +++ b/src/services/status_notifier/watcher.cpp @@ -10,7 +10,9 @@ #include #include -Q_LOGGING_CATEGORY(logStatusNotifierWatcher, "quickshell.service.sni.watcher", QtWarningMsg); +#include "../../core/logcat.hpp" + +QS_LOGGING_CATEGORY(logStatusNotifierWatcher, "quickshell.service.sni.watcher", QtWarningMsg); namespace qs::service::sni { diff --git a/src/services/status_notifier/watcher.hpp b/src/services/status_notifier/watcher.hpp index 4a04225..4f3c68f 100644 --- a/src/services/status_notifier/watcher.hpp +++ b/src/services/status_notifier/watcher.hpp @@ -9,7 +9,9 @@ #include #include -Q_DECLARE_LOGGING_CATEGORY(logStatusNotifierWatcher); +#include "../../core/logcat.hpp" + +QS_DECLARE_LOGGING_CATEGORY(logStatusNotifierWatcher); namespace qs::service::sni { diff --git a/src/services/upower/core.cpp b/src/services/upower/core.cpp index 9fe0e60..0f9d9da 100644 --- a/src/services/upower/core.cpp +++ b/src/services/upower/core.cpp @@ -12,6 +12,7 @@ #include #include +#include "../../core/logcat.hpp" #include "../../core/model.hpp" #include "../../dbus/bus.hpp" #include "../../dbus/properties.hpp" @@ -21,7 +22,7 @@ namespace qs::service::upower { namespace { -Q_LOGGING_CATEGORY(logUPower, "quickshell.service.upower", QtWarningMsg); +QS_LOGGING_CATEGORY(logUPower, "quickshell.service.upower", QtWarningMsg); } UPower::UPower() { diff --git a/src/services/upower/core.hpp b/src/services/upower/core.hpp index e2ed4f7..62fca1d 100644 --- a/src/services/upower/core.hpp +++ b/src/services/upower/core.hpp @@ -22,7 +22,7 @@ class UPower: public QObject { public: [[nodiscard]] UPowerDevice* displayDevice(); [[nodiscard]] ObjectModel* devices(); - [[nodiscard]] QBindable bindableOnBattery() const { return &this->bOnBattery; }; + [[nodiscard]] QBindable bindableOnBattery() const { return &this->bOnBattery; } static UPower* instance(); diff --git a/src/services/upower/device.cpp b/src/services/upower/device.cpp index b7c61e1..63382ad 100644 --- a/src/services/upower/device.cpp +++ b/src/services/upower/device.cpp @@ -8,6 +8,7 @@ #include #include +#include "../../core/logcat.hpp" #include "../../dbus/properties.hpp" #include "dbus_device.h" @@ -16,7 +17,7 @@ using namespace qs::dbus; namespace qs::service::upower { namespace { -Q_LOGGING_CATEGORY(logUPowerDevice, "quickshell.service.upower.device", QtWarningMsg); +QS_LOGGING_CATEGORY(logUPowerDevice, "quickshell.service.upower.device", QtWarningMsg); } QString UPowerDeviceState::toString(UPowerDeviceState::Enum status) { @@ -72,7 +73,7 @@ UPowerDevice::UPowerDevice(QObject* parent): QObject(parent) { return this->bType == UPowerDeviceType::Battery && this->bPowerSupply; }); - this->bHealthSupported.setBinding([this]() { return this->bHealthPercentage != 0; }); + this->bHealthSupported.setBinding([this]() { return this->bHealthPercentage.value() != 0; }); } void UPowerDevice::init(const QString& path) { @@ -100,7 +101,7 @@ QString UPowerDevice::address() const { return this->device ? this->device->serv QString UPowerDevice::path() const { return this->device ? this->device->path() : QString(); } void UPowerDevice::onGetAllFinished() { - qCDebug(logUPowerDevice) << "UPowerDevice" << device->path() << "ready."; + qCDebug(logUPowerDevice) << "UPowerDevice" << this->device->path() << "ready."; this->bReady = true; } @@ -125,8 +126,8 @@ DBusDataTransform::fromWire(quint32 wire) { ); } -DBusResult DBusDataTransform::fromWire(quint32 wire -) { +DBusResult +DBusDataTransform::fromWire(quint32 wire) { if (wire >= UPowerDeviceType::Unknown && wire <= UPowerDeviceType::BluetoothGeneric) { return DBusResult(static_cast(wire)); } diff --git a/src/services/upower/device.hpp b/src/services/upower/device.hpp index b2b5f02..a4fbe83 100644 --- a/src/services/upower/device.hpp +++ b/src/services/upower/device.hpp @@ -173,25 +173,25 @@ public: [[nodiscard]] QString address() const; [[nodiscard]] QString path() const; - [[nodiscard]] QBindable bindableType() const { return &this->bType; }; - [[nodiscard]] QBindable bindablePowerSupply() const { return &this->bPowerSupply; }; - [[nodiscard]] QBindable bindableEnergy() const { return &this->bEnergy; }; - [[nodiscard]] QBindable bindableEnergyCapacity() const { return &this->bEnergyCapacity; }; - [[nodiscard]] QBindable bindableChangeRate() const { return &this->bChangeRate; }; - [[nodiscard]] QBindable bindableTimeToEmpty() const { return &this->bTimeToEmpty; }; - [[nodiscard]] QBindable bindableTimeToFull() const { return &this->bTimeToFull; }; - [[nodiscard]] QBindable bindablePercentage() const { return &this->bPercentage; }; - [[nodiscard]] QBindable bindableIsPresent() const { return &this->bIsPresent; }; - [[nodiscard]] QBindable bindableState() const { return &this->bState; }; + [[nodiscard]] QBindable bindableType() const { return &this->bType; } + [[nodiscard]] QBindable bindablePowerSupply() const { return &this->bPowerSupply; } + [[nodiscard]] QBindable bindableEnergy() const { return &this->bEnergy; } + [[nodiscard]] QBindable bindableEnergyCapacity() const { return &this->bEnergyCapacity; } + [[nodiscard]] QBindable bindableChangeRate() const { return &this->bChangeRate; } + [[nodiscard]] QBindable bindableTimeToEmpty() const { return &this->bTimeToEmpty; } + [[nodiscard]] QBindable bindableTimeToFull() const { return &this->bTimeToFull; } + [[nodiscard]] QBindable bindablePercentage() const { return &this->bPercentage; } + [[nodiscard]] QBindable bindableIsPresent() const { return &this->bIsPresent; } + [[nodiscard]] QBindable bindableState() const { return &this->bState; } [[nodiscard]] QBindable bindableHealthPercentage() const { return &this->bHealthPercentage; - }; - [[nodiscard]] QBindable bindableHealthSupported() const { return &this->bHealthSupported; }; - [[nodiscard]] QBindable bindableIconName() const { return &this->bIconName; }; - [[nodiscard]] QBindable bindableIsLaptopBattery() const { return &this->bIsLaptopBattery; }; - [[nodiscard]] QBindable bindableNativePath() const { return &this->bNativePath; }; - [[nodiscard]] QBindable bindableModel() const { return &this->bModel; }; - [[nodiscard]] QBindable bindableReady() const { return &this->bReady; }; + } + [[nodiscard]] QBindable bindableHealthSupported() const { return &this->bHealthSupported; } + [[nodiscard]] QBindable bindableIconName() const { return &this->bIconName; } + [[nodiscard]] QBindable bindableIsLaptopBattery() const { return &this->bIsLaptopBattery; } + [[nodiscard]] QBindable bindableNativePath() const { return &this->bNativePath; } + [[nodiscard]] QBindable bindableModel() const { return &this->bModel; } + [[nodiscard]] QBindable bindableReady() const { return &this->bReady; } signals: QSDOC_HIDE void readyChanged(); diff --git a/src/services/upower/powerprofiles.cpp b/src/services/upower/powerprofiles.cpp index 4e9ea92..f59b871 100644 --- a/src/services/upower/powerprofiles.cpp +++ b/src/services/upower/powerprofiles.cpp @@ -10,15 +10,16 @@ #include #include #include -#include +#include +#include "../../core/logcat.hpp" #include "../../dbus/bus.hpp" #include "../../dbus/properties.hpp" namespace qs::service::upower { namespace { -Q_LOGGING_CATEGORY(logPowerProfiles, "quickshell.service.powerprofiles", QtWarningMsg); +QS_LOGGING_CATEGORY(logPowerProfiles, "quickshell.service.powerprofiles", QtWarningMsg); } QString PowerProfile::toString(PowerProfile::Enum profile) { @@ -65,7 +66,8 @@ PowerProfiles::PowerProfiles() { auto bus = QDBusConnection::systemBus(); if (!bus.isConnected()) { - qCWarning(logPowerProfiles + qCWarning( + logPowerProfiles ) << "Could not connect to DBus. PowerProfiles services will not work."; } @@ -78,7 +80,8 @@ PowerProfiles::PowerProfiles() { ); if (!this->service->isValid()) { - qCDebug(logPowerProfiles + qCDebug( + logPowerProfiles ) << "PowerProfilesDaemon is not currently running, attempting to start it."; dbus::tryLaunchService(this, bus, "org.freedesktop.UPower.PowerProfiles", [this](bool success) { @@ -102,13 +105,15 @@ void PowerProfiles::init() { void PowerProfiles::setProfile(PowerProfile::Enum profile) { if (!this->properties.isConnected()) { - qCCritical(logPowerProfiles + qCCritical( + logPowerProfiles ) << "Cannot set power profile: power-profiles-daemon not accessible or not running"; return; } if (profile == PowerProfile::Performance && !this->bHasPerformanceProfile) { - qCCritical(logPowerProfiles + qCCritical( + logPowerProfiles ) << "Cannot request performance profile as it is not present for this device."; return; } else if (profile < PowerProfile::PowerSaver || profile > PowerProfile::Performance) { @@ -134,8 +139,9 @@ PowerProfilesQml::PowerProfilesQml(QObject* parent): QObject(parent) { return instance->bHasPerformanceProfile.value(); }); - this->bDegradationReason.setBinding([instance]() { return instance->bDegradationReason.value(); } - ); + this->bDegradationReason.setBinding([instance]() { + return instance->bDegradationReason.value(); + }); this->bHolds.setBinding([instance]() { return instance->bHolds.value(); }); } @@ -163,6 +169,7 @@ QString DBusDataTransform::toWire(Data data) { case PowerProfile::PowerSaver: return QStringLiteral("power-saver"); case PowerProfile::Balanced: return QStringLiteral("balanced"); case PowerProfile::Performance: return QStringLiteral("performance"); + default: qFatal() << "Attempted to convert invalid power profile" << data << "to wire format."; } } diff --git a/src/ui/reload_popup.cpp b/src/ui/reload_popup.cpp index 8e58dc9..f000374 100644 --- a/src/ui/reload_popup.cpp +++ b/src/ui/reload_popup.cpp @@ -25,7 +25,7 @@ ReloadPopup::ReloadPopup(QString instanceId, bool failed, QString errorString) this->popup = component.createWithInitialProperties({{"reloadInfo", QVariant::fromValue(this)}}); - if (!popup) { + if (!this->popup) { qCritical() << "Failed to open reload popup:" << component.errorString(); } diff --git a/src/wayland/CMakeLists.txt b/src/wayland/CMakeLists.txt index 26d29c8..cf84713 100644 --- a/src/wayland/CMakeLists.txt +++ b/src/wayland/CMakeLists.txt @@ -1,6 +1,6 @@ find_package(PkgConfig REQUIRED) find_package(WaylandScanner REQUIRED) -pkg_check_modules(wayland REQUIRED IMPORTED_TARGET wayland-client wayland-protocols) +pkg_check_modules(wayland REQUIRED IMPORTED_TARGET wayland-client wayland-protocols>=1.41) # wayland protocols @@ -12,13 +12,13 @@ if(NOT TARGET Qt6::qtwaylandscanner) message(FATAL_ERROR "qtwaylandscanner executable not found. Most likely there is an issue with your Qt installation.") endif() -execute_process( - COMMAND pkg-config --variable=pkgdatadir wayland-protocols - OUTPUT_VARIABLE WAYLAND_PROTOCOLS - OUTPUT_STRIP_TRAILING_WHITESPACE -) +pkg_get_variable(WAYLAND_PROTOCOLS wayland-protocols pkgdatadir) -message(STATUS "Found wayland-protocols at ${WAYLAND_PROTOCOLS_DIR}") +if(WAYLAND_PROTOCOLS) + message(STATUS "Found wayland protocols at ${WAYLAND_PROTOCOLS}") +else() + message(FATAL_ERROR "Could not find wayland protocols") +endif() qs_add_pchset(wayland-protocol DEPENDENCIES Qt::Core Qt::WaylandClient Qt::WaylandClientPrivate @@ -68,17 +68,24 @@ function (wl_proto target name dir) target_include_directories(${target} INTERFACE ${PROTO_BUILD_PATH}) target_link_libraries(${target} wl-proto-${name}-wl Qt6::WaylandClient Qt6::WaylandClientPrivate) qs_pch(${target} SET wayland-protocol) + target_compile_options(wl-proto-${name}-wl PRIVATE ${wayland_CFLAGS}) endfunction() # ----- qt_add_library(quickshell-wayland STATIC + wl_proxy_safe_deref.cpp platformmenu.cpp popupanchor.cpp xdgshell.cpp util.cpp + output_tracking.cpp ) +# required for wl_proxy_safe_deref +target_link_libraries(quickshell-wayland PRIVATE ${CMAKE_DL_LIBS}) +target_link_options(quickshell PRIVATE "LINKER:--export-dynamic-symbol=wl_proxy_get_listener") + # required to make sure the constructor is linked add_library(quickshell-wayland-init OBJECT init.cpp) @@ -113,6 +120,20 @@ if (HYPRLAND) add_subdirectory(hyprland) endif() +add_subdirectory(background_effect) +list(APPEND WAYLAND_MODULES Quickshell.Wayland._BackgroundEffect) + +add_subdirectory(idle_inhibit) +list(APPEND WAYLAND_MODULES Quickshell.Wayland._IdleInhibitor) + +add_subdirectory(idle_notify) +list(APPEND WAYLAND_MODULES Quickshell.Wayland._IdleNotify) + +add_subdirectory(shortcuts_inhibit) +list(APPEND WAYLAND_MODULES Quickshell.Wayland._ShortcutsInhibitor) + +add_subdirectory(windowmanager) + # widgets for qmenu target_link_libraries(quickshell-wayland PRIVATE Qt::Quick Qt::Widgets Qt::WaylandClient Qt::WaylandClientPrivate diff --git a/src/wayland/background_effect/CMakeLists.txt b/src/wayland/background_effect/CMakeLists.txt new file mode 100644 index 0000000..f45f94d --- /dev/null +++ b/src/wayland/background_effect/CMakeLists.txt @@ -0,0 +1,24 @@ +qt_add_library(quickshell-wayland-background-effect STATIC + manager.cpp + surface.cpp + qml.cpp +) + +qt_add_qml_module(quickshell-wayland-background-effect + URI Quickshell.Wayland._BackgroundEffect + VERSION 0.1 + DEPENDENCIES QtQml +) + +install_qml_module(quickshell-wayland-background-effect) + +wl_proto(wlp-background-effect ext-background-effect-v1 "${WAYLAND_PROTOCOLS}/staging/ext-background-effect") + +target_link_libraries(quickshell-wayland-background-effect PRIVATE + Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client + wlp-background-effect +) + +qs_module_pch(quickshell-wayland-background-effect) + +target_link_libraries(quickshell PRIVATE quickshell-wayland-background-effectplugin) diff --git a/src/wayland/background_effect/manager.cpp b/src/wayland/background_effect/manager.cpp new file mode 100644 index 0000000..4cb06f1 --- /dev/null +++ b/src/wayland/background_effect/manager.cpp @@ -0,0 +1,38 @@ +#include "manager.hpp" +#include + +#include +#include +#include +#include + +#include "surface.hpp" + +namespace qs::wayland::background_effect::impl { + +BackgroundEffectManager::BackgroundEffectManager(): QWaylandClientExtensionTemplate(1) { + this->initialize(); +} + +BackgroundEffectSurface* +BackgroundEffectManager::createEffectSurface(QtWaylandClient::QWaylandWindow* window) { + return new BackgroundEffectSurface(this->get_background_effect(window->surface())); +} + +bool BackgroundEffectManager::blurAvailable() const { + return this->isActive() && this->mBlurAvailable; +} + +void BackgroundEffectManager::ext_background_effect_manager_v1_capabilities(uint32_t flags) { + auto available = static_cast(flags & capability_blur); + if (available == this->mBlurAvailable) return; + this->mBlurAvailable = available; + emit this->blurAvailableChanged(); +} + +BackgroundEffectManager* BackgroundEffectManager::instance() { + static auto* instance = new BackgroundEffectManager(); // NOLINT + return instance->isInitialized() ? instance : nullptr; +} + +} // namespace qs::wayland::background_effect::impl diff --git a/src/wayland/background_effect/manager.hpp b/src/wayland/background_effect/manager.hpp new file mode 100644 index 0000000..6c2e981 --- /dev/null +++ b/src/wayland/background_effect/manager.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "surface.hpp" + +namespace qs::wayland::background_effect::impl { + +class BackgroundEffectManager + : public QWaylandClientExtensionTemplate + , public QtWayland::ext_background_effect_manager_v1 { + Q_OBJECT; + +public: + explicit BackgroundEffectManager(); + + BackgroundEffectSurface* createEffectSurface(QtWaylandClient::QWaylandWindow* window); + + [[nodiscard]] bool blurAvailable() const; + + static BackgroundEffectManager* instance(); + +signals: + void blurAvailableChanged(); + +protected: + void ext_background_effect_manager_v1_capabilities(uint32_t flags) override; + +private: + bool mBlurAvailable = false; +}; + +} // namespace qs::wayland::background_effect::impl diff --git a/src/wayland/background_effect/qml.cpp b/src/wayland/background_effect/qml.cpp new file mode 100644 index 0000000..b54a847 --- /dev/null +++ b/src/wayland/background_effect/qml.cpp @@ -0,0 +1,246 @@ +#include "qml.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/region.hpp" +#include "../../window/proxywindow.hpp" +#include "../../window/windowinterface.hpp" +#include "manager.hpp" +#include "surface.hpp" + +using QtWaylandClient::QWaylandWindow; + +namespace qs::wayland::background_effect { + +BackgroundEffect* BackgroundEffect::qmlAttachedProperties(QObject* object) { + auto* proxyWindow = qobject_cast(object); + + if (!proxyWindow) { + if (auto* iface = qobject_cast(object)) { + proxyWindow = iface->proxyWindow(); + } + } + + if (!proxyWindow) return nullptr; + return new BackgroundEffect(proxyWindow); +} + +BackgroundEffect::BackgroundEffect(ProxyWindowBase* window): QObject(nullptr), proxyWindow(window) { + QObject::connect( + window, + &ProxyWindowBase::windowConnected, + this, + &BackgroundEffect::onWindowConnected + ); + + QObject::connect(window, &ProxyWindowBase::polished, this, &BackgroundEffect::onWindowPolished); + + QObject::connect( + window, + &ProxyWindowBase::devicePixelRatioChanged, + this, + &BackgroundEffect::updateBlurRegion + ); + + QObject::connect(window, &QObject::destroyed, this, &BackgroundEffect::onProxyWindowDestroyed); + + if (window->backingWindow()) { + this->onWindowConnected(); + } +} + +PendingRegion* BackgroundEffect::blurRegion() const { return this->mBlurRegion; } + +void BackgroundEffect::setBlurRegion(PendingRegion* region) { + if (region == this->mBlurRegion) return; + + if (this->mBlurRegion) { + QObject::disconnect(this->mBlurRegion, nullptr, this, nullptr); + } + + this->mBlurRegion = region; + + if (region) { + QObject::connect(region, &QObject::destroyed, this, &BackgroundEffect::onBlurRegionDestroyed); + QObject::connect(region, &PendingRegion::changed, this, &BackgroundEffect::updateBlurRegion); + } + + this->updateBlurRegion(); + emit this->blurRegionChanged(); +} + +void BackgroundEffect::onBlurRegionDestroyed() { + this->mBlurRegion = nullptr; + this->updateBlurRegion(); + emit this->blurRegionChanged(); +} + +void BackgroundEffect::updateBlurRegion() { + if (!this->surface || !this->proxyWindow) return; + + this->pendingBlurRegion = true; + this->proxyWindow->schedulePolish(); +} + +void BackgroundEffect::onWindowPolished() { + if (!this->surface || !this->pendingBlurRegion) return; + if (!this->mWaylandWindow || !this->mWaylandWindow->surface()) { + this->pendingBlurRegion = false; + return; + } + + QRegion region; + if (this->mBlurRegion) { + region = + this->mBlurRegion->applyTo(QRect(0, 0, this->mWindow->width(), this->mWindow->height())); + + auto scale = QHighDpiScaling::factor(this->mWindow); + if (!qFuzzyCompare(scale, 1.0)) { + region = QHighDpi::scale(region, scale); + } + + auto margins = this->mWaylandWindow->clientSideMargins(); + region.translate(margins.left(), margins.top()); + } + + this->surface->setBlurRegion(region); + this->pendingBlurRegion = false; +} + +bool BackgroundEffect::eventFilter(QObject* object, QEvent* event) { + if (event->type() == QEvent::PlatformSurface) { + auto* surfaceEvent = dynamic_cast(event); + if (surfaceEvent->surfaceEventType() == QPlatformSurfaceEvent::SurfaceAboutToBeDestroyed) { + this->surface = nullptr; + this->pendingBlurRegion = false; + } + } + + return this->QObject::eventFilter(object, event); +} + +void BackgroundEffect::onWindowConnected() { + this->mWindow = this->proxyWindow->backingWindow(); + this->mWindow->installEventFilter(this); + + QObject::connect( + this->mWindow, + &QWindow::visibleChanged, + this, + &BackgroundEffect::onWindowVisibleChanged + ); + + this->onWindowVisibleChanged(); +} + +void BackgroundEffect::onWindowVisibleChanged() { + if (this->mWindow->isVisible()) { + if (!this->mWindow->handle()) { + this->mWindow->create(); + } + } + + auto* window = dynamic_cast(this->mWindow->handle()); + if (window == this->mWaylandWindow) return; + + if (this->mWaylandWindow) { + QObject::disconnect(this->mWaylandWindow, nullptr, this, nullptr); + } + + this->mWaylandWindow = window; + if (!window) return; + + QObject::connect( + this->mWaylandWindow, + &QObject::destroyed, + this, + &BackgroundEffect::onWaylandWindowDestroyed + ); + + QObject::connect( + this->mWaylandWindow, + &QWaylandWindow::surfaceCreated, + this, + &BackgroundEffect::onWaylandSurfaceCreated + ); + + QObject::connect( + this->mWaylandWindow, + &QWaylandWindow::surfaceDestroyed, + this, + &BackgroundEffect::onWaylandSurfaceDestroyed + ); + + if (this->mWaylandWindow->surface()) { + this->onWaylandSurfaceCreated(); + } +} + +void BackgroundEffect::onWaylandWindowDestroyed() { this->mWaylandWindow = nullptr; } + +void BackgroundEffect::onWaylandSurfaceCreated() { + auto* manager = impl::BackgroundEffectManager::instance(); + + if (!manager) { + qWarning() << "Cannot enable background effect as ext-background-effect-v1 is not supported " + "by the current compositor."; + return; + } + + // Steal protocol surface from previous BackgroundEffect to avoid duplicate-attachment on reload. + auto v = this->mWaylandWindow->property("qs_background_effect"); + if (v.canConvert()) { + auto* prev = v.value(); + if (prev != this && prev->surface) { + this->surface.swap(prev->surface); + } + } + + if (!this->surface) { + this->surface = std::unique_ptr( + manager->createEffectSurface(this->mWaylandWindow) + ); + } + + this->mWaylandWindow->setProperty("qs_background_effect", QVariant::fromValue(this)); + + this->pendingBlurRegion = this->mBlurRegion != nullptr; + if (this->pendingBlurRegion) { + this->proxyWindow->schedulePolish(); + } +} + +void BackgroundEffect::onWaylandSurfaceDestroyed() { + this->surface = nullptr; + this->pendingBlurRegion = false; + + if (!this->proxyWindow) { + this->deleteLater(); + } +} + +void BackgroundEffect::onProxyWindowDestroyed() { + // Don't delete the BackgroundEffect, and therefore the impl::BackgroundEffectSurface + // until the wl_surface is destroyed. Deleting it when the proxy window is deleted would + // cause a frame without blur between the destruction of the ext_background_effect_surface_v1 + // and wl_surface objects. + + this->proxyWindow = nullptr; + + if (this->surface == nullptr) { + this->deleteLater(); + } +} + +} // namespace qs::wayland::background_effect diff --git a/src/wayland/background_effect/qml.hpp b/src/wayland/background_effect/qml.hpp new file mode 100644 index 0000000..dd93aec --- /dev/null +++ b/src/wayland/background_effect/qml.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "../../core/region.hpp" +#include "../../window/proxywindow.hpp" +#include "surface.hpp" + +namespace qs::wayland::background_effect { + +///! Background blur effect for Wayland surfaces. +/// Applies background blur behind a @@Quickshell.QsWindow or subclass, +/// as an attached object, using the [ext-background-effect-v1] Wayland protocol. +/// +/// > [!NOTE] Using a background effect requires the compositor support the +/// > [ext-background-effect-v1] protocol. +/// +/// [ext-background-effect-v1]: https://wayland.app/protocols/ext-background-effect-v1 +/// +/// #### Example +/// ```qml +/// @@Quickshell.PanelWindow { +/// id: root +/// color: "#80000000" +/// +/// BackgroundEffect.blurRegion: Region { item: root.contentItem } +/// } +/// ``` +class BackgroundEffect: public QObject { + Q_OBJECT; + // clang-format off + /// Region to blur behind the surface. Set to null to remove blur. + Q_PROPERTY(PendingRegion* blurRegion READ blurRegion WRITE setBlurRegion NOTIFY blurRegionChanged); + // clang-format on + QML_ELEMENT; + QML_UNCREATABLE("BackgroundEffect can only be used as an attached object."); + QML_ATTACHED(BackgroundEffect); + +public: + explicit BackgroundEffect(ProxyWindowBase* window); + + [[nodiscard]] PendingRegion* blurRegion() const; + void setBlurRegion(PendingRegion* region); + + static BackgroundEffect* qmlAttachedProperties(QObject* object); + + bool eventFilter(QObject* object, QEvent* event) override; + +signals: + void blurRegionChanged(); + +private slots: + void onWindowConnected(); + void onWindowVisibleChanged(); + void onWaylandWindowDestroyed(); + void onWaylandSurfaceCreated(); + void onWaylandSurfaceDestroyed(); + void onProxyWindowDestroyed(); + void onBlurRegionDestroyed(); + void onWindowPolished(); + void updateBlurRegion(); + +private: + ProxyWindowBase* proxyWindow = nullptr; + QWindow* mWindow = nullptr; + QtWaylandClient::QWaylandWindow* mWaylandWindow = nullptr; + + bool pendingBlurRegion = false; + PendingRegion* mBlurRegion = nullptr; + std::unique_ptr surface; +}; + +} // namespace qs::wayland::background_effect diff --git a/src/wayland/background_effect/surface.cpp b/src/wayland/background_effect/surface.cpp new file mode 100644 index 0000000..648361d --- /dev/null +++ b/src/wayland/background_effect/surface.cpp @@ -0,0 +1,37 @@ +#include "surface.hpp" + +#include +#include +#include +#include +#include + +namespace qs::wayland::background_effect::impl { + +BackgroundEffectSurface::BackgroundEffectSurface( + ::ext_background_effect_surface_v1* surface // NOLINT(misc-include-cleaner) +) + : QtWayland::ext_background_effect_surface_v1(surface) {} + +BackgroundEffectSurface::~BackgroundEffectSurface() { + if (!this->isInitialized()) return; + this->destroy(); +} + +void BackgroundEffectSurface::setBlurRegion(const QRegion& region) { + if (!this->isInitialized()) return; + + if (region.isEmpty()) { + this->set_blur_region(nullptr); + return; + } + + static const auto* waylandIntegration = QtWaylandClient::QWaylandIntegration::instance(); + auto* display = waylandIntegration->display(); + + auto* wlRegion = display->createRegion(region); + this->set_blur_region(wlRegion); + wl_region_destroy(wlRegion); // NOLINT(misc-include-cleaner) +} + +} // namespace qs::wayland::background_effect::impl diff --git a/src/wayland/background_effect/surface.hpp b/src/wayland/background_effect/surface.hpp new file mode 100644 index 0000000..65b0bc8 --- /dev/null +++ b/src/wayland/background_effect/surface.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +namespace qs::wayland::background_effect::impl { + +class BackgroundEffectSurface: public QtWayland::ext_background_effect_surface_v1 { +public: + explicit BackgroundEffectSurface(::ext_background_effect_surface_v1* surface); + ~BackgroundEffectSurface() override; + Q_DISABLE_COPY_MOVE(BackgroundEffectSurface); + + void setBlurRegion(const QRegion& region); +}; + +} // namespace qs::wayland::background_effect::impl diff --git a/src/wayland/background_effect/test/manual/background_effect.qml b/src/wayland/background_effect/test/manual/background_effect.qml new file mode 100644 index 0000000..679cb01 --- /dev/null +++ b/src/wayland/background_effect/test/manual/background_effect.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland + +FloatingWindow { + id: root + color: "transparent" + contentItem.palette.windowText: "white" + + ColumnLayout { + anchors.centerIn: parent + + CheckBox { + id: enableBox + checked: true + text: "Enable Blur" + } + + Button { + text: "Hide->Show" + onClicked: { + root.visible = false + showTimer.start() + } + } + + Timer { + id: showTimer + interval: 200 + onTriggered: root.visible = true + } + + Slider { + id: radiusSlider + from: 0 + to: 1000 + value: 100 + } + + component EdgeSlider: Slider { + from: -1 + to: 1000 + value: -1 + } + + EdgeSlider { id: topLeftSlider } + EdgeSlider { id: topRightSlider } + EdgeSlider { id: bottomLeftSlider } + EdgeSlider { id: bottomRightSlider } + } + + BackgroundEffect.blurRegion: Region { + item: enableBox.checked ? root.contentItem : null + radius: radiusSlider.value == -1 ? undefined : radiusSlider.value + topLeftRadius: topLeftSlider.value == -1 ? undefined : topLeftSlider.value + topRightRadius: topRightSlider.value == -1 ? undefined : topRightSlider.value + bottomLeftRadius: bottomLeftSlider.value == -1 ? undefined : bottomLeftSlider.value + bottomRightRadius: bottomRightSlider.value == -1 ? undefined : bottomRightSlider.value + } +} diff --git a/src/wayland/buffer/CMakeLists.txt b/src/wayland/buffer/CMakeLists.txt index f80c53a..15818fc 100644 --- a/src/wayland/buffer/CMakeLists.txt +++ b/src/wayland/buffer/CMakeLists.txt @@ -1,6 +1,8 @@ find_package(PkgConfig REQUIRED) pkg_check_modules(dmabuf-deps REQUIRED IMPORTED_TARGET libdrm gbm egl) +find_package(VulkanHeaders REQUIRED) + qt_add_library(quickshell-wayland-buffer STATIC manager.cpp dmabuf.cpp @@ -10,9 +12,10 @@ qt_add_library(quickshell-wayland-buffer STATIC wl_proto(wlp-linux-dmabuf linux-dmabuf-v1 "${WAYLAND_PROTOCOLS}/stable/linux-dmabuf") target_link_libraries(quickshell-wayland-buffer PRIVATE - Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client + Qt::Quick Qt::QuickPrivate Qt::GuiPrivate Qt::WaylandClient Qt::WaylandClientPrivate wayland-client PkgConfig::dmabuf-deps wlp-linux-dmabuf + Vulkan::Headers ) qs_pch(quickshell-wayland-buffer SET large) diff --git a/src/wayland/buffer/dmabuf.cpp b/src/wayland/buffer/dmabuf.cpp index d233da5..47462fb 100644 --- a/src/wayland/buffer/dmabuf.cpp +++ b/src/wayland/buffer/dmabuf.cpp @@ -1,7 +1,6 @@ #include "dmabuf.hpp" #include #include -#include #include #include #include @@ -11,10 +10,11 @@ #include #include #include -#include #include #include #include +#include +#include #include #include #include @@ -25,17 +25,23 @@ #include #include #include +#include #include +#include +#include +#include #include #include #include #include #include +#include #include #include #include #include +#include "../../core/logcat.hpp" #include "../../core/stacklist.hpp" #include "manager.hpp" #include "manager_p.hpp" @@ -44,10 +50,40 @@ namespace qs::wayland::buffer::dmabuf { namespace { -Q_LOGGING_CATEGORY(logDmabuf, "quickshell.wayland.buffer.dmabuf", QtWarningMsg); +QS_LOGGING_CATEGORY(logDmabuf, "quickshell.wayland.buffer.dmabuf", QtWarningMsg); LinuxDmabufManager* MANAGER = nullptr; // NOLINT +VkFormat drmFormatToVkFormat(uint32_t drmFormat) { + // NOLINTBEGIN(bugprone-branch-clone): XRGB/ARGB intentionally map to the same VK format + switch (drmFormat) { + case DRM_FORMAT_ARGB8888: return VK_FORMAT_B8G8R8A8_UNORM; + case DRM_FORMAT_XRGB8888: return VK_FORMAT_B8G8R8A8_UNORM; + case DRM_FORMAT_ABGR8888: return VK_FORMAT_R8G8B8A8_UNORM; + case DRM_FORMAT_XBGR8888: return VK_FORMAT_R8G8B8A8_UNORM; + case DRM_FORMAT_ARGB2101010: return VK_FORMAT_A2R10G10B10_UNORM_PACK32; + case DRM_FORMAT_XRGB2101010: return VK_FORMAT_A2R10G10B10_UNORM_PACK32; + case DRM_FORMAT_ABGR2101010: return VK_FORMAT_A2B10G10R10_UNORM_PACK32; + case DRM_FORMAT_XBGR2101010: return VK_FORMAT_A2B10G10R10_UNORM_PACK32; + case DRM_FORMAT_ABGR16161616F: return VK_FORMAT_R16G16B16A16_SFLOAT; + case DRM_FORMAT_RGB565: return VK_FORMAT_R5G6B5_UNORM_PACK16; + case DRM_FORMAT_BGR565: return VK_FORMAT_B5G6R5_UNORM_PACK16; + default: return VK_FORMAT_UNDEFINED; + } + // NOLINTEND(bugprone-branch-clone) +} + +bool drmFormatHasAlpha(uint32_t drmFormat) { + switch (drmFormat) { + case DRM_FORMAT_ARGB8888: + case DRM_FORMAT_ABGR8888: + case DRM_FORMAT_ARGB2101010: + case DRM_FORMAT_ABGR2101010: + case DRM_FORMAT_ABGR16161616F: return true; + default: return false; + } +} + } // namespace QDebug& operator<<(QDebug& debug, const FourCCStr& fourcc) { @@ -76,30 +112,32 @@ QDebug& operator<<(QDebug& debug, const WlDmaBuffer* buffer) { } GbmDeviceHandle::~GbmDeviceHandle() { - if (device) { + if (this->device) { MANAGER->unrefGbmDevice(this->device); } } -// This will definitely backfire later +// Prefer ARGB over XRGB: XRGB has undefined alpha bytes which cause +// transparency artifacts on Vulkan (notably Intel GPUs) since Vulkan +// doesn't auto-fill alpha=1.0 for X formats like EGL does. void LinuxDmabufFormatSelection::ensureSorted() { if (this->sorted) return; auto beginIter = this->formats.begin(); - auto xrgbIter = std::ranges::find_if(this->formats, [](const auto& format) { - return format.first == DRM_FORMAT_XRGB8888; - }); - - if (xrgbIter != this->formats.end()) { - std::swap(*beginIter, *xrgbIter); - ++beginIter; - } - auto argbIter = std::ranges::find_if(this->formats, [](const auto& format) { return format.first == DRM_FORMAT_ARGB8888; }); - if (argbIter != this->formats.end()) std::swap(*beginIter, *argbIter); + if (argbIter != this->formats.end()) { + std::swap(*beginIter, *argbIter); + ++beginIter; + } + + auto xrgbIter = std::ranges::find_if(this->formats, [](const auto& format) { + return format.first == DRM_FORMAT_XRGB8888; + }); + + if (xrgbIter != this->formats.end()) std::swap(*beginIter, *xrgbIter); this->sorted = true; } @@ -413,7 +451,8 @@ WlBuffer* LinuxDmabufManager::createDmabuf( if (modifiers.modifiers.isEmpty()) { if (!modifiers.implicit) { - qCritical(logDmabuf + qCritical( + logDmabuf ) << "Failed to create gbm_bo: format supports no implicit OR explicit modifiers."; return nullptr; } @@ -521,7 +560,7 @@ WlDmaBuffer::~WlDmaBuffer() { bool WlDmaBuffer::isCompatible(const WlBufferRequest& request) const { if (request.width != this->width || request.height != this->height) return false; - auto matchingFormat = std::ranges::find_if(request.dmabuf.formats, [&](const auto& format) { + auto matchingFormat = std::ranges::find_if(request.dmabuf.formats, [this](const auto& format) { return format.format == this->format && (format.modifiers.isEmpty() || std::ranges::find(format.modifiers, this->modifier) != format.modifiers.end()); @@ -531,6 +570,15 @@ bool WlDmaBuffer::isCompatible(const WlBufferRequest& request) const { } WlBufferQSGTexture* WlDmaBuffer::createQsgTexture(QQuickWindow* window) const { + auto* ri = window->rendererInterface(); + if (ri && ri->graphicsApi() == QSGRendererInterface::Vulkan) { + return this->createQsgTextureVulkan(window); + } + + return this->createQsgTextureGl(window); +} + +WlBufferQSGTexture* WlDmaBuffer::createQsgTextureGl(QQuickWindow* window) const { static auto* glEGLImageTargetTexture2DOES = []() { auto* fn = reinterpret_cast( eglGetProcAddress("glEGLImageTargetTexture2DOES") @@ -661,6 +709,313 @@ WlBufferQSGTexture* WlDmaBuffer::createQsgTexture(QQuickWindow* window) const { return tex; } +WlBufferQSGTexture* WlDmaBuffer::createQsgTextureVulkan(QQuickWindow* window) const { + auto* ri = window->rendererInterface(); + auto* vkInst = window->vulkanInstance(); + + if (!vkInst) { + qCWarning(logDmabuf) << "Failed to create Vulkan QSG texture: no QVulkanInstance."; + return nullptr; + } + + auto* vkDevicePtr = + static_cast(ri->getResource(window, QSGRendererInterface::DeviceResource)); + auto* vkPhysDevicePtr = static_cast( + ri->getResource(window, QSGRendererInterface::PhysicalDeviceResource) + ); + + if (!vkDevicePtr || !vkPhysDevicePtr) { + qCWarning(logDmabuf) << "Failed to create Vulkan QSG texture: could not get Vulkan device."; + return nullptr; + } + + VkDevice device = *vkDevicePtr; + VkPhysicalDevice physDevice = *vkPhysDevicePtr; + + auto* devFuncs = vkInst->deviceFunctions(device); + auto* instFuncs = vkInst->functions(); + + if (!devFuncs || !instFuncs) { + qCWarning(logDmabuf) << "Failed to create Vulkan QSG texture: " + "could not get Vulkan functions."; + return nullptr; + } + + auto getMemoryFdPropertiesKHR = reinterpret_cast( + instFuncs->vkGetDeviceProcAddr(device, "vkGetMemoryFdPropertiesKHR") + ); + + if (!getMemoryFdPropertiesKHR) { + qCWarning(logDmabuf) << "Failed to create Vulkan QSG texture: " + "vkGetMemoryFdPropertiesKHR not available. " + "Missing VK_KHR_external_memory_fd extension."; + return nullptr; + } + + const VkFormat vkFormat = drmFormatToVkFormat(this->format); + if (vkFormat == VK_FORMAT_UNDEFINED) { + qCWarning(logDmabuf) << "Failed to create Vulkan QSG texture: unsupported DRM format" + << FourCCStr(this->format); + return nullptr; + } + + if (this->planeCount > 4) { + qCWarning(logDmabuf) << "Failed to create Vulkan QSG texture: too many planes" + << this->planeCount; + return nullptr; + } + + std::array planeLayouts = {}; + for (int i = 0; i < this->planeCount; ++i) { + planeLayouts[i].offset = this->planes[i].offset; // NOLINT + planeLayouts[i].rowPitch = this->planes[i].stride; // NOLINT + planeLayouts[i].size = 0; + planeLayouts[i].arrayPitch = 0; + planeLayouts[i].depthPitch = 0; + } + + const bool useModifier = this->modifier != DRM_FORMAT_MOD_INVALID; + + VkExternalMemoryImageCreateInfo externalInfo = {}; + externalInfo.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO; + externalInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; + + VkImageDrmFormatModifierExplicitCreateInfoEXT modifierInfo = {}; + modifierInfo.sType = VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_EXPLICIT_CREATE_INFO_EXT; + modifierInfo.drmFormatModifier = this->modifier; + modifierInfo.drmFormatModifierPlaneCount = static_cast(this->planeCount); + modifierInfo.pPlaneLayouts = planeLayouts.data(); + + if (useModifier) { + externalInfo.pNext = &modifierInfo; + } + + VkImageCreateInfo imageInfo = {}; + imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imageInfo.pNext = &externalInfo; + imageInfo.imageType = VK_IMAGE_TYPE_2D; + imageInfo.format = vkFormat; + imageInfo.extent = {.width = this->width, .height = this->height, .depth = 1}; + imageInfo.mipLevels = 1; + imageInfo.arrayLayers = 1; + imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imageInfo.tiling = useModifier ? VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT : VK_IMAGE_TILING_LINEAR; + imageInfo.usage = VK_IMAGE_USAGE_SAMPLED_BIT; + imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + VkImage image = VK_NULL_HANDLE; + VkResult result = devFuncs->vkCreateImage(device, &imageInfo, nullptr, &image); + if (result != VK_SUCCESS) { + qCWarning(logDmabuf) << "Failed to create VkImage for DMA-BUF import, result:" << result; + return nullptr; + } + + VkDeviceMemory memory = VK_NULL_HANDLE; + + // dup() is required because vkAllocateMemory with VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT + // takes ownership of the fd on succcess. Without dup, WlDmaBuffer would double-close. + const int dupFd = + dup(this->planes[0].fd); // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic) + if (dupFd < 0) { + qCWarning(logDmabuf) << "Failed to dup() fd for DMA-BUF import"; + goto cleanup_fail; // NOLINT + } + + { + VkMemoryRequirements memReqs = {}; + devFuncs->vkGetImageMemoryRequirements(device, image, &memReqs); + + VkMemoryFdPropertiesKHR fdProps = {}; + fdProps.sType = VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR; + + result = getMemoryFdPropertiesKHR( + device, + VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, + dupFd, + &fdProps + ); + + if (result != VK_SUCCESS) { + close(dupFd); + qCWarning(logDmabuf) << "vkGetMemoryFdPropertiesKHR failed, result:" << result; + goto cleanup_fail; // NOLINT + } + + const uint32_t memTypeBits = memReqs.memoryTypeBits & fdProps.memoryTypeBits; + + VkPhysicalDeviceMemoryProperties memProps = {}; + instFuncs->vkGetPhysicalDeviceMemoryProperties(physDevice, &memProps); + + uint32_t memTypeIndex = UINT32_MAX; + for (uint32_t j = 0; j < memProps.memoryTypeCount; ++j) { + if (memTypeBits & (1u << j)) { + memTypeIndex = j; + break; + } + } + + if (memTypeIndex == UINT32_MAX) { + close(dupFd); + qCWarning(logDmabuf) << "No compatible memory type for DMA-BUF import"; + goto cleanup_fail; // NOLINT + } + + VkImportMemoryFdInfoKHR importInfo = {}; + importInfo.sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR; + importInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; + importInfo.fd = dupFd; + + VkMemoryDedicatedAllocateInfo dedicatedInfo = {}; + dedicatedInfo.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; + dedicatedInfo.image = image; + dedicatedInfo.pNext = &importInfo; + + VkMemoryAllocateInfo allocInfo = {}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.pNext = &dedicatedInfo; + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = memTypeIndex; + + result = devFuncs->vkAllocateMemory(device, &allocInfo, nullptr, &memory); + if (result != VK_SUCCESS) { + close(dupFd); + qCWarning(logDmabuf) << "vkAllocateMemory failed, result:" << result; + goto cleanup_fail; // NOLINT + } + + result = devFuncs->vkBindImageMemory(device, image, memory, 0); + if (result != VK_SUCCESS) { + qCWarning(logDmabuf) << "vkBindImageMemory failed, result:" << result; + goto cleanup_fail; // NOLINT + } + } + + { + // acquire the DMA-BUF from the foreign (compositor) queue and transition + // to shader-read layout. oldLayout must be GENERAL (not UNDEFINED) to + // preserve the DMA-BUF contents written by the external producer. Hopefully. + window->beginExternalCommands(); + + auto* cmdBufPtr = static_cast( + ri->getResource(window, QSGRendererInterface::CommandListResource) + ); + + if (cmdBufPtr && *cmdBufPtr) { + VkCommandBuffer cmdBuf = *cmdBufPtr; + + // find the graphics queue family index for the ownrship transfer. + uint32_t graphicsQueueFamily = 0; + uint32_t queueFamilyCount = 0; + instFuncs->vkGetPhysicalDeviceQueueFamilyProperties(physDevice, &queueFamilyCount, nullptr); + std::vector queueFamilies(queueFamilyCount); + instFuncs->vkGetPhysicalDeviceQueueFamilyProperties( + physDevice, + &queueFamilyCount, + queueFamilies.data() + ); + for (uint32_t i = 0; i < queueFamilyCount; ++i) { + if (queueFamilies[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) { + graphicsQueueFamily = i; + break; + } + } + + VkImageMemoryBarrier barrier = {}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_GENERAL; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_FOREIGN_EXT; + barrier.dstQueueFamilyIndex = graphicsQueueFamily; + barrier.image = image; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + + devFuncs->vkCmdPipelineBarrier( + cmdBuf, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, + 0, + nullptr, + 0, + nullptr, + 1, + &barrier + ); + } + + window->endExternalCommands(); + + auto* qsgTexture = QQuickWindowPrivate::get(window)->createTextureFromNativeTexture( + reinterpret_cast(image), + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + static_cast(vkFormat), + QSize(static_cast(this->width), static_cast(this->height)), + {} + ); + + // For opaque DRM formats (XRGB, XBGR, etc.), the alpha bytes are underfined. + // EGL silently forces alpha=1.0 for these, but Vulkan doesn't. Replace Qt's + // default identity-swizzle VkImageView with one that maps alpha to ONE. + if (!drmFormatHasAlpha(this->format)) { + auto* vkTexture = static_cast(qsgTexture->rhiTexture()); // NOLINT + + devFuncs->vkDestroyImageView(device, vkTexture->imageView, nullptr); + + VkImageViewCreateInfo viewInfo = {}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = image; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.format = vkFormat; + viewInfo.components.r = VK_COMPONENT_SWIZZLE_IDENTITY; + viewInfo.components.g = VK_COMPONENT_SWIZZLE_IDENTITY; + viewInfo.components.b = VK_COMPONENT_SWIZZLE_IDENTITY; + viewInfo.components.a = VK_COMPONENT_SWIZZLE_ONE; + viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + viewInfo.subresourceRange.levelCount = 1; + viewInfo.subresourceRange.layerCount = 1; + + result = devFuncs->vkCreateImageView(device, &viewInfo, nullptr, &vkTexture->imageView); + if (result != VK_SUCCESS) { + qCWarning(logDmabuf) << "Failed to create alpha-swizzled VkImageView, result:" << result; + } + } + + auto* tex = new WlDmaBufferVulkanQSGTexture(devFuncs, device, image, memory, qsgTexture); + qCDebug(logDmabuf) << "Created WlDmaBufferVulkanQSGTexture" << tex << "from" << this; + return tex; + } + +cleanup_fail: + if (image != VK_NULL_HANDLE) { + devFuncs->vkDestroyImage(device, image, nullptr); + } + if (memory != VK_NULL_HANDLE) { + devFuncs->vkFreeMemory(device, memory, nullptr); + } + return nullptr; +} + +WlDmaBufferVulkanQSGTexture::~WlDmaBufferVulkanQSGTexture() { + delete this->qsgTexture; + + if (this->image != VK_NULL_HANDLE) { + this->devFuncs->vkDestroyImage(this->device, this->image, nullptr); + } + + if (this->memory != VK_NULL_HANDLE) { + this->devFuncs->vkFreeMemory(this->device, this->memory, nullptr); + } + + qCDebug(logDmabuf) << "WlDmaBufferVulkanQSGTexture" << this << "destroyed."; +} + WlDmaBufferQSGTexture::~WlDmaBufferQSGTexture() { auto* context = QOpenGLContext::currentContext(); auto* display = context->nativeInterface()->display(); diff --git a/src/wayland/buffer/dmabuf.hpp b/src/wayland/buffer/dmabuf.hpp index a05e82a..ffe5d02 100644 --- a/src/wayland/buffer/dmabuf.hpp +++ b/src/wayland/buffer/dmabuf.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -12,9 +13,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -40,7 +43,7 @@ public: '\0'} ) { for (auto i = 3; i != 0; i--) { - if (chars[i] == ' ') chars[i] = '\0'; + if (this->chars[i] == ' ') this->chars[i] = '\0'; else break; } } @@ -114,6 +117,36 @@ private: friend class WlDmaBuffer; }; +class WlDmaBufferVulkanQSGTexture: public WlBufferQSGTexture { +public: + ~WlDmaBufferVulkanQSGTexture() override; + Q_DISABLE_COPY_MOVE(WlDmaBufferVulkanQSGTexture); + + [[nodiscard]] QSGTexture* texture() const override { return this->qsgTexture; } + +private: + WlDmaBufferVulkanQSGTexture( + QVulkanDeviceFunctions* devFuncs, + VkDevice device, + VkImage image, + VkDeviceMemory memory, + QSGTexture* qsgTexture + ) + : devFuncs(devFuncs) + , device(device) + , image(image) + , memory(memory) + , qsgTexture(qsgTexture) {} + + QVulkanDeviceFunctions* devFuncs = nullptr; + VkDevice device = VK_NULL_HANDLE; + VkImage image = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + QSGTexture* qsgTexture = nullptr; + + friend class WlDmaBuffer; +}; + class WlDmaBuffer: public WlBuffer { public: ~WlDmaBuffer() override; @@ -151,6 +184,9 @@ private: friend class LinuxDmabufManager; friend QDebug& operator<<(QDebug& debug, const WlDmaBuffer* buffer); + + [[nodiscard]] WlBufferQSGTexture* createQsgTextureGl(QQuickWindow* window) const; + [[nodiscard]] WlBufferQSGTexture* createQsgTextureVulkan(QQuickWindow* window) const; }; QDebug& operator<<(QDebug& debug, const WlDmaBuffer* buffer); diff --git a/src/wayland/buffer/manager.cpp b/src/wayland/buffer/manager.cpp index 4c2e267..9cf77fd 100644 --- a/src/wayland/buffer/manager.cpp +++ b/src/wayland/buffer/manager.cpp @@ -10,6 +10,7 @@ #include #include +#include "../../core/logcat.hpp" #include "dmabuf.hpp" #include "manager_p.hpp" #include "qsg.hpp" @@ -18,9 +19,11 @@ namespace qs::wayland::buffer { namespace { -Q_LOGGING_CATEGORY(logBuffer, "quickshell.wayland.buffer", QtWarningMsg); +QS_LOGGING_CATEGORY(logBuffer, "quickshell.wayland.buffer", QtWarningMsg); } +void WlBufferRequest::reset() { *this = WlBufferRequest(); } + WlBuffer* WlBufferSwapchain::createBackbuffer(const WlBufferRequest& request, bool* newBuffer) { auto& buffer = this->presentSecondBuffer ? this->buffer1 : this->buffer2; @@ -52,7 +55,8 @@ bool WlBufferManager::isReady() const { return this->p->mReady; } << " (disabled: " << dmabufDisabled << ')'; for (const auto& [format, modifiers]: request.dmabuf.formats) { - qCDebug(logBuffer) << " Format" << dmabuf::FourCCStr(format); + qCDebug(logBuffer).nospace() << " Format " << dmabuf::FourCCStr(format) + << (modifiers.length() == 0 ? " (No modifiers specified)" : ""); for (const auto& modifier: modifiers) { qCDebug(logBuffer) << " Explicit Modifier" << dmabuf::FourCCModStr(modifier); @@ -65,6 +69,11 @@ bool WlBufferManager::isReady() const { return this->p->mReady; } qCDebug(logBuffer) << " Format" << format; } + if (request.width == 0 || request.height == 0) { + qCWarning(logBuffer) << "Cannot create zero-sized buffer."; + return nullptr; + } + if (!dmabufDisabled) { if (auto* buf = this->p->dmabuf.createDmabuf(request)) return buf; qCWarning(logBuffer) << "DMA buffer creation failed, falling back to SHM."; @@ -114,6 +123,10 @@ void WlBufferQSGDisplayNode::setRect(const QRectF& rect) { this->setMatrix(matrix); } +void WlBufferQSGDisplayNode::setFiltering(QSGTexture::Filtering filtering) { + this->imageNode->setFiltering(filtering); +} + void WlBufferQSGDisplayNode::syncSwapchain(const WlBufferSwapchain& swapchain) { auto* buffer = swapchain.frontbuffer(); auto& texture = swapchain.presentSecondBuffer ? this->buffer2 : this->buffer1; diff --git a/src/wayland/buffer/manager.hpp b/src/wayland/buffer/manager.hpp index c84ee86..8abc218 100644 --- a/src/wayland/buffer/manager.hpp +++ b/src/wayland/buffer/manager.hpp @@ -40,6 +40,7 @@ struct WlBufferTransform { [[nodiscard]] int degrees() const { return 90 * (this->transform & 0b11111011); } [[nodiscard]] bool flip() const { return this->transform & 0b00000100; } + [[nodiscard]] bool flipSize() const { return this->transform & 0b00000001; } void apply(QMatrix4x4& matrix) const { matrix.rotate(this->flip() ? 180 : 0, 0, 1, 0); @@ -67,6 +68,8 @@ struct WlBufferRequest { dev_t device = 0; StackList formats; } dmabuf; + + void reset(); }; class WlBuffer { diff --git a/src/wayland/buffer/qsg.hpp b/src/wayland/buffer/qsg.hpp index c230cfe..bb05954 100644 --- a/src/wayland/buffer/qsg.hpp +++ b/src/wayland/buffer/qsg.hpp @@ -33,6 +33,7 @@ public: void syncSwapchain(const WlBufferSwapchain& swapchain); void setRect(const QRectF& rect); + void setFiltering(QSGTexture::Filtering filtering); private: QQuickWindow* window; diff --git a/src/wayland/buffer/shm.cpp b/src/wayland/buffer/shm.cpp index 59a8e91..6a8c642 100644 --- a/src/wayland/buffer/shm.cpp +++ b/src/wayland/buffer/shm.cpp @@ -13,12 +13,13 @@ #include #include +#include "../../core/logcat.hpp" #include "manager.hpp" namespace qs::wayland::buffer::shm { namespace { -Q_LOGGING_CATEGORY(logShm, "quickshell.wayland.buffer.shm", QtWarningMsg); +QS_LOGGING_CATEGORY(logShm, "quickshell.wayland.buffer.shm", QtWarningMsg); } bool WlShmBuffer::isCompatible(const WlBufferRequest& request) const { diff --git a/src/wayland/hyprland/CMakeLists.txt b/src/wayland/hyprland/CMakeLists.txt index 570cbe5..66b32b6 100644 --- a/src/wayland/hyprland/CMakeLists.txt +++ b/src/wayland/hyprland/CMakeLists.txt @@ -30,6 +30,7 @@ qt_add_qml_module(quickshell-hyprland IMPORTS ${HYPRLAND_MODULES} ) +qs_add_module_deps_light(quickshell-io Quickshell) install_qml_module(quickshell-hyprland) # intentionally no pch as the module is empty diff --git a/src/wayland/hyprland/focus_grab/qml.cpp b/src/wayland/hyprland/focus_grab/qml.cpp index e26a75a..cf1ac24 100644 --- a/src/wayland/hyprland/focus_grab/qml.cpp +++ b/src/wayland/hyprland/focus_grab/qml.cpp @@ -9,7 +9,6 @@ #include #include "../../../window/proxywindow.hpp" -#include "../../../window/windowinterface.hpp" #include "grab.hpp" #include "manager.hpp" @@ -38,8 +37,51 @@ QObjectList HyprlandFocusGrab::windows() const { return this->windowObjects; } void HyprlandFocusGrab::setWindows(QObjectList windows) { if (windows == this->windowObjects) return; + if (this->grab) this->grab->startTransaction(); + + for (auto* obj: this->windowObjects) { + if (windows.contains(obj)) continue; + QObject::disconnect(obj, nullptr, this, nullptr); + + auto* proxy = ProxyWindowBase::forObject(obj); + if (!proxy) continue; + + QObject::disconnect(proxy, nullptr, this, nullptr); + + if (this->grab && proxy->backingWindow()) { + this->grab->removeWindow(proxy->backingWindow()); + } + } + + for (auto it = windows.begin(); it != windows.end();) { + auto* proxy = ProxyWindowBase::forObject(*it); + if (!proxy) { + it = windows.erase(it); + continue; + } + + if (this->windowObjects.contains(*it)) { + ++it; + continue; + } + + QObject::connect(*it, &QObject::destroyed, this, &HyprlandFocusGrab::onObjectDestroyed); + QObject::connect( + proxy, + &ProxyWindowBase::windowConnected, + this, + &HyprlandFocusGrab::onProxyConnected + ); + + if (this->grab && proxy->backingWindow()) { + this->grab->addWindow(proxy->backingWindow()); + } + + ++it; + } + + if (this->grab) this->grab->completeTransaction(); this->windowObjects = std::move(windows); - this->syncWindows(); emit this->windowsChanged(); } @@ -75,59 +117,18 @@ void HyprlandFocusGrab::tryActivate() { QObject::connect(this->grab, &FocusGrab::cleared, this, &HyprlandFocusGrab::onGrabCleared); this->grab->startTransaction(); - for (auto* proxy: this->trackedProxies) { - if (proxy->backingWindow() != nullptr) { + for (auto* obj: this->windowObjects) { + auto* proxy = ProxyWindowBase::forObject(obj); + if (proxy && proxy->backingWindow()) { this->grab->addWindow(proxy->backingWindow()); } } this->grab->completeTransaction(); } -void HyprlandFocusGrab::syncWindows() { - auto newProxy = QList(); - for (auto* windowObject: this->windowObjects) { - auto* proxyWindow = qobject_cast(windowObject); - - if (proxyWindow == nullptr) { - if (auto* iface = qobject_cast(windowObject)) { - proxyWindow = iface->proxyWindow(); - } - } - - if (proxyWindow != nullptr) { - newProxy.push_back(proxyWindow); - } - } - - if (this->grab) this->grab->startTransaction(); - - for (auto* oldWindow: this->trackedProxies) { - if (!newProxy.contains(oldWindow)) { - QObject::disconnect(oldWindow, nullptr, this, nullptr); - - if (this->grab != nullptr && oldWindow->backingWindow() != nullptr) { - this->grab->removeWindow(oldWindow->backingWindow()); - } - } - } - - for (auto* newProxy: newProxy) { - if (!this->trackedProxies.contains(newProxy)) { - QObject::connect( - newProxy, - &ProxyWindowBase::windowConnected, - this, - &HyprlandFocusGrab::onProxyConnected - ); - - if (this->grab != nullptr && newProxy->backingWindow() != nullptr) { - this->grab->addWindow(newProxy->backingWindow()); - } - } - } - - this->trackedProxies = newProxy; - if (this->grab) this->grab->completeTransaction(); +void HyprlandFocusGrab::onObjectDestroyed(QObject* object) { + this->windowObjects.removeOne(object); + emit this->windowsChanged(); } } // namespace qs::hyprland diff --git a/src/wayland/hyprland/focus_grab/qml.hpp b/src/wayland/hyprland/focus_grab/qml.hpp index 4ba7227..97a10de 100644 --- a/src/wayland/hyprland/focus_grab/qml.hpp +++ b/src/wayland/hyprland/focus_grab/qml.hpp @@ -56,6 +56,8 @@ class HyprlandFocusGrab : public QObject , public QQmlParserStatus { Q_OBJECT; + QML_ELEMENT; + Q_INTERFACES(QQmlParserStatus); /// If the focus grab is active. Defaults to false. /// /// When set to true, an input grab will be created for the listed windows. @@ -66,7 +68,6 @@ class HyprlandFocusGrab Q_PROPERTY(bool active READ isActive WRITE setActive NOTIFY activeChanged); /// The list of windows to whitelist for input. Q_PROPERTY(QList windows READ windows WRITE setWindows NOTIFY windowsChanged); - QML_ELEMENT; public: explicit HyprlandFocusGrab(QObject* parent = nullptr): QObject(parent) {} @@ -95,15 +96,13 @@ private slots: void onGrabActivated(); void onGrabCleared(); void onProxyConnected(); + void onObjectDestroyed(QObject* object); private: void tryActivate(); - void syncWindows(); bool targetActive = false; QObjectList windowObjects; - QList trackedProxies; - QList trackedWindows; focus_grab::FocusGrab* grab = nullptr; }; diff --git a/src/wayland/hyprland/global_shortcuts/qml.hpp b/src/wayland/hyprland/global_shortcuts/qml.hpp index a43d963..f257632 100644 --- a/src/wayland/hyprland/global_shortcuts/qml.hpp +++ b/src/wayland/hyprland/global_shortcuts/qml.hpp @@ -32,9 +32,7 @@ namespace qs::hyprland::global_shortcuts { /// /// [hyprland_global_shortcuts_v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-global-shortcuts-v1.xml /// [the wiki]: https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts -class GlobalShortcut - : public QObject - , public PostReloadHook { +class GlobalShortcut: public PostReloadHook { using ShortcutImpl = impl::GlobalShortcut; Q_OBJECT; @@ -59,7 +57,7 @@ class GlobalShortcut QML_ELEMENT; public: - explicit GlobalShortcut(QObject* parent = nullptr): QObject(parent) {} + explicit GlobalShortcut(QObject* parent = nullptr): PostReloadHook(parent) {} ~GlobalShortcut() override; Q_DISABLE_COPY_MOVE(GlobalShortcut); diff --git a/src/wayland/hyprland/ipc/CMakeLists.txt b/src/wayland/hyprland/ipc/CMakeLists.txt index fd01463..9e42520 100644 --- a/src/wayland/hyprland/ipc/CMakeLists.txt +++ b/src/wayland/hyprland/ipc/CMakeLists.txt @@ -15,7 +15,7 @@ qs_add_module_deps_light(quickshell-hyprland-ipc Quickshell) install_qml_module(quickshell-hyprland-ipc) -target_link_libraries(quickshell-hyprland-ipc PRIVATE Qt::Quick) +target_link_libraries(quickshell-hyprland-ipc PRIVATE Qt::Quick quickshell-core) if (WAYLAND_TOPLEVEL_MANAGEMENT) target_sources(quickshell-hyprland-ipc PRIVATE diff --git a/src/wayland/hyprland/ipc/connection.cpp b/src/wayland/hyprland/ipc/connection.cpp index 1c9f4eb..d15701d 100644 --- a/src/wayland/hyprland/ipc/connection.cpp +++ b/src/wayland/hyprland/ipc/connection.cpp @@ -14,21 +14,27 @@ #include #include #include +#include +#include #include #include #include #include +#include "../../../core/logcat.hpp" #include "../../../core/model.hpp" #include "../../../core/qmlscreen.hpp" +#include "../../toplevel_management/handle.hpp" +#include "hyprland_toplevel.hpp" #include "monitor.hpp" +#include "toplevel_mapping.hpp" #include "workspace.hpp" namespace qs::hyprland::ipc { namespace { -Q_LOGGING_CATEGORY(logHyprlandIpc, "quickshell.hyprland.ipc", QtWarningMsg); -Q_LOGGING_CATEGORY(logHyprlandIpcEvents, "quickshell.hyprland.ipc.events", QtWarningMsg); +QS_LOGGING_CATEGORY(logHyprlandIpc, "quickshell.hyprland.ipc", QtWarningMsg); +QS_LOGGING_CATEGORY(logHyprlandIpcEvents, "quickshell.hyprland.ipc.events", QtWarningMsg); } // namespace HyprlandIpc::HyprlandIpc() { @@ -62,11 +68,16 @@ HyprlandIpc::HyprlandIpc() { QObject::connect(&this->eventSocket, &QLocalSocket::errorOccurred, this, &HyprlandIpc::eventSocketError); QObject::connect(&this->eventSocket, &QLocalSocket::stateChanged, this, &HyprlandIpc::eventSocketStateChanged); QObject::connect(&this->eventSocket, &QLocalSocket::readyRead, this, &HyprlandIpc::eventSocketReady); + + auto *instance = HyprlandToplevelMappingManager::instance(); + QObject::connect(instance, &HyprlandToplevelMappingManager::toplevelAddressed, this, &HyprlandIpc::toplevelAddressed); + // clang-format on this->eventSocket.connectToServer(this->mEventSocketPath, QLocalSocket::ReadOnly); this->refreshMonitors(true); this->refreshWorkspaces(true); + this->refreshToplevels(); } QString HyprlandIpc::requestSocketPath() const { return this->mRequestSocketPath; } @@ -82,6 +93,7 @@ void HyprlandIpc::eventSocketError(QLocalSocket::LocalSocketError error) const { void HyprlandIpc::eventSocketStateChanged(QLocalSocket::LocalSocketState state) { if (state == QLocalSocket::ConnectedState) { + this->eventReader.setDevice(&this->eventSocket); qCInfo(logHyprlandIpc) << "Hyprland event socket connected."; emit this->connected(); } else if (state == QLocalSocket::UnconnectedState && this->valid) { @@ -93,11 +105,11 @@ void HyprlandIpc::eventSocketStateChanged(QLocalSocket::LocalSocketState state) void HyprlandIpc::eventSocketReady() { while (true) { - auto rawEvent = this->eventSocket.readLine(); - if (rawEvent.isEmpty()) break; + this->eventReader.startTransaction(); + auto rawEvent = this->eventReader.readUntil('\n'); + if (!this->eventReader.commitTransaction()) return; - // remove trailing \n - rawEvent.truncate(rawEvent.length() - 1); + rawEvent.chop(1); // remove trailing \n auto splitIdx = rawEvent.indexOf(">>"); auto event = QByteArrayView(rawEvent.data(), splitIdx); auto data = QByteArrayView( @@ -113,6 +125,36 @@ void HyprlandIpc::eventSocketReady() { } } +void HyprlandIpc::toplevelAddressed( + wayland::toplevel_management::impl::ToplevelHandle* handle, + quint64 address +) { + auto* waylandToplevel = + wayland::toplevel_management::ToplevelManager::instance()->forImpl(handle); + + if (!waylandToplevel) return; + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(waylandToplevel, false) + ); + + auto* hyprToplevel = this->findToplevelByAddress(address, true); + + if (attached) { + if (attached->address()) { + qCDebug(logHyprlandIpc) << "Toplevel" << attached->addressStr() << "already has address" + << address; + + return; + } + + attached->setAddress(address); + attached->setHyprlandHandle(hyprToplevel); + } + + hyprToplevel->setWaylandHandle(waylandToplevel->implHandle()); +} + void HyprlandIpc::makeRequest( const QByteArray& request, const std::function& callback @@ -166,6 +208,8 @@ ObjectModel* HyprlandIpc::monitors() { return &this->mMonitors; ObjectModel* HyprlandIpc::workspaces() { return &this->mWorkspaces; } +ObjectModel* HyprlandIpc::toplevels() { return &this->mToplevels; } + QVector HyprlandIpc::parseEventArgs(QByteArrayView event, quint16 count) { auto args = QVector(); @@ -218,6 +262,7 @@ void HyprlandIpc::onEvent(HyprlandIpcEvent* event) { if (event->name == "configreloaded") { this->refreshMonitors(true); this->refreshWorkspaces(true); + this->refreshToplevels(); } else if (event->name == "monitoraddedv2") { auto args = event->parseView(3); @@ -390,6 +435,134 @@ void HyprlandIpc::onEvent(HyprlandIpcEvent* event) { // the fullscreen state changed, but this falls apart if you move a fullscreen // window between workspaces. this->refreshWorkspaces(false); + } else if (event->name == "openwindow") { + auto args = event->parseView(4); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + + if (!ok) return; + + auto workspaceName = QString::fromUtf8(args.at(1)); + auto windowClass = QString::fromUtf8(args.at(2)); + auto windowTitle = QString::fromUtf8(args.at(3)); + + auto* workspace = this->findWorkspaceByName(workspaceName, false); + if (!workspace) { + qCWarning(logHyprlandIpc) << "Got openwindow for workspace" << workspaceName + << "which was not previously tracked."; + return; + } + + auto* toplevel = this->findToplevelByAddress(windowAddress, false); + const bool existed = toplevel != nullptr; + + if (!toplevel) toplevel = new HyprlandToplevel(this); + toplevel->updateInitial(windowAddress, windowTitle, workspaceName); + + workspace->insertToplevel(toplevel); + + if (!existed) { + this->mToplevels.insertObject(toplevel); + qCDebug(logHyprlandIpc) << "New toplevel created with address" << windowAddress << ", title" + << windowTitle << ", workspace" << workspaceName; + } + } else if (event->name == "closewindow") { + auto args = event->parseView(1); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + + if (!ok) return; + + const auto& mList = this->mToplevels.valueList(); + auto toplevelIter = std::ranges::find_if(mList, [windowAddress](HyprlandToplevel* m) { + return m->address() == windowAddress; + }); + + if (toplevelIter == mList.end()) { + qCWarning(logHyprlandIpc) << "Got closewindow for address" << windowAddress + << "which was not previously tracked."; + return; + } + + auto* toplevel = *toplevelIter; + if (toplevel == this->bActiveToplevel.value()) this->bActiveToplevel = nullptr; + auto index = toplevelIter - mList.begin(); + this->mToplevels.removeAt(index); + + // Remove from workspace + auto* workspace = toplevel->bindableWorkspace().value(); + if (workspace) { + workspace->toplevels()->removeObject(toplevel); + } + + delete toplevel; + } else if (event->name == "movewindowv2") { + auto args = event->parseView(3); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + auto workspaceName = QString::fromUtf8(args.at(2)); + + auto* toplevel = this->findToplevelByAddress(windowAddress, false); + if (!toplevel) { + qCWarning(logHyprlandIpc) << "Got movewindowv2 event for client with address" << windowAddress + << "which was not previously tracked."; + return; + } + + HyprlandWorkspace* workspace = this->findWorkspaceByName(workspaceName, false); + if (!workspace) { + qCWarning(logHyprlandIpc) << "Got movewindowv2 event for workspace" << args.at(2) + << "which was not previously tracked."; + return; + } + + auto* oldWorkspace = toplevel->bindableWorkspace().value(); + toplevel->setWorkspace(workspace); + + if (oldWorkspace) { + oldWorkspace->removeToplevel(toplevel); + } + + workspace->insertToplevel(toplevel); + } else if (event->name == "windowtitlev2") { + auto args = event->parseView(2); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + auto windowTitle = QString::fromUtf8(args.at(1)); + + if (!ok) return; + + // It happens that Hyprland sends windowtitlev2 events before event + // "openwindow" is emitted, so let's preemptively create it + auto* toplevel = this->findToplevelByAddress(windowAddress, true); + if (!toplevel) { + qCWarning(logHyprlandIpc) << "Got windowtitlev2 event for client with address" + << windowAddress << "which was not previously tracked."; + return; + } + + toplevel->bindableTitle().setValue(windowTitle); + } else if (event->name == "activewindowv2") { + auto args = event->parseView(1); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + + if (!ok) return; + + // Did not observe "activewindowv2" event before "openwindow", + // but better safe than sorry, so create if missing. + auto* toplevel = this->findToplevelByAddress(windowAddress, true); + this->bActiveToplevel = toplevel; + } else if (event->name == "urgent") { + auto args = event->parseView(1); + auto ok = false; + auto windowAddress = args.at(0).toULongLong(&ok, 16); + + if (!ok) return; + + // It happens that Hyprland sends urgent before "openwindow" + auto* toplevel = this->findToplevelByAddress(windowAddress, true); + toplevel->bindableUrgent().setValue(true); } } @@ -496,6 +669,71 @@ void HyprlandIpc::refreshWorkspaces(bool canCreate) { }); } +HyprlandToplevel* HyprlandIpc::findToplevelByAddress(quint64 address, bool createIfMissing) { + const auto& mList = this->mToplevels.valueList(); + HyprlandToplevel* toplevel = nullptr; + + auto toplevelIter = + std::ranges::find_if(mList, [&](HyprlandToplevel* m) { return m->address() == address; }); + + toplevel = toplevelIter == mList.end() ? nullptr : *toplevelIter; + + if (!toplevel && createIfMissing) { + qCDebug(logHyprlandIpc) << "Toplevel with address" << address + << "requested before creation, performing early init"; + + toplevel = new HyprlandToplevel(this); + toplevel->updateInitial(address, "", ""); + this->mToplevels.insertObject(toplevel); + } + + return toplevel; +} + +void HyprlandIpc::refreshToplevels() { + if (this->requestingToplevels) return; + this->requestingToplevels = true; + + this->makeRequest("j/clients", [this](bool success, const QByteArray& resp) { + this->requestingToplevels = false; + if (!success) return; + + qCDebug(logHyprlandIpc) << "Parsing j/clients response"; + auto json = QJsonDocument::fromJson(resp).array(); + + const auto& mList = this->mToplevels.valueList(); + + for (auto entry: json) { + auto object = entry.toObject().toVariantMap(); + + bool ok = false; + auto address = object.value("address").toString().toULongLong(&ok, 16); + + if (!ok) { + qCWarning(logHyprlandIpc) << "Invalid address in j/clients entry:" << object; + continue; + } + + auto toplevelsIter = + std::ranges::find_if(mList, [&](HyprlandToplevel* m) { return m->address() == address; }); + + auto* toplevel = toplevelsIter == mList.end() ? nullptr : *toplevelsIter; + auto exists = toplevel != nullptr; + + if (!exists) toplevel = new HyprlandToplevel(this); + toplevel->updateFromObject(object); + + if (!exists) { + qCDebug(logHyprlandIpc) << "New toplevel created with address" << address; + this->mToplevels.insertObject(toplevel); + } + + auto* workspace = toplevel->bindableWorkspace().value(); + if (workspace) workspace->insertToplevel(toplevel); + } + }); +} + HyprlandMonitor* HyprlandIpc::findMonitorByName(const QString& name, bool createIfMissing, qint32 id) { const auto& mList = this->mMonitors.valueList(); diff --git a/src/wayland/hyprland/ipc/connection.hpp b/src/wayland/hyprland/ipc/connection.hpp index 5a5783f..ba1e7c9 100644 --- a/src/wayland/hyprland/ipc/connection.hpp +++ b/src/wayland/hyprland/ipc/connection.hpp @@ -14,16 +14,20 @@ #include "../../../core/model.hpp" #include "../../../core/qmlscreen.hpp" +#include "../../../core/streamreader.hpp" +#include "../../../wayland/toplevel_management/handle.hpp" namespace qs::hyprland::ipc { class HyprlandMonitor; class HyprlandWorkspace; +class HyprlandToplevel; } // namespace qs::hyprland::ipc Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandWorkspace*); Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandMonitor*); +Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandToplevel*); namespace qs::hyprland::ipc { @@ -85,18 +89,25 @@ public: return &this->bFocusedWorkspace; } + [[nodiscard]] QBindable bindableActiveToplevel() const { + return &this->bActiveToplevel; + } + void setFocusedMonitor(HyprlandMonitor* monitor); [[nodiscard]] ObjectModel* monitors(); [[nodiscard]] ObjectModel* workspaces(); + [[nodiscard]] ObjectModel* toplevels(); // No byId because these preemptively create objects. The given id is set if created. HyprlandWorkspace* findWorkspaceByName(const QString& name, bool createIfMissing, qint32 id = -1); HyprlandMonitor* findMonitorByName(const QString& name, bool createIfMissing, qint32 id = -1); + HyprlandToplevel* findToplevelByAddress(quint64 address, bool createIfMissing); // canCreate avoids making ghost workspaces when the connection races void refreshWorkspaces(bool canCreate); void refreshMonitors(bool canCreate); + void refreshToplevels(); // The last argument may contain commas, so the count is required. [[nodiscard]] static QVector parseEventArgs(QByteArrayView event, quint16 count); @@ -107,12 +118,18 @@ signals: void focusedMonitorChanged(); void focusedWorkspaceChanged(); + void activeToplevelChanged(); private slots: void eventSocketError(QLocalSocket::LocalSocketError error) const; void eventSocketStateChanged(QLocalSocket::LocalSocketState state); void eventSocketReady(); + void toplevelAddressed( + qs::wayland::toplevel_management::impl::ToplevelHandle* handle, + quint64 address + ); + void onFocusedMonitorDestroyed(); private: @@ -123,15 +140,18 @@ private: static bool compareWorkspaces(HyprlandWorkspace* a, HyprlandWorkspace* b); QLocalSocket eventSocket; + StreamReader eventReader; QString mRequestSocketPath; QString mEventSocketPath; bool valid = false; bool requestingMonitors = false; bool requestingWorkspaces = false; + bool requestingToplevels = false; bool monitorsRequested = false; ObjectModel mMonitors {this}; ObjectModel mWorkspaces {this}; + ObjectModel mToplevels {this}; HyprlandIpcEvent event {this}; @@ -148,6 +168,13 @@ private: bFocusedWorkspace, &HyprlandIpc::focusedWorkspaceChanged ); + + Q_OBJECT_BINDABLE_PROPERTY( + HyprlandIpc, + HyprlandToplevel*, + bActiveToplevel, + &HyprlandIpc::activeToplevelChanged + ); }; } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/hyprland_toplevel.cpp b/src/wayland/hyprland/ipc/hyprland_toplevel.cpp index 93c924c..43b9838 100644 --- a/src/wayland/hyprland/ipc/hyprland_toplevel.cpp +++ b/src/wayland/hyprland/ipc/hyprland_toplevel.cpp @@ -2,50 +2,158 @@ #include #include +#include #include #include -#include "toplevel_mapping.hpp" -#include "../../toplevel_management/handle.hpp" #include "../../toplevel_management/qml.hpp" +#include "connection.hpp" +#include "toplevel_mapping.hpp" +#include "workspace.hpp" using namespace qs::wayland::toplevel_management; -using namespace qs::wayland::toplevel_management::impl; namespace qs::hyprland::ipc { -HyprlandToplevel::HyprlandToplevel(Toplevel* toplevel) - : QObject(toplevel) - , handle(toplevel->implHandle()) { - auto* instance = HyprlandToplevelMappingManager::instance(); - auto addr = instance->getToplevelAddress(handle); +HyprlandToplevel::HyprlandToplevel(HyprlandIpc* ipc): QObject(ipc), ipc(ipc) { + this->bMonitor.setBinding([this]() { + return this->bWorkspace ? this->bWorkspace->bindableMonitor().value() : nullptr; + }); - if (addr != 0) this->setAddress(addr); - else { - QObject::connect( - instance, - &HyprlandToplevelMappingManager::toplevelAddressed, - this, - &HyprlandToplevel::onToplevelAddressed - ); - } + this->bActivated.setBinding([this]() { + return this->ipc->bindableActiveToplevel().value() == this; + }); + + QObject::connect( + this, + &HyprlandToplevel::activatedChanged, + this, + &HyprlandToplevel::onActivatedChanged + ); } -void HyprlandToplevel::onToplevelAddressed(ToplevelHandle* handle, quint64 address) { - if (handle == this->handle) { - this->setAddress(address); - QObject::disconnect(HyprlandToplevelMappingManager::instance(), nullptr, this, nullptr); +HyprlandToplevel::HyprlandToplevel(HyprlandIpc* ipc, Toplevel* toplevel): HyprlandToplevel(ipc) { + this->mWaylandHandle = toplevel->implHandle(); + auto* instance = HyprlandToplevelMappingManager::instance(); + auto addr = instance->getToplevelAddress(this->mWaylandHandle); + + if (!addr) { + // Address not available, will rely on HyprlandIpc to resolve it. + return; + } + + this->setAddress(addr); + + // Check if client is present in HyprlandIPC + auto* hyprToplevel = ipc->findToplevelByAddress(addr, false); + // HyprlandIpc will eventually resolve it + if (!hyprToplevel) return; + + this->setHyprlandHandle(hyprToplevel); +} + +void HyprlandToplevel::updateInitial( + quint64 address, + const QString& title, + const QString& workspaceName +) { + auto* workspace = this->ipc->findWorkspaceByName(workspaceName, false); + Qt::beginPropertyUpdateGroup(); + this->setAddress(address); + this->bTitle = title; + this->setWorkspace(workspace); + Qt::endPropertyUpdateGroup(); +} + +void HyprlandToplevel::updateFromObject(const QVariantMap& object) { + auto addressStr = object.value("address").value(); + auto title = object.value("title").value(); + + Qt::beginPropertyUpdateGroup(); + bool ok = false; + auto address = addressStr.toULongLong(&ok, 16); + if (ok && address) this->setAddress(address); + + this->bTitle = title; + + auto workspaceMap = object.value("workspace").toMap(); + auto workspaceName = workspaceMap.value("name").toString(); + + auto* workspace = this->ipc->findWorkspaceByName(workspaceName, true); + if (workspace) this->setWorkspace(workspace); + + this->bLastIpcObject = object; + Qt::endPropertyUpdateGroup(); +} + +void HyprlandToplevel::setWorkspace(HyprlandWorkspace* workspace) { + auto* oldWorkspace = this->bWorkspace.value(); + if (oldWorkspace == workspace) return; + + if (oldWorkspace) { + QObject::disconnect(oldWorkspace, nullptr, this, nullptr); + } + + this->bWorkspace = workspace; + + if (workspace) { + QObject::connect(workspace, &QObject::destroyed, this, [this]() { + this->bWorkspace = nullptr; + }); } } void HyprlandToplevel::setAddress(quint64 address) { - this->mAddress = QString::number(address, 16); + this->mAddress = address; emit this->addressChanged(); } +Toplevel* HyprlandToplevel::waylandHandle() { + return ToplevelManager::instance()->forImpl(this->mWaylandHandle); +} + +void HyprlandToplevel::setWaylandHandle(impl::ToplevelHandle* handle) { + if (this->mWaylandHandle == handle) return; + if (this->mWaylandHandle) { + QObject::disconnect(this->mWaylandHandle, nullptr, this, nullptr); + } + + this->mWaylandHandle = handle; + if (handle) { + QObject::connect(handle, &QObject::destroyed, this, [this]() { + this->mWaylandHandle = nullptr; + }); + } + + emit this->waylandHandleChanged(); +} + +void HyprlandToplevel::setHyprlandHandle(HyprlandToplevel* handle) { + if (this->mHyprlandHandle == handle) return; + if (this->mHyprlandHandle) { + QObject::disconnect(this->mHyprlandHandle, nullptr, this, nullptr); + } + this->mHyprlandHandle = handle; + if (handle) { + QObject::connect(handle, &QObject::destroyed, this, [this]() { + this->mHyprlandHandle = nullptr; + }); + } + + emit this->hyprlandHandleChanged(); +} + +void HyprlandToplevel::onActivatedChanged() { + if (this->bUrgent.value()) { + // If was urgent, and now active, clear urgent state + this->bUrgent = false; + } +} + HyprlandToplevel* HyprlandToplevel::qmlAttachedProperties(QObject* object) { if (auto* toplevel = qobject_cast(object)) { - return new HyprlandToplevel(toplevel); + auto* ipc = HyprlandIpc::instance(); + return new HyprlandToplevel(ipc, toplevel); } else { return nullptr; } diff --git a/src/wayland/hyprland/ipc/hyprland_toplevel.hpp b/src/wayland/hyprland/ipc/hyprland_toplevel.hpp index 2cc70a5..ebd4f84 100644 --- a/src/wayland/hyprland/ipc/hyprland_toplevel.hpp +++ b/src/wayland/hyprland/ipc/hyprland_toplevel.hpp @@ -2,49 +2,120 @@ #include #include +#include #include #include #include #include "../../toplevel_management/handle.hpp" #include "../../toplevel_management/qml.hpp" +#include "connection.hpp" namespace qs::hyprland::ipc { -//! Exposes Hyprland window address for a Toplevel -/// Attached object of @@Quickshell.Wayland.Toplevel which exposes -/// a Hyprland window address for the window. +//! Hyprland Toplevel +/// Represents a window as Hyprland exposes it. +/// Can also be used as an attached object of a @@Quickshell.Wayland.Toplevel, +/// to resolve a handle to an Hyprland toplevel. class HyprlandToplevel: public QObject { Q_OBJECT; QML_ELEMENT; QML_UNCREATABLE(""); QML_ATTACHED(HyprlandToplevel); + // clang-format off /// Hexadecimal Hyprland window address. Will be an empty string until /// the address is reported. - Q_PROPERTY(QString address READ address NOTIFY addressChanged); + Q_PROPERTY(QString address READ addressStr NOTIFY addressChanged); + /// The toplevel handle, exposing the Hyprland toplevel. + /// Will be null until the address is reported + Q_PROPERTY(HyprlandToplevel* handle READ hyprlandHandle NOTIFY hyprlandHandleChanged); + /// The wayland toplevel handle. Will be null intil the address is reported + Q_PROPERTY(qs::wayland::toplevel_management::Toplevel* wayland READ waylandHandle NOTIFY waylandHandleChanged); + /// The title of the toplevel + Q_PROPERTY(QString title READ default NOTIFY titleChanged BINDABLE bindableTitle); + /// Whether the toplevel is active or not + Q_PROPERTY(bool activated READ default NOTIFY activatedChanged BINDABLE bindableActivated); + /// Whether the client is urgent or not + Q_PROPERTY(bool urgent READ default NOTIFY urgentChanged BINDABLE bindableUrgent); + /// Last json returned for this toplevel, as a javascript object. + /// + /// > [!WARNING] This is *not* updated unless the toplevel object is fetched again from + /// > Hyprland. If you need a value that is subject to change and does not have a dedicated + /// > property, run @@Hyprland.refreshToplevels() and wait for this property to update. + Q_PROPERTY(QVariantMap lastIpcObject READ default BINDABLE bindableLastIpcObject NOTIFY lastIpcObjectChanged); + /// The current workspace of the toplevel (might be null) + Q_PROPERTY(qs::hyprland::ipc::HyprlandWorkspace* workspace READ default NOTIFY workspaceChanged BINDABLE bindableWorkspace); + /// The current monitor of the toplevel (might be null) + Q_PROPERTY(qs::hyprland::ipc::HyprlandMonitor* monitor READ default NOTIFY monitorChanged BINDABLE bindableMonitor); + // clang-format on public: - explicit HyprlandToplevel(qs::wayland::toplevel_management::Toplevel* toplevel); - - [[nodiscard]] QString address() { return this->mAddress; } + /// When invoked from HyprlandIpc, reacting to Hyprland's IPC events. + explicit HyprlandToplevel(HyprlandIpc* ipc); + /// When attached from a Toplevel + explicit HyprlandToplevel(HyprlandIpc* ipc, qs::wayland::toplevel_management::Toplevel* toplevel); static HyprlandToplevel* qmlAttachedProperties(QObject* object); -signals: - void addressChanged(); + void updateInitial(quint64 address, const QString& title, const QString& workspaceName); -private slots: - void onToplevelAddressed( - qs::wayland::toplevel_management::impl::ToplevelHandle* handle, - quint64 address - ); + void updateFromObject(const QVariantMap& object); -private: + [[nodiscard]] QString addressStr() const { return QString::number(this->mAddress, 16); } + [[nodiscard]] quint64 address() const { return this->mAddress; } void setAddress(quint64 address); - QString mAddress; - // doesn't have to be nulled on destroy, only used for comparison - qs::wayland::toplevel_management::impl::ToplevelHandle* handle; + // clang-format off + [[nodiscard]] HyprlandToplevel* hyprlandHandle() { return this->mHyprlandHandle; } + void setHyprlandHandle(HyprlandToplevel* handle); + + [[nodiscard]] wayland::toplevel_management::Toplevel* waylandHandle(); + void setWaylandHandle(wayland::toplevel_management::impl::ToplevelHandle* handle); + // clang-format on + + [[nodiscard]] QBindable bindableTitle() { return &this->bTitle; } + [[nodiscard]] QBindable bindableActivated() { return &this->bActivated; } + [[nodiscard]] QBindable bindableUrgent() { return &this->bUrgent; } + + [[nodiscard]] QBindable bindableLastIpcObject() const { + return &this->bLastIpcObject; + }; + + [[nodiscard]] QBindable bindableWorkspace() { return &this->bWorkspace; } + void setWorkspace(HyprlandWorkspace* workspace); + + [[nodiscard]] QBindable bindableMonitor() { return &this->bMonitor; } + +signals: + void addressChanged(); + QSDOC_HIDE void waylandHandleChanged(); + QSDOC_HIDE void hyprlandHandleChanged(); + + void titleChanged(); + void activatedChanged(); + void urgentChanged(); + void workspaceChanged(); + void monitorChanged(); + void lastIpcObjectChanged(); + +private slots: + void onActivatedChanged(); + +private: + quint64 mAddress = 0; + HyprlandIpc* ipc; + + qs::wayland::toplevel_management::impl::ToplevelHandle* mWaylandHandle = nullptr; + HyprlandToplevel* mHyprlandHandle = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, QString, bTitle, &HyprlandToplevel::titleChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, bool, bActivated, &HyprlandToplevel::activatedChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, bool, bUrgent, &HyprlandToplevel::urgentChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, HyprlandWorkspace*, bWorkspace, &HyprlandToplevel::workspaceChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, HyprlandMonitor*, bMonitor, &HyprlandToplevel::monitorChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandToplevel, QVariantMap, bLastIpcObject, &HyprlandToplevel::lastIpcObjectChanged); + // clang-format on }; } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/qml.cpp b/src/wayland/hyprland/ipc/qml.cpp index a497ab3..eb5fdc6 100644 --- a/src/wayland/hyprland/ipc/qml.cpp +++ b/src/wayland/hyprland/ipc/qml.cpp @@ -24,9 +24,16 @@ HyprlandIpcQml::HyprlandIpcQml() { QObject::connect( instance, - &HyprlandIpc::focusedMonitorChanged, + &HyprlandIpc::focusedWorkspaceChanged, this, - &HyprlandIpcQml::focusedMonitorChanged + &HyprlandIpcQml::focusedWorkspaceChanged + ); + + QObject::connect( + instance, + &HyprlandIpc::activeToplevelChanged, + this, + &HyprlandIpcQml::activeToplevelChanged ); } @@ -40,6 +47,7 @@ HyprlandMonitor* HyprlandIpcQml::monitorFor(QuickshellScreenInfo* screen) { void HyprlandIpcQml::refreshMonitors() { HyprlandIpc::instance()->refreshMonitors(false); } void HyprlandIpcQml::refreshWorkspaces() { HyprlandIpc::instance()->refreshWorkspaces(false); } +void HyprlandIpcQml::refreshToplevels() { HyprlandIpc::instance()->refreshToplevels(); } QString HyprlandIpcQml::requestSocketPath() { return HyprlandIpc::instance()->requestSocketPath(); } QString HyprlandIpcQml::eventSocketPath() { return HyprlandIpc::instance()->eventSocketPath(); } @@ -51,6 +59,10 @@ QBindable HyprlandIpcQml::bindableFocusedWorkspace() { return HyprlandIpc::instance()->bindableFocusedWorkspace(); } +QBindable HyprlandIpcQml::bindableActiveToplevel() { + return HyprlandIpc::instance()->bindableActiveToplevel(); +} + ObjectModel* HyprlandIpcQml::monitors() { return HyprlandIpc::instance()->monitors(); } @@ -59,4 +71,8 @@ ObjectModel* HyprlandIpcQml::workspaces() { return HyprlandIpc::instance()->workspaces(); } +ObjectModel* HyprlandIpcQml::toplevels() { + return HyprlandIpc::instance()->toplevels(); +} + } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/qml.hpp b/src/wayland/hyprland/ipc/qml.hpp index f776ef6..ebf5d80 100644 --- a/src/wayland/hyprland/ipc/qml.hpp +++ b/src/wayland/hyprland/ipc/qml.hpp @@ -24,6 +24,8 @@ class HyprlandIpcQml: public QObject { Q_PROPERTY(qs::hyprland::ipc::HyprlandMonitor* focusedMonitor READ default NOTIFY focusedMonitorChanged BINDABLE bindableFocusedMonitor); /// The currently focused hyprland workspace. May be null. Q_PROPERTY(qs::hyprland::ipc::HyprlandWorkspace* focusedWorkspace READ default NOTIFY focusedWorkspaceChanged BINDABLE bindableFocusedWorkspace); + /// Currently active toplevel (might be null) + Q_PROPERTY(qs::hyprland::ipc::HyprlandToplevel* activeToplevel READ default NOTIFY activeToplevelChanged BINDABLE bindableActiveToplevel); /// All hyprland monitors. QSDOC_TYPE_OVERRIDE(ObjectModel*); Q_PROPERTY(UntypedObjectModel* monitors READ monitors CONSTANT); @@ -32,6 +34,9 @@ class HyprlandIpcQml: public QObject { /// > [!NOTE] Named workspaces have a negative id, and will appear before unnamed workspaces. QSDOC_TYPE_OVERRIDE(ObjectModel*); Q_PROPERTY(UntypedObjectModel* workspaces READ workspaces CONSTANT); + /// All hyprland toplevels + QSDOC_TYPE_OVERRIDE(ObjectModel*); + Q_PROPERTY(UntypedObjectModel* toplevels READ toplevels CONSTANT); // clang-format on QML_NAMED_ELEMENT(Hyprland); QML_SINGLETON; @@ -57,12 +62,20 @@ public: /// so this function is available if required. Q_INVOKABLE static void refreshWorkspaces(); + /// Refresh toplevel information. + /// + /// Many actions that will invalidate workspace state don't send events, + /// so this function is available if required. + Q_INVOKABLE static void refreshToplevels(); + [[nodiscard]] static QString requestSocketPath(); [[nodiscard]] static QString eventSocketPath(); [[nodiscard]] static QBindable bindableFocusedMonitor(); [[nodiscard]] static QBindable bindableFocusedWorkspace(); + [[nodiscard]] static QBindable bindableActiveToplevel(); [[nodiscard]] static ObjectModel* monitors(); [[nodiscard]] static ObjectModel* workspaces(); + [[nodiscard]] static ObjectModel* toplevels(); signals: /// Emitted for every event that comes in through the hyprland event socket (socket2). @@ -72,6 +85,7 @@ signals: void focusedMonitorChanged(); void focusedWorkspaceChanged(); + void activeToplevelChanged(); }; } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/workspace.cpp b/src/wayland/hyprland/ipc/workspace.cpp index bc0070d..c5d63b0 100644 --- a/src/wayland/hyprland/ipc/workspace.cpp +++ b/src/wayland/hyprland/ipc/workspace.cpp @@ -1,4 +1,5 @@ #include "workspace.hpp" +#include #include #include @@ -25,6 +26,12 @@ HyprlandWorkspace::HyprlandWorkspace(HyprlandIpc* ipc): QObject(ipc), ipc(ipc) { return this->ipc->bindableFocusedWorkspace().value() == this; }); + QObject::connect(this, &HyprlandWorkspace::focusedChanged, this, [this]() { + if (this->bFocused.value()) { + this->updateUrgent(); + } + }); + Qt::endPropertyUpdateGroup(); } @@ -82,8 +89,69 @@ void HyprlandWorkspace::setMonitor(HyprlandMonitor* monitor) { void HyprlandWorkspace::onMonitorDestroyed() { this->bMonitor = nullptr; } +void HyprlandWorkspace::insertToplevel(HyprlandToplevel* toplevel) { + if (!toplevel) return; + + const auto& mList = this->mToplevels.valueList(); + + if (std::ranges::find(mList, toplevel) != mList.end()) { + return; + } + + this->mToplevels.insertObject(toplevel); + + QObject::connect(toplevel, &QObject::destroyed, this, [this, toplevel]() { + this->removeToplevel(toplevel); + }); + + QObject::connect( + toplevel, + &HyprlandToplevel::urgentChanged, + this, + &HyprlandWorkspace::updateUrgent + ); + + this->updateUrgent(); +} + +void HyprlandWorkspace::removeToplevel(HyprlandToplevel* toplevel) { + if (!toplevel) return; + + this->mToplevels.removeObject(toplevel); + emit this->updateUrgent(); + QObject::disconnect(toplevel, nullptr, this, nullptr); +} + +// Triggered when there is an update either on the toplevel list, on a toplevel's urgent state +void HyprlandWorkspace::updateUrgent() { + const auto& mList = this->mToplevels.valueList(); + + const bool hasUrgentToplevel = std::ranges::any_of(mList, [&](HyprlandToplevel* toplevel) { + return toplevel->bindableUrgent().value(); + }); + + if (this->bFocused && hasUrgentToplevel) { + this->clearUrgent(); + return; + } + + if (hasUrgentToplevel != this->bUrgent.value()) { + this->bUrgent = hasUrgentToplevel; + } +} + +void HyprlandWorkspace::clearUrgent() { + this->bUrgent = false; + + // Clear all urgent toplevels + const auto& mList = this->mToplevels.valueList(); + for (auto* toplevel: mList) { + toplevel->bindableUrgent().setValue(false); + } +} + void HyprlandWorkspace::activate() { - this->ipc->dispatch(QString("workspace %1").arg(this->bId.value())); + this->ipc->dispatch(QString("workspace %1").arg(this->bName.value())); } } // namespace qs::hyprland::ipc diff --git a/src/wayland/hyprland/ipc/workspace.hpp b/src/wayland/hyprland/ipc/workspace.hpp index 3493c5f..a0b09cf 100644 --- a/src/wayland/hyprland/ipc/workspace.hpp +++ b/src/wayland/hyprland/ipc/workspace.hpp @@ -9,6 +9,7 @@ #include #include "connection.hpp" +#include "hyprland_toplevel.hpp" namespace qs::hyprland::ipc { @@ -24,8 +25,11 @@ class HyprlandWorkspace: public QObject { /// If this workspace is currently active on a monitor and that monitor is currently /// focused. See also @@active. Q_PROPERTY(bool focused READ default NOTIFY focusedChanged BINDABLE bindableFocused); + /// If this workspace has a window that is urgent. + /// Becomes always falsed after the workspace is @@focused. + Q_PROPERTY(bool urgent READ default NOTIFY urgentChanged BINDABLE bindableUrgent); /// If this workspace currently has a fullscreen client. - Q_PROPERTY(bool hasFullscreen READ default NOTIFY focusedChanged BINDABLE bindableHasFullscreen); + Q_PROPERTY(bool hasFullscreen READ default NOTIFY hasFullscreenChanged BINDABLE bindableHasFullscreen); /// Last json returned for this workspace, as a javascript object. /// /// > [!WARNING] This is *not* updated unless the workspace object is fetched again from @@ -33,6 +37,9 @@ class HyprlandWorkspace: public QObject { /// > property, run @@Hyprland.refreshWorkspaces() and wait for this property to update. Q_PROPERTY(QVariantMap lastIpcObject READ lastIpcObject NOTIFY lastIpcObjectChanged); Q_PROPERTY(qs::hyprland::ipc::HyprlandMonitor* monitor READ default NOTIFY monitorChanged BINDABLE bindableMonitor); + /// List of toplevels on this workspace. + QSDOC_TYPE_OVERRIDE(ObjectModel [!NOTE] This is equivalent to running /// > ```qml - /// > HyprlandIpc.dispatch(`workspace ${workspace.id}`); + /// > HyprlandIpc.dispatch(`workspace ${workspace.name}`); /// > ``` Q_INVOKABLE void activate(); @@ -55,35 +62,46 @@ public: [[nodiscard]] QBindable bindableName() { return &this->bName; } [[nodiscard]] QBindable bindableActive() { return &this->bActive; } [[nodiscard]] QBindable bindableFocused() { return &this->bFocused; } + [[nodiscard]] QBindable bindableUrgent() { return &this->bUrgent; } [[nodiscard]] QBindable bindableHasFullscreen() { return &this->bHasFullscreen; } [[nodiscard]] QBindable bindableMonitor() { return &this->bMonitor; } + [[nodiscard]] ObjectModel* toplevels() { return &this->mToplevels; } [[nodiscard]] QVariantMap lastIpcObject() const; void setMonitor(HyprlandMonitor* monitor); + void insertToplevel(HyprlandToplevel* toplevel); + void removeToplevel(HyprlandToplevel* toplevel); + signals: void idChanged(); void nameChanged(); void activeChanged(); void focusedChanged(); + void urgentChanged(); void hasFullscreenChanged(); void lastIpcObjectChanged(); void monitorChanged(); private slots: void onMonitorDestroyed(); + void updateUrgent(); private: - HyprlandIpc* ipc; + void clearUrgent(); + HyprlandIpc* ipc; QVariantMap mLastIpcObject; + ObjectModel mToplevels {this}; + // clang-format off Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(HyprlandWorkspace, qint32, bId, -1, &HyprlandWorkspace::idChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, QString, bName, &HyprlandWorkspace::nameChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, bool, bActive, &HyprlandWorkspace::activeChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, bool, bFocused, &HyprlandWorkspace::focusedChanged); + Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, bool, bUrgent, &HyprlandWorkspace::urgentChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, bool, bHasFullscreen, &HyprlandWorkspace::hasFullscreenChanged); Q_OBJECT_BINDABLE_PROPERTY(HyprlandWorkspace, HyprlandMonitor*, bMonitor, &HyprlandWorkspace::monitorChanged); // clang-format on diff --git a/src/wayland/hyprland/module.md b/src/wayland/hyprland/module.md index 0bdb6a7..921bac9 100644 --- a/src/wayland/hyprland/module.md +++ b/src/wayland/hyprland/module.md @@ -4,6 +4,7 @@ headers = [ "ipc/connection.hpp", "ipc/monitor.hpp", "ipc/workspace.hpp", + "ipc/hyprland_toplevel.hpp", "ipc/qml.hpp", "focus_grab/qml.hpp", "global_shortcuts/qml.hpp", diff --git a/src/wayland/hyprland/surface/qml.cpp b/src/wayland/hyprland/surface/qml.cpp index b00ee33..4575842 100644 --- a/src/wayland/hyprland/surface/qml.cpp +++ b/src/wayland/hyprland/surface/qml.cpp @@ -14,7 +14,6 @@ #include "../../../core/region.hpp" #include "../../../window/proxywindow.hpp" -#include "../../../window/windowinterface.hpp" #include "manager.hpp" #include "surface.hpp" @@ -23,13 +22,7 @@ using QtWaylandClient::QWaylandWindow; namespace qs::hyprland::surface { HyprlandWindow* HyprlandWindow::qmlAttachedProperties(QObject* object) { - auto* proxyWindow = qobject_cast(object); - - if (!proxyWindow) { - if (auto* iface = qobject_cast(object)) { - proxyWindow = iface->proxyWindow(); - } - } + auto* proxyWindow = ProxyWindowBase::forObject(object); if (!proxyWindow) return nullptr; return new HyprlandWindow(proxyWindow); @@ -65,7 +58,8 @@ void HyprlandWindow::setOpacity(qreal opacity) { if (opacity == this->mOpacity) return; if (opacity < 0.0 || opacity > 1.0) { - qmlWarning(this + qmlWarning( + this ) << "Cannot set HyprlandWindow.opacity to a value larger than 1.0 or smaller than 0.0"; return; } diff --git a/src/wayland/hyprland/surface/surface.cpp b/src/wayland/hyprland/surface/surface.cpp index 487da40..774acd0 100644 --- a/src/wayland/hyprland/surface/surface.cpp +++ b/src/wayland/hyprland/surface/surface.cpp @@ -1,5 +1,4 @@ #include "surface.hpp" -#include #include #include @@ -18,7 +17,6 @@ HyprlandSurface::HyprlandSurface( QtWaylandClient::QWaylandWindow* backer ) : QtWayland::hyprland_surface_v1(surface) - , backer(backer) , backerSurface(backer->surface()) {} HyprlandSurface::~HyprlandSurface() { this->destroy(); } diff --git a/src/wayland/hyprland/surface/surface.hpp b/src/wayland/hyprland/surface/surface.hpp index 1c8b548..48a2cda 100644 --- a/src/wayland/hyprland/surface/surface.hpp +++ b/src/wayland/hyprland/surface/surface.hpp @@ -24,7 +24,6 @@ public: void setVisibleRegion(const QRegion& region); private: - QtWaylandClient::QWaylandWindow* backer; wl_surface* backerSurface = nullptr; }; diff --git a/src/wayland/hyprland/test/manual/toplevel-association.qml b/src/wayland/hyprland/test/manual/toplevel-association.qml new file mode 100644 index 0000000..042b915 --- /dev/null +++ b/src/wayland/hyprland/test/manual/toplevel-association.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland + +FloatingWindow { + ColumnLayout { + anchors.fill: parent + + Text { text: "Hyprland -> Wayland" } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: Hyprland.toplevels + delegate: Text { + required property HyprlandToplevel modelData + text: `${modelData} -> ${modelData.wayland}` + } + } + + Text { text: "Wayland -> Hyprland" } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: ToplevelManager.toplevels + delegate: Text { + required property Toplevel modelData + text: `${modelData} -> ${modelData.HyprlandToplevel.handle}` + } + } + } +} diff --git a/src/wayland/hyprland/test/manual/toplevels.qml b/src/wayland/hyprland/test/manual/toplevels.qml new file mode 100644 index 0000000..da54e5c --- /dev/null +++ b/src/wayland/hyprland/test/manual/toplevels.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland + +FloatingWindow { + ColumnLayout { + anchors.fill: parent + + Text { text: "Current toplevel:" } + + ToplevelFromHyprland { + modelData: Hyprland.activeToplevel + } + + Text { text: "\nAll toplevels:" } + + ListView { + Layout.fillHeight: true + Layout.fillWidth: true + clip: true + model: Hyprland.toplevels + delegate: ToplevelFromHyprland {} + } + } + + component ToplevelFromHyprland: ColumnLayout { + required property HyprlandToplevel modelData + + Text { + text: `Window 0x${modelData.address}, title: ${modelData.title}, activated: ${modelData.activated}, workspace id: ${modelData.workspace.id}, monitor name: ${modelData.monitor.name}, urgent: ${modelData.urgent}` + } + } +} diff --git a/src/wayland/hyprland/test/manual/workspaces.qml b/src/wayland/hyprland/test/manual/workspaces.qml new file mode 100644 index 0000000..ef1bafe --- /dev/null +++ b/src/wayland/hyprland/test/manual/workspaces.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland + +FloatingWindow { + ListView { + anchors.fill: parent + model: Hyprland.workspaces + spacing: 5 + + delegate: WrapperRectangle { + id: wsDelegate + required property HyprlandWorkspace modelData + color: "lightgray" + + ColumnLayout { + Text { text: `Workspace ${wsDelegate.modelData.id} on ${wsDelegate.modelData.monitor} | urgent: ${wsDelegate.modelData.urgent}`} + + ColumnLayout { + Repeater { + model: wsDelegate.modelData.toplevels + Text { + id: tDelegate + required property HyprlandToplevel modelData; + text: `${tDelegate.modelData}: ${tDelegate.modelData.title}` + } + } + } + } + } + } +} diff --git a/src/wayland/idle_inhibit/CMakeLists.txt b/src/wayland/idle_inhibit/CMakeLists.txt new file mode 100644 index 0000000..eb346d6 --- /dev/null +++ b/src/wayland/idle_inhibit/CMakeLists.txt @@ -0,0 +1,25 @@ +qt_add_library(quickshell-wayland-idle-inhibit STATIC + proto.cpp + inhibitor.cpp +) + +qt_add_qml_module(quickshell-wayland-idle-inhibit + URI Quickshell.Wayland._IdleInhibitor + VERSION 0.1 + DEPENDENCIES QtQuick +) + +install_qml_module(quickshell-wayland-idle-inhibit) + +qs_add_module_deps_light(quickshell-wayland-idle-inhibit Quickshell) + +wl_proto(wlp-idle-inhibit idle-inhibit-unstable-v1 "${WAYLAND_PROTOCOLS}/unstable/idle-inhibit") + +target_link_libraries(quickshell-wayland-idle-inhibit PRIVATE + Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client + wlp-idle-inhibit +) + +qs_module_pch(quickshell-wayland-idle-inhibit SET large) + +target_link_libraries(quickshell PRIVATE quickshell-wayland-idle-inhibitplugin) diff --git a/src/wayland/idle_inhibit/inhibitor.cpp b/src/wayland/idle_inhibit/inhibitor.cpp new file mode 100644 index 0000000..bfea7a0 --- /dev/null +++ b/src/wayland/idle_inhibit/inhibitor.cpp @@ -0,0 +1,125 @@ +#include "inhibitor.hpp" + +#include +#include +#include +#include + +#include "../../window/proxywindow.hpp" +#include "proto.hpp" + +namespace qs::wayland::idle_inhibit { +using QtWaylandClient::QWaylandWindow; + +IdleInhibitor::IdleInhibitor() { + this->bBoundWindow.setBinding([this] { + return this->bEnabled ? this->bWindowObject.value() : nullptr; + }); +} + +IdleInhibitor::~IdleInhibitor() { delete this->inhibitor; } + +QObject* IdleInhibitor::window() const { return this->bWindowObject; } + +void IdleInhibitor::setWindow(QObject* window) { + if (window == this->bWindowObject) return; + + auto* proxyWindow = ProxyWindowBase::forObject(window); + this->bWindowObject = proxyWindow ? window : nullptr; +} + +void IdleInhibitor::boundWindowChanged() { + auto* window = this->bBoundWindow.value(); + auto* proxyWindow = ProxyWindowBase::forObject(window); + if (proxyWindow == this->proxyWindow) return; + + if (this->mWaylandWindow) { + QObject::disconnect(this->mWaylandWindow, nullptr, this, nullptr); + this->mWaylandWindow = nullptr; + this->onWaylandSurfaceDestroyed(); + } + + if (this->proxyWindow) { + QObject::disconnect(this->proxyWindow, nullptr, this, nullptr); + this->proxyWindow = nullptr; + } + + if (proxyWindow) { + this->proxyWindow = proxyWindow; + + QObject::connect(proxyWindow, &QObject::destroyed, this, &IdleInhibitor::onWindowDestroyed); + + QObject::connect( + proxyWindow, + &ProxyWindowBase::backerVisibilityChanged, + this, + &IdleInhibitor::onWindowVisibilityChanged + ); + + this->onWindowVisibilityChanged(); + } + + emit this->windowChanged(); +} + +void IdleInhibitor::onWindowDestroyed() { + this->proxyWindow = nullptr; + this->onWaylandSurfaceDestroyed(); + this->bWindowObject = nullptr; +} + +void IdleInhibitor::onWindowVisibilityChanged() { + if (!this->proxyWindow->isVisibleDirect()) return; + + auto* window = this->proxyWindow->backingWindow(); + if (!window->handle()) window->create(); + + auto* waylandWindow = dynamic_cast(window->handle()); + if (waylandWindow == this->mWaylandWindow) return; + this->mWaylandWindow = waylandWindow; + + QObject::connect( + waylandWindow, + &QObject::destroyed, + this, + &IdleInhibitor::onWaylandWindowDestroyed + ); + + QObject::connect( + waylandWindow, + &QWaylandWindow::surfaceCreated, + this, + &IdleInhibitor::onWaylandSurfaceCreated + ); + + QObject::connect( + waylandWindow, + &QWaylandWindow::surfaceDestroyed, + this, + &IdleInhibitor::onWaylandSurfaceDestroyed + ); + + if (waylandWindow->surface()) this->onWaylandSurfaceCreated(); +} + +void IdleInhibitor::onWaylandWindowDestroyed() { this->mWaylandWindow = nullptr; } + +void IdleInhibitor::onWaylandSurfaceCreated() { + auto* manager = impl::IdleInhibitManager::instance(); + + if (!manager) { + qWarning() << "Cannot enable idle inhibitor as idle-inhibit-unstable-v1 is not supported by " + "the current compositor."; + return; + } + + delete this->inhibitor; + this->inhibitor = manager->createIdleInhibitor(this->mWaylandWindow); +} + +void IdleInhibitor::onWaylandSurfaceDestroyed() { + delete this->inhibitor; + this->inhibitor = nullptr; +} + +} // namespace qs::wayland::idle_inhibit diff --git a/src/wayland/idle_inhibit/inhibitor.hpp b/src/wayland/idle_inhibit/inhibitor.hpp new file mode 100644 index 0000000..c1a3e95 --- /dev/null +++ b/src/wayland/idle_inhibit/inhibitor.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../../window/proxywindow.hpp" +#include "proto.hpp" + +namespace qs::wayland::idle_inhibit { + +///! Prevents a wayland session from idling +/// If an idle daemon is running, it may perform actions such as locking the screen +/// or putting the computer to sleep. +/// +/// An idle inhibitor prevents a wayland session from being marked as idle, if compositor +/// defined heuristics determine the window the inhibitor is attached to is important. +/// +/// A compositor will usually consider a @@Quickshell.PanelWindow or +/// a focused @@Quickshell.FloatingWindow to be important. +/// +/// > [!NOTE] Using an idle inhibitor requires the compositor support the [idle-inhibit-unstable-v1] protocol. +/// +/// [idle-inhibit-unstable-v1]: https://wayland.app/protocols/idle-inhibit-unstable-v1 +class IdleInhibitor: public QObject { + Q_OBJECT; + QML_ELEMENT; + // clang-format off + /// If the idle inhibitor should be enabled. Defaults to false. + Q_PROPERTY(bool enabled READ default WRITE default NOTIFY enabledChanged BINDABLE bindableEnabled); + /// The window to associate the idle inhibitor with. This may be used by the compositor + /// to determine if the inhibitor should be respected. + /// + /// Must be set to a non null value to enable the inhibitor. + Q_PROPERTY(QObject* window READ window WRITE setWindow NOTIFY windowChanged); + // clang-format on + +public: + IdleInhibitor(); + ~IdleInhibitor() override; + Q_DISABLE_COPY_MOVE(IdleInhibitor); + + [[nodiscard]] QObject* window() const; + void setWindow(QObject* window); + + [[nodiscard]] QBindable bindableEnabled() { return &this->bEnabled; } + +signals: + void enabledChanged(); + void windowChanged(); + +private slots: + void onWindowDestroyed(); + void onWindowVisibilityChanged(); + void onWaylandWindowDestroyed(); + void onWaylandSurfaceCreated(); + void onWaylandSurfaceDestroyed(); + +private: + void boundWindowChanged(); + ProxyWindowBase* proxyWindow = nullptr; + QtWaylandClient::QWaylandWindow* mWaylandWindow = nullptr; + + impl::IdleInhibitor* inhibitor = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(IdleInhibitor, bool, bEnabled, &IdleInhibitor::enabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(IdleInhibitor, QObject*, bWindowObject, &IdleInhibitor::windowChanged); + Q_OBJECT_BINDABLE_PROPERTY(IdleInhibitor, QObject*, bBoundWindow, &IdleInhibitor::boundWindowChanged); + // clang-format on +}; + +} // namespace qs::wayland::idle_inhibit diff --git a/src/wayland/idle_inhibit/proto.cpp b/src/wayland/idle_inhibit/proto.cpp new file mode 100644 index 0000000..25701a7 --- /dev/null +++ b/src/wayland/idle_inhibit/proto.cpp @@ -0,0 +1,34 @@ +#include "proto.hpp" + +#include +#include +#include +#include + +#include "../../core/logcat.hpp" + +namespace qs::wayland::idle_inhibit::impl { + +namespace { +QS_LOGGING_CATEGORY(logIdleInhibit, "quickshell.wayland.idle_inhibit", QtWarningMsg); +} + +IdleInhibitManager::IdleInhibitManager(): QWaylandClientExtensionTemplate(1) { this->initialize(); } + +IdleInhibitManager* IdleInhibitManager::instance() { + static auto* instance = new IdleInhibitManager(); // NOLINT + return instance->isInitialized() ? instance : nullptr; +} + +IdleInhibitor* IdleInhibitManager::createIdleInhibitor(QtWaylandClient::QWaylandWindow* surface) { + auto* inhibitor = new IdleInhibitor(this->create_inhibitor(surface->surface())); + qCDebug(logIdleInhibit) << "Created inhibitor" << inhibitor; + return inhibitor; +} + +IdleInhibitor::~IdleInhibitor() { + qCDebug(logIdleInhibit) << "Destroyed inhibitor" << this; + if (this->isInitialized()) this->destroy(); +} + +} // namespace qs::wayland::idle_inhibit::impl diff --git a/src/wayland/idle_inhibit/proto.hpp b/src/wayland/idle_inhibit/proto.hpp new file mode 100644 index 0000000..c797c33 --- /dev/null +++ b/src/wayland/idle_inhibit/proto.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +#include "wayland-idle-inhibit-unstable-v1-client-protocol.h" + +namespace qs::wayland::idle_inhibit::impl { + +class IdleInhibitor; + +class IdleInhibitManager + : public QWaylandClientExtensionTemplate + , public QtWayland::zwp_idle_inhibit_manager_v1 { +public: + explicit IdleInhibitManager(); + + IdleInhibitor* createIdleInhibitor(QtWaylandClient::QWaylandWindow* surface); + + static IdleInhibitManager* instance(); +}; + +class IdleInhibitor: public QtWayland::zwp_idle_inhibitor_v1 { +public: + explicit IdleInhibitor(::zwp_idle_inhibitor_v1* inhibitor) + : QtWayland::zwp_idle_inhibitor_v1(inhibitor) {} + + ~IdleInhibitor() override; + Q_DISABLE_COPY_MOVE(IdleInhibitor); +}; + +} // namespace qs::wayland::idle_inhibit::impl diff --git a/src/wayland/idle_inhibit/test/manual/idle_inhibit.qml b/src/wayland/idle_inhibit/test/manual/idle_inhibit.qml new file mode 100644 index 0000000..f80e647 --- /dev/null +++ b/src/wayland/idle_inhibit/test/manual/idle_inhibit.qml @@ -0,0 +1,20 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland + +FloatingWindow { + id: root + color: contentItem.palette.window + + CheckBox { + id: enableCb + anchors.centerIn: parent + text: "Enable Inhibitor" + } + + IdleInhibitor { + window: root + enabled: enableCb.checked + } +} diff --git a/src/wayland/idle_notify/CMakeLists.txt b/src/wayland/idle_notify/CMakeLists.txt new file mode 100644 index 0000000..889c7ce --- /dev/null +++ b/src/wayland/idle_notify/CMakeLists.txt @@ -0,0 +1,25 @@ +qt_add_library(quickshell-wayland-idle-notify STATIC + proto.cpp + monitor.cpp +) + +qt_add_qml_module(quickshell-wayland-idle-notify + URI Quickshell.Wayland._IdleNotify + VERSION 0.1 + DEPENDENCIES QtQuick +) + +install_qml_module(quickshell-wayland-idle-notify) + +qs_add_module_deps_light(quickshell-wayland-idle-notify Quickshell) + +wl_proto(wlp-idle-notify ext-idle-notify-v1 "${WAYLAND_PROTOCOLS}/staging/ext-idle-notify") + +target_link_libraries(quickshell-wayland-idle-notify PRIVATE + Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client + wlp-idle-notify +) + +qs_module_pch(quickshell-wayland-idle-notify SET large) + +target_link_libraries(quickshell PRIVATE quickshell-wayland-idle-notifyplugin) diff --git a/src/wayland/idle_notify/monitor.cpp b/src/wayland/idle_notify/monitor.cpp new file mode 100644 index 0000000..3f496e4 --- /dev/null +++ b/src/wayland/idle_notify/monitor.cpp @@ -0,0 +1,52 @@ +#include "monitor.hpp" +#include + +#include +#include +#include + +#include "proto.hpp" + +namespace qs::wayland::idle_notify { + +IdleMonitor::~IdleMonitor() { delete this->bNotification.value(); } + +void IdleMonitor::onPostReload() { + this->bParams.setBinding([this] { + return Params { + .enabled = this->bEnabled.value(), + .timeout = this->bTimeout.value(), + .respectInhibitors = this->bRespectInhibitors.value() + }; + }); + + this->bIsIdle.setBinding([this] { + auto* notification = this->bNotification.value(); + return notification ? notification->bIsIdle.value() : false; + }); +} + +void IdleMonitor::updateNotification() { + auto* notification = this->bNotification.value(); + delete notification; + notification = nullptr; + + auto guard = qScopeGuard([&, this] { this->bNotification = notification; }); + + auto params = this->bParams.value(); + + if (params.enabled) { + auto* manager = impl::IdleNotificationManager::instance(); + + if (!manager) { + qWarning() << "Cannot create idle monitor as ext-idle-notify-v1 is not supported by the " + "current compositor."; + return; + } + + auto timeout = static_cast(std::max(0, static_cast(params.timeout * 1000))); + notification = manager->createIdleNotification(timeout, params.respectInhibitors); + } +} + +} // namespace qs::wayland::idle_notify diff --git a/src/wayland/idle_notify/monitor.hpp b/src/wayland/idle_notify/monitor.hpp new file mode 100644 index 0000000..25bd5a6 --- /dev/null +++ b/src/wayland/idle_notify/monitor.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../../core/reload.hpp" +#include "proto.hpp" + +namespace qs::wayland::idle_notify { + +///! Provides a notification when a wayland session is makred idle +/// An idle monitor detects when the user stops providing input for a period of time. +/// +/// > [!NOTE] Using an idle monitor requires the compositor support the [ext-idle-notify-v1] protocol. +/// +/// [ext-idle-notify-v1]: https://wayland.app/protocols/ext-idle-notify-v1 +class IdleMonitor: public PostReloadHook { + Q_OBJECT; + QML_ELEMENT; + // clang-format off + /// If the idle monitor should be enabled. Defaults to true. + Q_PROPERTY(bool enabled READ isEnabled WRITE setEnabled NOTIFY enabledChanged); + /// The amount of time in seconds the idle monitor should wait before reporting an idle state. + /// + /// Defaults to zero, which reports idle status immediately. + Q_PROPERTY(qreal timeout READ default WRITE default NOTIFY timeoutChanged BINDABLE bindableTimeout); + /// When set to true, @@isIdle will depend on both user interaction and active idle inhibitors. + /// When false, the value will depend solely on user interaction. Defaults to true. + Q_PROPERTY(bool respectInhibitors READ default WRITE default NOTIFY respectInhibitorsChanged BINDABLE bindableRespectInhibitors); + /// This property is true if the user has been idle for at least @@timeout. + /// What is considered to be idle is influenced by @@respectInhibitors. + Q_PROPERTY(bool isIdle READ default NOTIFY isIdleChanged BINDABLE bindableIsIdle); + // clang-format on + +public: + IdleMonitor() = default; + ~IdleMonitor() override; + Q_DISABLE_COPY_MOVE(IdleMonitor); + + void onPostReload() override; + + [[nodiscard]] bool isEnabled() const { return this->bNotification.value(); } + void setEnabled(bool enabled) { this->bEnabled = enabled; } + + [[nodiscard]] QBindable bindableTimeout() { return &this->bTimeout; } + [[nodiscard]] QBindable bindableRespectInhibitors() { return &this->bRespectInhibitors; } + [[nodiscard]] QBindable bindableIsIdle() const { return &this->bIsIdle; } + +signals: + void enabledChanged(); + void timeoutChanged(); + void respectInhibitorsChanged(); + void isIdleChanged(); + +private: + void updateNotification(); + + struct Params { + bool enabled; + qreal timeout; + bool respectInhibitors; + }; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(IdleMonitor, bool, bEnabled, true, &IdleMonitor::enabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(IdleMonitor, qreal, bTimeout, &IdleMonitor::timeoutChanged); + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(IdleMonitor, bool, bRespectInhibitors, true, &IdleMonitor::respectInhibitorsChanged); + Q_OBJECT_BINDABLE_PROPERTY(IdleMonitor, Params, bParams, &IdleMonitor::updateNotification); + Q_OBJECT_BINDABLE_PROPERTY(IdleMonitor, impl::IdleNotification*, bNotification); + Q_OBJECT_BINDABLE_PROPERTY(IdleMonitor, bool, bIsIdle, &IdleMonitor::isIdleChanged); + // clang-format on +}; + +} // namespace qs::wayland::idle_notify diff --git a/src/wayland/idle_notify/proto.cpp b/src/wayland/idle_notify/proto.cpp new file mode 100644 index 0000000..9b3fa81 --- /dev/null +++ b/src/wayland/idle_notify/proto.cpp @@ -0,0 +1,75 @@ +#include "proto.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" + +namespace qs::wayland::idle_notify { +QS_LOGGING_CATEGORY(logIdleNotify, "quickshell.wayland.idle_notify", QtWarningMsg); +} + +namespace qs::wayland::idle_notify::impl { + +IdleNotificationManager::IdleNotificationManager(): QWaylandClientExtensionTemplate(2) { + this->initialize(); +} + +IdleNotificationManager* IdleNotificationManager::instance() { + static auto* instance = new IdleNotificationManager(); // NOLINT + return instance->isInitialized() ? instance : nullptr; +} + +IdleNotification* +IdleNotificationManager::createIdleNotification(quint32 timeout, bool respectInhibitors) { + if (!respectInhibitors + && this->QtWayland::ext_idle_notifier_v1::version() + < EXT_IDLE_NOTIFIER_V1_GET_INPUT_IDLE_NOTIFICATION_SINCE_VERSION) + { + qCWarning(logIdleNotify) << "Cannot ignore inhibitors for new idle notifier: Compositor does " + "not support protocol version 2."; + + respectInhibitors = true; + } + + auto* display = QtWaylandClient::QWaylandIntegration::instance()->display(); + auto* inputDevice = display->lastInputDevice(); + if (inputDevice == nullptr) inputDevice = display->defaultInputDevice(); + if (inputDevice == nullptr) { + qCCritical(logIdleNotify) << "Could not create idle notifier: No seat."; + return nullptr; + } + + ::ext_idle_notification_v1* notification = nullptr; + if (respectInhibitors) notification = this->get_idle_notification(timeout, inputDevice->object()); + else notification = this->get_input_idle_notification(timeout, inputDevice->object()); + + auto* wrapper = new IdleNotification(notification); + qCDebug(logIdleNotify) << "Created" << wrapper << "with timeout:" << timeout + << "respects inhibitors:" << respectInhibitors; + return wrapper; +} + +IdleNotification::~IdleNotification() { + qCDebug(logIdleNotify) << "Destroyed" << this; + if (this->isInitialized()) this->destroy(); +} + +void IdleNotification::ext_idle_notification_v1_idled() { + qCDebug(logIdleNotify) << this << "has been marked idle"; + this->bIsIdle = true; +} + +void IdleNotification::ext_idle_notification_v1_resumed() { + qCDebug(logIdleNotify) << this << "has been marked resumed"; + this->bIsIdle = false; +} + +} // namespace qs::wayland::idle_notify::impl diff --git a/src/wayland/idle_notify/proto.hpp b/src/wayland/idle_notify/proto.hpp new file mode 100644 index 0000000..11dbf29 --- /dev/null +++ b/src/wayland/idle_notify/proto.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" + +namespace qs::wayland::idle_notify { +QS_DECLARE_LOGGING_CATEGORY(logIdleNotify); +} + +namespace qs::wayland::idle_notify::impl { + +class IdleNotification; + +class IdleNotificationManager + : public QWaylandClientExtensionTemplate + , public QtWayland::ext_idle_notifier_v1 { +public: + explicit IdleNotificationManager(); + IdleNotification* createIdleNotification(quint32 timeout, bool respectInhibitors); + + static IdleNotificationManager* instance(); +}; + +class IdleNotification + : public QObject + , public QtWayland::ext_idle_notification_v1 { + Q_OBJECT; + +public: + explicit IdleNotification(::ext_idle_notification_v1* notification) + : QtWayland::ext_idle_notification_v1(notification) {} + + ~IdleNotification() override; + Q_DISABLE_COPY_MOVE(IdleNotification); + + Q_OBJECT_BINDABLE_PROPERTY(IdleNotification, bool, bIsIdle); + +protected: + void ext_idle_notification_v1_idled() override; + void ext_idle_notification_v1_resumed() override; +}; + +} // namespace qs::wayland::idle_notify::impl diff --git a/src/wayland/idle_notify/test/manual/idle_notify.qml b/src/wayland/idle_notify/test/manual/idle_notify.qml new file mode 100644 index 0000000..3bf6cbd --- /dev/null +++ b/src/wayland/idle_notify/test/manual/idle_notify.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland + +FloatingWindow { + color: contentItem.palette.window + + IdleMonitor { + id: monitor + enabled: enabledCb.checked + timeout: timeoutSb.value + respectInhibitors: respectInhibitorsCb.checked + } + + ColumnLayout { + Label { text: `Is idle? ${monitor.isIdle}` } + + CheckBox { + id: enabledCb + text: "Enabled" + checked: true + } + + CheckBox { + id: respectInhibitorsCb + text: "Respect Inhibitors" + checked: true + } + + RowLayout { + Label { text: "Timeout" } + + SpinBox { + id: timeoutSb + editable: true + from: 0 + to: 1000 + value: 5 + } + } + } +} diff --git a/src/wayland/init.cpp b/src/wayland/init.cpp index e56eee3..579e42a 100644 --- a/src/wayland/init.cpp +++ b/src/wayland/init.cpp @@ -10,6 +10,7 @@ #include "wlr_layershell/wlr_layershell.hpp" #endif +void installWlProxySafeDeref(); // NOLINT(misc-use-internal-linkage) void installPlatformMenuHook(); // NOLINT(misc-use-internal-linkage) void installPopupPositioner(); // NOLINT(misc-use-internal-linkage) @@ -32,6 +33,8 @@ class WaylandPlugin: public QsEnginePlugin { return isWayland; } + void preinit() override { installWlProxySafeDeref(); } + void init() override { installPlatformMenuHook(); installPopupPositioner(); diff --git a/src/wayland/module.md b/src/wayland/module.md index b9f8f59..964fa76 100644 --- a/src/wayland/module.md +++ b/src/wayland/module.md @@ -5,5 +5,9 @@ headers = [ "session_lock.hpp", "toplevel_management/qml.hpp", "screencopy/view.hpp", + "idle_inhibit/inhibitor.hpp", + "idle_notify/monitor.hpp", + "shortcuts_inhibit/inhibitor.hpp", + "background_effect/qml.hpp", ] ----- diff --git a/src/wayland/output_tracking.cpp b/src/wayland/output_tracking.cpp new file mode 100644 index 0000000..9d69ee7 --- /dev/null +++ b/src/wayland/output_tracking.cpp @@ -0,0 +1,73 @@ +#include "output_tracking.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qs::wayland { + +void WlOutputTracker::addOutput(::wl_output* output) { + auto* display = QtWaylandClient::QWaylandIntegration::instance()->display(); + + if (auto* platformScreen = display->screenForOutput(output)) { + auto* screen = platformScreen->screen(); + this->mScreens.append(screen); + emit this->screenAdded(screen); + } else { + QObject::connect( + static_cast(QGuiApplication::instance()), // NOLINT + &QGuiApplication::screenAdded, + this, + &WlOutputTracker::onQScreenAdded, + Qt::UniqueConnection + ); + + this->mOutputs.append(output); + } +} + +void WlOutputTracker::removeOutput(::wl_output* output) { + auto* display = QtWaylandClient::QWaylandIntegration::instance()->display(); + + if (auto* platformScreen = display->screenForOutput(output)) { + auto* screen = platformScreen->screen(); + this->mScreens.removeOne(screen); + emit this->screenRemoved(screen); + } else { + this->mOutputs.removeOne(output); + + if (this->mOutputs.isEmpty()) { + QObject::disconnect( + static_cast(QGuiApplication::instance()), // NOLINT + nullptr, + this, + nullptr + ); + } + } +} + +void WlOutputTracker::onQScreenAdded(QScreen* screen) { + if (auto* platformScreen = dynamic_cast(screen->handle())) { + if (this->mOutputs.removeOne(platformScreen->output())) { + this->mScreens.append(screen); + emit this->screenAdded(screen); + + if (this->mOutputs.isEmpty()) { + QObject::disconnect( + static_cast(QGuiApplication::instance()), // NOLINT + nullptr, + this, + nullptr + ); + } + } + } +} + +} // namespace qs::wayland diff --git a/src/wayland/output_tracking.hpp b/src/wayland/output_tracking.hpp new file mode 100644 index 0000000..988e53b --- /dev/null +++ b/src/wayland/output_tracking.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +struct wl_output; + +namespace qs::wayland { + +class WlOutputTracker: public QObject { + Q_OBJECT; + +public: + [[nodiscard]] const QList& screens() const { return this->mScreens; } + +signals: + void screenAdded(QScreen* screen); + void screenRemoved(QScreen* screen); + +public slots: + void addOutput(::wl_output* output); + void removeOutput(::wl_output* output); + +private slots: + void onQScreenAdded(QScreen* screen); + +private: + QList mScreens; + QList<::wl_output*> mOutputs; +}; + +} // namespace qs::wayland diff --git a/src/wayland/popupanchor.cpp b/src/wayland/popupanchor.cpp index cbbccae..14e1923 100644 --- a/src/wayland/popupanchor.cpp +++ b/src/wayland/popupanchor.cpp @@ -16,7 +16,6 @@ using XdgPositioner = QtWayland::xdg_positioner; using qs::wayland::xdg_shell::XdgWmBase; void WaylandPopupPositioner::reposition(PopupAnchor* anchor, QWindow* window, bool onlyIfDirty) { - auto* waylandWindow = dynamic_cast(window->handle()); auto* popupRole = waylandWindow ? waylandWindow->surfaceRole<::xdg_popup>() : nullptr; diff --git a/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.cpp b/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.cpp index 457f105..6fc2955 100644 --- a/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.cpp +++ b/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.cpp @@ -8,6 +8,7 @@ #include #include +#include "../../../core/logcat.hpp" #include "../../toplevel_management/handle.hpp" #include "../manager.hpp" #include "hyprland_screencopy_p.hpp" @@ -15,7 +16,7 @@ namespace qs::wayland::screencopy::hyprland { namespace { -Q_LOGGING_CATEGORY(logScreencopy, "quickshell.wayland.screencopy.hyprland", QtWarningMsg); +QS_LOGGING_CATEGORY(logScreencopy, "quickshell.wayland.screencopy.hyprland", QtWarningMsg); } HyprlandScreencopyManager::HyprlandScreencopyManager(): QWaylandClientExtensionTemplate(2) { @@ -63,6 +64,8 @@ void HyprlandScreencopyContext::onToplevelDestroyed() { void HyprlandScreencopyContext::captureFrame() { if (this->object()) return; + this->request.reset(); + this->init(this->manager->capture_toplevel_with_wlr_toplevel_handle( this->paintCursors ? 1 : 0, this->handle->object() @@ -100,6 +103,16 @@ void HyprlandScreencopyContext::hyprland_toplevel_export_frame_v1_flags(uint32_t void HyprlandScreencopyContext::hyprland_toplevel_export_frame_v1_buffer_done() { auto* backbuffer = this->mSwapchain.createBackbuffer(this->request); + + if (!backbuffer || !backbuffer->buffer()) { + qCWarning(logScreencopy) << "Backbuffer creation failed for screencopy. Skipping frame."; + + // Try again. This will be spammy if the compositor continuously sends bad frames. + this->destroy(); + this->captureFrame(); + return; + } + this->copy(backbuffer->buffer(), this->copiedFirstFrame ? 0 : 1); } diff --git a/src/wayland/screencopy/image_copy_capture/image_copy_capture.cpp b/src/wayland/screencopy/image_copy_capture/image_copy_capture.cpp index 649b111..13d1bc6 100644 --- a/src/wayland/screencopy/image_copy_capture/image_copy_capture.cpp +++ b/src/wayland/screencopy/image_copy_capture/image_copy_capture.cpp @@ -13,13 +13,14 @@ #include #include +#include "../../../core/logcat.hpp" #include "../manager.hpp" #include "image_copy_capture_p.hpp" namespace qs::wayland::screencopy::icc { namespace { -Q_LOGGING_CATEGORY(logIcc, "quickshell.wayland.screencopy.icc", QtWarningMsg); +QS_LOGGING_CATEGORY(logIcc, "quickshell.wayland.screencopy.icc", QtWarningMsg); } using IccCaptureSession = QtWayland::ext_image_copy_capture_session_v1; @@ -116,6 +117,12 @@ void IccScreencopyContext::doCapture() { auto newBuffer = false; auto* backbuffer = this->mSwapchain.createBackbuffer(this->request, &newBuffer); + if (!backbuffer || !backbuffer->buffer()) { + qCWarning(logIcc) << "Backbuffer creation failed for screencopy. Waiting for updated buffer " + "creation parameters before trying again."; + return; + } + this->IccCaptureFrame::init(this->IccCaptureSession::create_frame()); this->IccCaptureFrame::attach_buffer(backbuffer->buffer()); diff --git a/src/wayland/screencopy/view.cpp b/src/wayland/screencopy/view.cpp index aeafea6..7d10dc2 100644 --- a/src/wayland/screencopy/view.cpp +++ b/src/wayland/screencopy/view.cpp @@ -120,8 +120,14 @@ void ScreencopyView::captureFrame() { void ScreencopyView::onFrameCaptured() { this->setFlag(QQuickItem::ItemHasContents); this->update(); + + const auto& frontbuffer = this->context->swapchain().frontbuffer(); + + auto size = frontbuffer->size(); + if (frontbuffer->transform.flipSize()) size.transpose(); + + this->bSourceSize = size; this->bHasContent = true; - this->bSourceSize = this->context->swapchain().frontbuffer()->size(); } void ScreencopyView::componentComplete() { @@ -161,6 +167,7 @@ QSGNode* ScreencopyView::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* auto& swapchain = this->context->swapchain(); node->syncSwapchain(swapchain); node->setRect(this->boundingRect()); + node->setFiltering(QSGTexture::Linear); // NOLINT (misc-include-cleaner) if (this->mLive) this->context->captureFrame(); return node; diff --git a/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.cpp b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.cpp index 8cc89bc..927da8d 100644 --- a/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.cpp +++ b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.cpp @@ -1,15 +1,18 @@ #include "wlr_screencopy.hpp" #include +#include #include #include #include #include #include #include +#include #include #include +#include "../../../core/logcat.hpp" #include "../../buffer/manager.hpp" #include "../manager.hpp" #include "wlr_screencopy_p.hpp" @@ -17,7 +20,7 @@ namespace qs::wayland::screencopy::wlr { namespace { -Q_LOGGING_CATEGORY(logScreencopy, "quickshell.wayland.screencopy.wlr", QtWarningMsg); +QS_LOGGING_CATEGORY(logScreencopy, "quickshell.wayland.screencopy.wlr", QtWarningMsg); } WlrScreencopyManager::WlrScreencopyManager(): QWaylandClientExtensionTemplate(3) { @@ -45,6 +48,7 @@ WlrScreencopyContext::WlrScreencopyContext( , screen(dynamic_cast(screen->handle())) , paintCursors(paintCursors) , region(region) { + this->transform.setScreen(this->screen); QObject::connect(screen, &QObject::destroyed, this, &WlrScreencopyContext::onScreenDestroyed); } @@ -61,12 +65,14 @@ void WlrScreencopyContext::onScreenDestroyed() { void WlrScreencopyContext::captureFrame() { if (this->object()) return; + this->request.reset(); + if (this->region.isEmpty()) { - this->init(manager->capture_output(this->paintCursors ? 1 : 0, screen->output())); + this->init(this->manager->capture_output(this->paintCursors ? 1 : 0, this->screen->output())); } else { - this->init(manager->capture_output_region( + this->init(this->manager->capture_output_region( this->paintCursors ? 1 : 0, - screen->output(), + this->screen->output(), this->region.x(), this->region.y(), this->region.width(), @@ -99,14 +105,21 @@ void WlrScreencopyContext::zwlr_screencopy_frame_v1_linux_dmabuf( } void WlrScreencopyContext::zwlr_screencopy_frame_v1_flags(uint32_t flags) { - if (flags & ZWLR_SCREENCOPY_FRAME_V1_FLAGS_Y_INVERT) { - this->mSwapchain.backbuffer()->transform = buffer::WlBufferTransform::Flipped180; - } + this->yInvert = flags & ZWLR_SCREENCOPY_FRAME_V1_FLAGS_Y_INVERT; } void WlrScreencopyContext::zwlr_screencopy_frame_v1_buffer_done() { auto* backbuffer = this->mSwapchain.createBackbuffer(this->request); + if (!backbuffer || !backbuffer->buffer()) { + qCWarning(logScreencopy) << "Backbuffer creation failed for screencopy. Skipping frame."; + + // Try again. This will be spammy if the compositor continuously sends bad frames. + this->destroy(); + this->captureFrame(); + return; + } + if (this->copiedFirstFrame) { this->copy_with_damage(backbuffer->buffer()); } else { @@ -119,10 +132,7 @@ void WlrScreencopyContext::zwlr_screencopy_frame_v1_ready( uint32_t /*tvSecLo*/, uint32_t /*tvNsec*/ ) { - this->destroy(); - this->copiedFirstFrame = true; - this->mSwapchain.swapBuffers(); - emit this->frameCaptured(); + this->submitFrame(); } void WlrScreencopyContext::zwlr_screencopy_frame_v1_failed() { @@ -130,4 +140,62 @@ void WlrScreencopyContext::zwlr_screencopy_frame_v1_failed() { emit this->stopped(); } +void WlrScreencopyContext::updateTransform(bool previouslyUnset) { + if (previouslyUnset && this->copiedFirstFrame) this->submitFrame(); +} + +void WlrScreencopyContext::submitFrame() { + this->copiedFirstFrame = true; + if (this->transform.transform == -1) return; + + auto flipTransform = + this->yInvert ? buffer::WlBufferTransform::Flipped180 : buffer::WlBufferTransform::Normal0; + + this->mSwapchain.backbuffer()->transform = this->transform.transform ^ flipTransform; + + this->destroy(); + this->mSwapchain.swapBuffers(); + emit this->frameCaptured(); +} + +WlrScreencopyContext::OutputTransformQuery::OutputTransformQuery(WlrScreencopyContext* context) + : context(context) {} + +WlrScreencopyContext::OutputTransformQuery::~OutputTransformQuery() { + if (this->isInitialized()) this->release(); +} + +void WlrScreencopyContext::OutputTransformQuery::setScreen( + QtWaylandClient::QWaylandScreen* screen +) { + // cursed hack + class QWaylandScreenReflector: public QtWaylandClient::QWaylandScreen { + public: + [[nodiscard]] int globalId() const { return this->m_outputId; } + }; + + if (this->isInitialized()) this->release(); + + this->init( + screen->display()->wl_registry(), + static_cast(screen)->globalId(), // NOLINT + 3 + ); +} + +void WlrScreencopyContext::OutputTransformQuery::output_geometry( + qint32 /*x*/, + qint32 /*y*/, + qint32 /*width*/, + qint32 /*height*/, + qint32 /*subpixel*/, + const QString& /*make*/, + const QString& /*model*/, + qint32 transform +) { + auto newTransform = this->transform == -1; + this->transform = transform; + this->context->updateTransform(newTransform); +} + } // namespace qs::wayland::screencopy::wlr diff --git a/src/wayland/screencopy/wlr_screencopy/wlr_screencopy_p.hpp b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy_p.hpp index 7bdbafb..6e7620c 100644 --- a/src/wayland/screencopy/wlr_screencopy/wlr_screencopy_p.hpp +++ b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy_p.hpp @@ -1,7 +1,10 @@ #pragma once +#include #include +#include #include +#include #include #include "../manager.hpp" @@ -24,6 +27,7 @@ public: Q_DISABLE_COPY_MOVE(WlrScreencopyContext); void captureFrame() override; + void updateTransform(bool previouslyUnset); protected: // clang-format off @@ -39,9 +43,38 @@ private slots: void onScreenDestroyed(); private: + void submitFrame(); + + class OutputTransformQuery: public QtWayland::wl_output { + public: + OutputTransformQuery(WlrScreencopyContext* context); + ~OutputTransformQuery() override; + Q_DISABLE_COPY_MOVE(OutputTransformQuery); + + qint32 transform = -1; + void setScreen(QtWaylandClient::QWaylandScreen* screen); + + protected: + void output_geometry( + qint32 x, + qint32 y, + qint32 width, + qint32 height, + qint32 subpixel, + const QString& make, + const QString& model, + qint32 transform + ) override; + + private: + WlrScreencopyContext* context; + }; + WlrScreencopyManager* manager; buffer::WlBufferRequest request; bool copiedFirstFrame = false; + OutputTransformQuery transform {this}; + bool yInvert = false; QtWaylandClient::QWaylandScreen* screen; bool paintCursors; diff --git a/src/wayland/session_lock.cpp b/src/wayland/session_lock.cpp index 5f0bb67..2ebe3fd 100644 --- a/src/wayland/session_lock.cpp +++ b/src/wayland/session_lock.cpp @@ -1,5 +1,6 @@ #include "session_lock.hpp" +#include #include #include #include @@ -8,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +54,17 @@ void WlSessionLock::onReload(QObject* oldInstance) { void WlSessionLock::updateSurfaces(bool show, WlSessionLock* old) { auto screens = QGuiApplication::screens(); + screens.removeIf([](QScreen* screen) { + if (dynamic_cast(screen->handle()) == nullptr) { + qDebug() << "Not creating lock surface for screen" << screen + << "as it is not backed by a wayland screen."; + + return true; + } + + return false; + }); + auto map = this->surfaces.toStdMap(); for (auto& [screen, surface]: map) { if (!screens.contains(screen)) { @@ -67,8 +80,8 @@ void WlSessionLock::updateSurfaces(bool show, WlSessionLock* old) { auto* instance = qobject_cast(instanceObj); if (instance == nullptr) { - qWarning( - ) << "WlSessionLock.surface does not create a WlSessionLockSurface. Aborting lock."; + qWarning() + << "WlSessionLock.surface does not create a WlSessionLockSurface. Aborting lock."; if (instanceObj != nullptr) instanceObj->deleteLater(); this->unlock(); return; @@ -97,6 +110,13 @@ void WlSessionLock::updateSurfaces(bool show, WlSessionLock* old) { void WlSessionLock::realizeLockTarget(WlSessionLock* old) { if (this->lockTarget) { + if (!SessionLockManager::lockAvailable()) { + qCritical() << "Cannot start session lock: The current compositor does not support the " + "ext-session-lock-v1 protocol."; + this->unlock(); + return; + } + if (this->mSurfaceComponent == nullptr) { qWarning() << "WlSessionLock.surface is null. Aborting lock."; this->unlock(); @@ -197,6 +217,15 @@ void WlSessionLockSurface::onReload(QObject* oldInstance) { if (this->window == nullptr) { this->window = new QQuickWindow(); + + // needed for vulkan dmabuf import, qt ignores these if not applicable + auto graphicsConfig = this->window->graphicsConfiguration(); + graphicsConfig.setDeviceExtensions({ + "VK_KHR_external_memory_fd", + "VK_EXT_external_memory_dma_buf", + "VK_EXT_image_drm_format_modifier", + }); + this->window->setGraphicsConfiguration(graphicsConfig); } this->mContentItem->setParentItem(this->window->contentItem()); diff --git a/src/wayland/session_lock/session_lock.cpp b/src/wayland/session_lock/session_lock.cpp index 50e8818..c32dd90 100644 --- a/src/wayland/session_lock/session_lock.cpp +++ b/src/wayland/session_lock/session_lock.cpp @@ -22,6 +22,8 @@ QSWaylandSessionLockManager* manager() { } } // namespace +bool SessionLockManager::lockAvailable() { return manager()->isActive(); } + bool SessionLockManager::lock() { if (this->isLocked() || SessionLockManager::sessionLocked()) return false; this->mLock = manager()->acquireLock(); diff --git a/src/wayland/session_lock/session_lock.hpp b/src/wayland/session_lock/session_lock.hpp index 1ad6ae9..5a55896 100644 --- a/src/wayland/session_lock/session_lock.hpp +++ b/src/wayland/session_lock/session_lock.hpp @@ -15,6 +15,8 @@ class SessionLockManager: public QObject { public: explicit SessionLockManager(QObject* parent = nullptr): QObject(parent) {} + [[nodiscard]] static bool lockAvailable(); + // Returns true if a lock was acquired. // If true is returned the caller must watch the global screen list and create/destroy // windows with an attached LockWindowExtension to match it. diff --git a/src/wayland/session_lock/shell_integration.cpp b/src/wayland/session_lock/shell_integration.cpp index 2b5fdbf..207ef42 100644 --- a/src/wayland/session_lock/shell_integration.cpp +++ b/src/wayland/session_lock/shell_integration.cpp @@ -10,10 +10,11 @@ QtWaylandClient::QWaylandShellSurface* QSWaylandSessionLockIntegration::createShellSurface(QtWaylandClient::QWaylandWindow* window) { auto* lock = LockWindowExtension::get(window->window()); - if (lock == nullptr || lock->surface == nullptr || !lock->surface->isExposed()) { + if (lock == nullptr || lock->surface == nullptr) { qFatal() << "Visibility canary failed. A window with a LockWindowExtension MUST be set to " "visible via LockWindowExtension::setVisible"; } - return lock->surface; + QSWaylandSessionLockSurface* surface = lock->surface; // shut up the unused include linter + return surface; } diff --git a/src/wayland/session_lock/shell_integration.hpp b/src/wayland/session_lock/shell_integration.hpp index d6f9175..b2e2891 100644 --- a/src/wayland/session_lock/shell_integration.hpp +++ b/src/wayland/session_lock/shell_integration.hpp @@ -8,6 +8,6 @@ class QSWaylandSessionLockIntegration: public QtWaylandClient::QWaylandShellIntegration { public: bool initialize(QtWaylandClient::QWaylandDisplay* /* display */) override { return true; } - QtWaylandClient::QWaylandShellSurface* createShellSurface(QtWaylandClient::QWaylandWindow* window - ) override; + QtWaylandClient::QWaylandShellSurface* + createShellSurface(QtWaylandClient::QWaylandWindow* window) override; }; diff --git a/src/wayland/session_lock/surface.cpp b/src/wayland/session_lock/surface.cpp index 6b1f652..c73f459 100644 --- a/src/wayland/session_lock/surface.cpp +++ b/src/wayland/session_lock/surface.cpp @@ -28,7 +28,7 @@ QSWaylandSessionLockSurface::QSWaylandSessionLockSurface(QtWaylandClient::QWayla wl_output* output = nullptr; // NOLINT (include) auto* waylandScreen = dynamic_cast(qwindow->screen()->handle()); - if (waylandScreen != nullptr) { + if (waylandScreen != nullptr && !waylandScreen->isPlaceholder() && waylandScreen->output()) { output = waylandScreen->output(); } else { qFatal() << "Session lock screen does not corrospond to a real screen. Force closing window"; @@ -48,16 +48,6 @@ void QSWaylandSessionLockSurface::applyConfigure() { this->window()->resizeFromApplyConfigure(this->size); } -bool QSWaylandSessionLockSurface::handleExpose(const QRegion& region) { - if (this->initBuf != nullptr) { - // at this point qt's next commit to the surface will have a new buffer, and we can safely delete this one. - delete this->initBuf; - this->initBuf = nullptr; - } - - return this->QtWaylandClient::QWaylandShellSurface::handleExpose(region); -} - void QSWaylandSessionLockSurface::setExtension(LockWindowExtension* ext) { if (ext == nullptr) { if (this->window() != nullptr) this->window()->window()->close(); @@ -71,16 +61,13 @@ void QSWaylandSessionLockSurface::setExtension(LockWindowExtension* ext) { } } -void QSWaylandSessionLockSurface::setVisible() { - if (this->configured && !this->visible) this->initVisible(); - this->visible = true; -} - void QSWaylandSessionLockSurface::ext_session_lock_surface_v1_configure( quint32 serial, quint32 width, quint32 height ) { + if (!this->window()) return; + this->ack_configure(serial); this->size = QSize(static_cast(width), static_cast(height)); @@ -95,13 +82,41 @@ void QSWaylandSessionLockSurface::ext_session_lock_surface_v1_configure( #else this->window()->updateExposure(); #endif + +#if QT_VERSION < QT_VERSION_CHECK(6, 10, 0) if (this->visible) this->initVisible(); +#endif } else { // applyConfigureWhenPossible runs too late and causes a protocol error on reconfigure. this->window()->resizeFromApplyConfigure(this->size); } } +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + +bool QSWaylandSessionLockSurface::commitSurfaceRole() const { return false; } + +void QSWaylandSessionLockSurface::setVisible() { this->window()->window()->setVisible(true); } + +#else + +bool QSWaylandSessionLockSurface::handleExpose(const QRegion& region) { + if (this->initBuf != nullptr) { + // at this point qt's next commit to the surface will have a new buffer, and we can safely delete this one. + delete this->initBuf; + this->initBuf = nullptr; + } + + return this->QtWaylandClient::QWaylandShellSurface::handleExpose(region); +} + +void QSWaylandSessionLockSurface::setVisible() { + if (this->configured && !this->visible) this->initVisible(); + this->visible = true; +} + +#endif + #if QT_VERSION < QT_VERSION_CHECK(6, 9, 0) #include @@ -121,7 +136,7 @@ void QSWaylandSessionLockSurface::initVisible() { this->window()->window()->setVisible(true); } -#else +#elif QT_VERSION < QT_VERSION_CHECK(6, 10, 0) #include @@ -169,6 +184,7 @@ void QSWaylandSessionLockSurface::initVisible() { auto& surfacePointer = reinterpret_cast(this->window())->surfacePointer(); // Swap out the surface for a dummy during initWindow. + QT_WARNING_PUSH QT_WARNING_DISABLE_DEPRECATED // swap() { surfacePointer.swap(*tempSurface); diff --git a/src/wayland/session_lock/surface.hpp b/src/wayland/session_lock/surface.hpp index f7abc77..39be88d 100644 --- a/src/wayland/session_lock/surface.hpp +++ b/src/wayland/session_lock/surface.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -20,7 +21,12 @@ public: [[nodiscard]] bool isExposed() const override; void applyConfigure() override; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + [[nodiscard]] bool commitSurfaceRole() const override; +#else bool handleExpose(const QRegion& region) override; +#endif void setExtension(LockWindowExtension* ext); void setVisible(); @@ -29,11 +35,13 @@ private: void ext_session_lock_surface_v1_configure(quint32 serial, quint32 width, quint32 height) override; +#if QT_VERSION < QT_VERSION_CHECK(6, 10, 0) void initVisible(); + bool visible = false; + QtWaylandClient::QWaylandShmBuffer* initBuf = nullptr; +#endif LockWindowExtension* ext = nullptr; QSize size; bool configured = false; - bool visible = false; - QtWaylandClient::QWaylandShmBuffer* initBuf = nullptr; }; diff --git a/src/wayland/shortcuts_inhibit/CMakeLists.txt b/src/wayland/shortcuts_inhibit/CMakeLists.txt new file mode 100644 index 0000000..8dedd3d --- /dev/null +++ b/src/wayland/shortcuts_inhibit/CMakeLists.txt @@ -0,0 +1,25 @@ +qt_add_library(quickshell-wayland-shortcuts-inhibit STATIC + proto.cpp + inhibitor.cpp +) + +qt_add_qml_module(quickshell-wayland-shortcuts-inhibit + URI Quickshell.Wayland._ShortcutsInhibitor + VERSION 0.1 + DEPENDENCIES QtQuick +) + +install_qml_module(quickshell-wayland-shortcuts-inhibit) + +qs_add_module_deps_light(quickshell-wayland-shortcuts-inhibit Quickshell) + +wl_proto(wlp-shortcuts-inhibit keyboard-shortcuts-inhibit-unstable-v1 "${WAYLAND_PROTOCOLS}/unstable/keyboard-shortcuts-inhibit") + +target_link_libraries(quickshell-wayland-shortcuts-inhibit PRIVATE + Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client + wlp-shortcuts-inhibit +) + +qs_module_pch(quickshell-wayland-shortcuts-inhibit SET large) + +target_link_libraries(quickshell PRIVATE quickshell-wayland-shortcuts-inhibitplugin) \ No newline at end of file diff --git a/src/wayland/shortcuts_inhibit/inhibitor.cpp b/src/wayland/shortcuts_inhibit/inhibitor.cpp new file mode 100644 index 0000000..a91d5e2 --- /dev/null +++ b/src/wayland/shortcuts_inhibit/inhibitor.cpp @@ -0,0 +1,179 @@ +#include "inhibitor.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../window/proxywindow.hpp" +#include "proto.hpp" + +namespace qs::wayland::shortcuts_inhibit { +using QtWaylandClient::QWaylandWindow; + +ShortcutInhibitor::ShortcutInhibitor() { + this->bBoundWindow.setBinding([this] { + return this->bEnabled ? this->bWindowObject.value() : nullptr; + }); + + this->bActive.setBinding([this]() { + auto* inhibitor = this->bInhibitor.value(); + if (!inhibitor) return false; + if (!inhibitor->bindableActive().value()) return false; + return this->bWindow.value() == this->bFocusedWindow; + }); + + QObject::connect( + dynamic_cast(QGuiApplication::instance()), + &QGuiApplication::focusWindowChanged, + this, + &ShortcutInhibitor::onFocusedWindowChanged + ); + + this->onFocusedWindowChanged(QGuiApplication::focusWindow()); +} + +ShortcutInhibitor::~ShortcutInhibitor() { + if (!this->bInhibitor) return; + + auto* manager = impl::ShortcutsInhibitManager::instance(); + if (!manager) return; + + manager->unrefShortcutsInhibitor(this->bInhibitor); +} + +void ShortcutInhibitor::onBoundWindowChanged() { + auto* window = this->bBoundWindow.value(); + auto* proxyWindow = ProxyWindowBase::forObject(window); + if (proxyWindow == this->proxyWindow) return; + + if (this->proxyWindow) { + QObject::disconnect(this->proxyWindow, nullptr, this, nullptr); + this->proxyWindow = nullptr; + } + + if (this->mWaylandWindow) { + QObject::disconnect(this->mWaylandWindow, nullptr, this, nullptr); + this->mWaylandWindow = nullptr; + this->onWaylandSurfaceDestroyed(); + } + + if (proxyWindow) { + this->proxyWindow = proxyWindow; + + QObject::connect(proxyWindow, &QObject::destroyed, this, &ShortcutInhibitor::onWindowDestroyed); + + QObject::connect( + proxyWindow, + &ProxyWindowBase::backerVisibilityChanged, + this, + &ShortcutInhibitor::onWindowVisibilityChanged + ); + + this->onWindowVisibilityChanged(); + } +} + +void ShortcutInhibitor::onWindowDestroyed() { + this->proxyWindow = nullptr; + this->onWaylandSurfaceDestroyed(); +} + +void ShortcutInhibitor::onWindowVisibilityChanged() { + if (!this->proxyWindow->isVisibleDirect()) return; + + auto* window = this->proxyWindow->backingWindow(); + if (!window->handle()) window->create(); + + auto* waylandWindow = dynamic_cast(window->handle()); + if (!waylandWindow) { + qCCritical(impl::logShortcutsInhibit()) << "Window handle is not a QWaylandWindow"; + return; + } + if (waylandWindow == this->mWaylandWindow) return; + this->mWaylandWindow = waylandWindow; + this->bWindow = window; + + QObject::connect( + waylandWindow, + &QObject::destroyed, + this, + &ShortcutInhibitor::onWaylandWindowDestroyed + ); + + QObject::connect( + waylandWindow, + &QWaylandWindow::surfaceCreated, + this, + &ShortcutInhibitor::onWaylandSurfaceCreated + ); + + QObject::connect( + waylandWindow, + &QWaylandWindow::surfaceDestroyed, + this, + &ShortcutInhibitor::onWaylandSurfaceDestroyed + ); + + if (waylandWindow->surface()) this->onWaylandSurfaceCreated(); +} + +void ShortcutInhibitor::onWaylandWindowDestroyed() { this->mWaylandWindow = nullptr; } + +void ShortcutInhibitor::onWaylandSurfaceCreated() { + auto* manager = impl::ShortcutsInhibitManager::instance(); + + if (!manager) { + qWarning() << "Cannot enable shortcuts inhibitor as keyboard-shortcuts-inhibit-unstable-v1 is " + "not supported by " + "the current compositor."; + return; + } + + if (this->bInhibitor) { + qFatal("ShortcutsInhibitor: inhibitor already exists when creating surface"); + } + + this->bInhibitor = manager->createShortcutsInhibitor(this->mWaylandWindow); +} + +void ShortcutInhibitor::onWaylandSurfaceDestroyed() { + if (!this->bInhibitor) return; + + auto* manager = impl::ShortcutsInhibitManager::instance(); + if (!manager) return; + + manager->unrefShortcutsInhibitor(this->bInhibitor); + this->bInhibitor = nullptr; +} + +void ShortcutInhibitor::onInhibitorChanged() { + auto* inhibitor = this->bInhibitor.value(); + if (inhibitor) { + QObject::connect( + inhibitor, + &impl::ShortcutsInhibitor::activeChanged, + this, + &ShortcutInhibitor::onInhibitorActiveChanged + ); + } +} + +void ShortcutInhibitor::onInhibitorActiveChanged() { + auto* inhibitor = this->bInhibitor.value(); + if (inhibitor && !inhibitor->isActive()) { + // Compositor has deactivated the inhibitor, making it invalid. + // Set enabled to false so the user can enable it again to create a new inhibitor. + this->bEnabled = false; + emit this->cancelled(); + } +} + +void ShortcutInhibitor::onFocusedWindowChanged(QWindow* focusedWindow) { + this->bFocusedWindow = focusedWindow; +} + +} // namespace qs::wayland::shortcuts_inhibit diff --git a/src/wayland/shortcuts_inhibit/inhibitor.hpp b/src/wayland/shortcuts_inhibit/inhibitor.hpp new file mode 100644 index 0000000..2eed54f --- /dev/null +++ b/src/wayland/shortcuts_inhibit/inhibitor.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../../window/proxywindow.hpp" +#include "proto.hpp" + +namespace qs::wayland::shortcuts_inhibit { + +///! Prevents compositor keyboard shortcuts from being triggered +/// A shortcuts inhibitor prevents the compositor from processing its own keyboard shortcuts +/// for the focused surface. This allows applications to receive key events for shortcuts +/// that would normally be handled by the compositor. +/// +/// The inhibitor only takes effect when the associated window is focused and the inhibitor +/// is enabled. The compositor may choose to ignore inhibitor requests based on its policy. +/// +/// > [!NOTE] Using a shortcuts inhibitor requires the compositor support the [keyboard-shortcuts-inhibit-unstable-v1] protocol. +/// +/// [keyboard-shortcuts-inhibit-unstable-v1]: https://wayland.app/protocols/keyboard-shortcuts-inhibit-unstable-v1 +class ShortcutInhibitor: public QObject { + Q_OBJECT; + QML_ELEMENT; + // clang-format off + /// If the shortcuts inhibitor should be enabled. Defaults to false. + Q_PROPERTY(bool enabled READ default WRITE default NOTIFY enabledChanged BINDABLE bindableEnabled); + /// The window to associate the shortcuts inhibitor with. + /// The inhibitor will only inhibit shortcuts pressed while this window has keyboard focus. + /// + /// Must be set to a non null value to enable the inhibitor. + Q_PROPERTY(QObject* window READ default WRITE default NOTIFY windowChanged BINDABLE bindableWindow); + /// Whether the inhibitor is currently active. The inhibitor is only active if @@enabled is true, + /// @@window has keyboard focus, and the compositor grants the inhibit request. + /// + /// The compositor may deactivate the inhibitor at any time (for example, if the user requests + /// normal shortcuts to be restored). When deactivated by the compositor, the inhibitor cannot be + /// programmatically reactivated. + Q_PROPERTY(bool active READ default NOTIFY activeChanged BINDABLE bindableActive); + // clang-format on + +public: + ShortcutInhibitor(); + ~ShortcutInhibitor() override; + Q_DISABLE_COPY_MOVE(ShortcutInhibitor); + + [[nodiscard]] QBindable bindableEnabled() { return &this->bEnabled; } + [[nodiscard]] QBindable bindableWindow() { return &this->bWindowObject; } + [[nodiscard]] QBindable bindableActive() const { return &this->bActive; } + +signals: + void enabledChanged(); + void windowChanged(); + void activeChanged(); + /// Sent if the compositor cancels the inhibitor while it is active. + void cancelled(); + +private slots: + void onWindowDestroyed(); + void onWindowVisibilityChanged(); + void onWaylandWindowDestroyed(); + void onWaylandSurfaceCreated(); + void onWaylandSurfaceDestroyed(); + void onInhibitorActiveChanged(); + +private: + void onBoundWindowChanged(); + void onInhibitorChanged(); + void onFocusedWindowChanged(QWindow* focusedWindow); + + ProxyWindowBase* proxyWindow = nullptr; + QtWaylandClient::QWaylandWindow* mWaylandWindow = nullptr; + + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(ShortcutInhibitor, bool, bEnabled, &ShortcutInhibitor::enabledChanged); + Q_OBJECT_BINDABLE_PROPERTY(ShortcutInhibitor, QObject*, bWindowObject, &ShortcutInhibitor::windowChanged); + Q_OBJECT_BINDABLE_PROPERTY(ShortcutInhibitor, QObject*, bBoundWindow, &ShortcutInhibitor::onBoundWindowChanged); + Q_OBJECT_BINDABLE_PROPERTY(ShortcutInhibitor, impl::ShortcutsInhibitor*, bInhibitor, &ShortcutInhibitor::onInhibitorChanged); + Q_OBJECT_BINDABLE_PROPERTY(ShortcutInhibitor, QWindow*, bWindow); + Q_OBJECT_BINDABLE_PROPERTY(ShortcutInhibitor, QWindow*, bFocusedWindow); + Q_OBJECT_BINDABLE_PROPERTY(ShortcutInhibitor, bool, bActive, &ShortcutInhibitor::activeChanged); + // clang-format on +}; + +} // namespace qs::wayland::shortcuts_inhibit diff --git a/src/wayland/shortcuts_inhibit/proto.cpp b/src/wayland/shortcuts_inhibit/proto.cpp new file mode 100644 index 0000000..8d35d5e --- /dev/null +++ b/src/wayland/shortcuts_inhibit/proto.cpp @@ -0,0 +1,88 @@ +#include "proto.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" + +namespace qs::wayland::shortcuts_inhibit::impl { + +QS_LOGGING_CATEGORY(logShortcutsInhibit, "quickshell.wayland.shortcuts_inhibit", QtWarningMsg); + +ShortcutsInhibitManager::ShortcutsInhibitManager(): QWaylandClientExtensionTemplate(1) { + this->initialize(); +} + +ShortcutsInhibitManager* ShortcutsInhibitManager::instance() { + static auto* instance = new ShortcutsInhibitManager(); // NOLINT + return instance->isInitialized() ? instance : nullptr; +} + +ShortcutsInhibitor* +ShortcutsInhibitManager::createShortcutsInhibitor(QtWaylandClient::QWaylandWindow* surface) { + auto* display = QtWaylandClient::QWaylandIntegration::instance()->display(); + auto* inputDevice = display->lastInputDevice(); + if (inputDevice == nullptr) inputDevice = display->defaultInputDevice(); + + if (inputDevice == nullptr) { + qCCritical(logShortcutsInhibit) << "Could not create shortcuts inhibitor: No seat."; + return nullptr; + } + + auto* wlSurface = surface->surface(); + + if (this->inhibitors.contains(wlSurface)) { + auto& pair = this->inhibitors[wlSurface]; + pair.second++; + qCDebug(logShortcutsInhibit) << "Reusing existing inhibitor" << pair.first << "for surface" + << wlSurface << "- refcount:" << pair.second; + return pair.first; + } + + auto* inhibitor = + new ShortcutsInhibitor(this->inhibit_shortcuts(wlSurface, inputDevice->object()), wlSurface); + this->inhibitors.insert(wlSurface, qMakePair(inhibitor, 1)); + qCDebug(logShortcutsInhibit) << "Created inhibitor" << inhibitor << "for surface" << wlSurface; + return inhibitor; +} + +void ShortcutsInhibitManager::unrefShortcutsInhibitor(ShortcutsInhibitor* inhibitor) { + if (!inhibitor) return; + + auto* surface = inhibitor->surface(); + if (!this->inhibitors.contains(surface)) return; + + auto& pair = this->inhibitors[surface]; + pair.second--; + qCDebug(logShortcutsInhibit) << "Decremented refcount for inhibitor" << inhibitor + << "- refcount:" << pair.second; + + if (pair.second <= 0) { + qCDebug(logShortcutsInhibit) << "Refcount reached 0, destroying inhibitor" << inhibitor; + this->inhibitors.remove(surface); + delete inhibitor; + } +} + +ShortcutsInhibitor::~ShortcutsInhibitor() { + qCDebug(logShortcutsInhibit) << "Destroying inhibitor" << this << "for surface" << this->mSurface; + if (this->isInitialized()) this->destroy(); +} + +void ShortcutsInhibitor::zwp_keyboard_shortcuts_inhibitor_v1_active() { + qCDebug(logShortcutsInhibit) << "Inhibitor became active" << this; + this->bActive = true; +} + +void ShortcutsInhibitor::zwp_keyboard_shortcuts_inhibitor_v1_inactive() { + qCDebug(logShortcutsInhibit) << "Inhibitor became inactive" << this; + this->bActive = false; +} + +} // namespace qs::wayland::shortcuts_inhibit::impl \ No newline at end of file diff --git a/src/wayland/shortcuts_inhibit/proto.hpp b/src/wayland/shortcuts_inhibit/proto.hpp new file mode 100644 index 0000000..e79d5ca --- /dev/null +++ b/src/wayland/shortcuts_inhibit/proto.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "wayland-keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h" + +namespace qs::wayland::shortcuts_inhibit::impl { + +QS_DECLARE_LOGGING_CATEGORY(logShortcutsInhibit); + +class ShortcutsInhibitor; + +class ShortcutsInhibitManager + : public QWaylandClientExtensionTemplate + , public QtWayland::zwp_keyboard_shortcuts_inhibit_manager_v1 { +public: + explicit ShortcutsInhibitManager(); + + ShortcutsInhibitor* createShortcutsInhibitor(QtWaylandClient::QWaylandWindow* surface); + void unrefShortcutsInhibitor(ShortcutsInhibitor* inhibitor); + + static ShortcutsInhibitManager* instance(); + +private: + QHash> inhibitors; +}; + +class ShortcutsInhibitor + : public QObject + , public QtWayland::zwp_keyboard_shortcuts_inhibitor_v1 { + Q_OBJECT; + +public: + explicit ShortcutsInhibitor(::zwp_keyboard_shortcuts_inhibitor_v1* inhibitor, wl_surface* surface) + : QtWayland::zwp_keyboard_shortcuts_inhibitor_v1(inhibitor) + , mSurface(surface) + , bActive(false) {} + + ~ShortcutsInhibitor() override; + Q_DISABLE_COPY_MOVE(ShortcutsInhibitor); + + [[nodiscard]] QBindable bindableActive() const { return &this->bActive; } + [[nodiscard]] bool isActive() const { return this->bActive; } + [[nodiscard]] wl_surface* surface() const { return this->mSurface; } + +signals: + void activeChanged(); + +protected: + void zwp_keyboard_shortcuts_inhibitor_v1_active() override; + void zwp_keyboard_shortcuts_inhibitor_v1_inactive() override; + +private: + wl_surface* mSurface; + Q_OBJECT_BINDABLE_PROPERTY(ShortcutsInhibitor, bool, bActive, &ShortcutsInhibitor::activeChanged); +}; + +} // namespace qs::wayland::shortcuts_inhibit::impl \ No newline at end of file diff --git a/src/wayland/shortcuts_inhibit/test/manual/test.qml b/src/wayland/shortcuts_inhibit/test/manual/test.qml new file mode 100644 index 0000000..1f64cbf --- /dev/null +++ b/src/wayland/shortcuts_inhibit/test/manual/test.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland + +Scope { + Timer { + id: toggleTimer + interval: 100 + onTriggered: windowLoader.active = true + } + + LazyLoader { + id: windowLoader + active: true + + property bool enabled: false + + FloatingWindow { + id: w + color: contentItem.palette.window + + ColumnLayout { + anchors.centerIn: parent + + CheckBox { + id: loadedCb + text: "Loaded" + checked: true + } + + CheckBox { + id: enabledCb + text: "Enabled" + checked: windowLoader.enabled + onCheckedChanged: windowLoader.enabled = checked + } + + Label { + text: `Active: ${inhibitorLoader.item?.active ?? false}` + } + + Button { + text: "Toggle Window" + onClicked: { + windowLoader.active = false; + toggleTimer.start(); + } + } + } + + LazyLoader { + id: inhibitorLoader + active: loadedCb.checked + + ShortcutInhibitor { + window: w + enabled: enabledCb.checked + onCancelled: enabledCb.checked = false + } + } + } + } +} diff --git a/src/wayland/toplevel_management/handle.cpp b/src/wayland/toplevel_management/handle.cpp index 026b439..914e44e 100644 --- a/src/wayland/toplevel_management/handle.cpp +++ b/src/wayland/toplevel_management/handle.cpp @@ -23,7 +23,6 @@ namespace qs::wayland::toplevel_management::impl { QString ToplevelHandle::appId() const { return this->mAppId; } QString ToplevelHandle::title() const { return this->mTitle; } -QVector ToplevelHandle::visibleScreens() const { return this->mVisibleScreens; } ToplevelHandle* ToplevelHandle::parent() const { return this->mParent; } bool ToplevelHandle::activated() const { return this->mActivated; } bool ToplevelHandle::maximized() const { return this->mMaximized; } @@ -181,59 +180,13 @@ void ToplevelHandle::zwlr_foreign_toplevel_handle_v1_state(wl_array* stateArray) } void ToplevelHandle::zwlr_foreign_toplevel_handle_v1_output_enter(wl_output* output) { - auto* display = QtWaylandClient::QWaylandIntegration::instance()->display(); - - auto* platformScreen = display->screenForOutput(output); - if (!platformScreen) { - qCDebug(logToplevelManagement) << this << "got pending output enter" << output; - - if (this->mPendingVisibleScreens.isEmpty()) { - QObject::connect( - static_cast(QGuiApplication::instance()), // NOLINT - &QGuiApplication::screenAdded, - this, - &ToplevelHandle::onScreenAdded - ); - } - - this->mPendingVisibleScreens.append(output); - return; - } - - auto* screen = platformScreen->screen(); - - qCDebug(logToplevelManagement) << this << "got output enter" << screen; - - this->mVisibleScreens.append(screen); - emit this->visibleScreenAdded(screen); + qCDebug(logToplevelManagement) << this << "got output enter" << output; + this->visibleScreens.addOutput(output); } void ToplevelHandle::zwlr_foreign_toplevel_handle_v1_output_leave(wl_output* output) { - auto* display = QtWaylandClient::QWaylandIntegration::instance()->display(); - auto* platformScreen = display->screenForOutput(output); - - if (!this->mPendingVisibleScreens.isEmpty()) { - this->mPendingVisibleScreens.removeOne(output); - - if (this->mPendingVisibleScreens.isEmpty()) { - qCDebug(logToplevelManagement) << this << "got pending output leave" << output; - - QObject::disconnect( - static_cast(QGuiApplication::instance()), // NOLINT - nullptr, - this, - nullptr - ); - } - } - - if (!platformScreen) return; - auto* screen = platformScreen->screen(); - - qCDebug(logToplevelManagement) << this << "got output leave" << screen; - - this->mVisibleScreens.removeOne(screen); - emit this->visibleScreenRemoved(screen); + qCDebug(logToplevelManagement) << this << "got output leave" << output; + this->visibleScreens.removeOutput(output); } void ToplevelHandle::zwlr_foreign_toplevel_handle_v1_parent( @@ -262,26 +215,4 @@ void ToplevelHandle::onParentClosed() { emit this->parentChanged(); } -void ToplevelHandle::onScreenAdded(QScreen* screen) { - auto* waylandScreen = dynamic_cast(screen->handle()); - if (!waylandScreen) return; - - auto* output = waylandScreen->output(); - - if (this->mPendingVisibleScreens.removeOne(output)) { - qCDebug(logToplevelManagement) << this << "got pending entered output init" << screen; - this->mVisibleScreens.append(screen); - emit this->visibleScreenAdded(screen); - } - - if (this->mPendingVisibleScreens.isEmpty()) { - QObject::disconnect( - static_cast(QGuiApplication::instance()), // NOLINT - nullptr, - this, - nullptr - ); - } -} - } // namespace qs::wayland::toplevel_management::impl diff --git a/src/wayland/toplevel_management/handle.hpp b/src/wayland/toplevel_management/handle.hpp index 991069a..92531a5 100644 --- a/src/wayland/toplevel_management/handle.hpp +++ b/src/wayland/toplevel_management/handle.hpp @@ -6,6 +6,7 @@ #include #include +#include "../output_tracking.hpp" #include "wayland-wlr-foreign-toplevel-management-unstable-v1-client-protocol.h" namespace qs::wayland::toplevel_management::impl { @@ -18,7 +19,6 @@ class ToplevelHandle public: [[nodiscard]] QString appId() const; [[nodiscard]] QString title() const; - [[nodiscard]] QVector visibleScreens() const; [[nodiscard]] ToplevelHandle* parent() const; [[nodiscard]] bool activated() const; [[nodiscard]] bool maximized() const; @@ -32,6 +32,8 @@ public: void fullscreenOn(QScreen* screen); void setRectangle(QWindow* window, QRect rect); + WlOutputTracker visibleScreens; + signals: // sent after the first done event. void ready(); @@ -40,8 +42,6 @@ signals: void appIdChanged(); void titleChanged(); - void visibleScreenAdded(QScreen* screen); - void visibleScreenRemoved(QScreen* screen); void parentChanged(); void activatedChanged(); void maximizedChanged(); @@ -51,7 +51,6 @@ signals: private slots: void onParentClosed(); void onRectWindowDestroyed(); - void onScreenAdded(QScreen* screen); private: void zwlr_foreign_toplevel_handle_v1_done() override; @@ -66,8 +65,6 @@ private: bool isReady = false; QString mAppId; QString mTitle; - QVector mVisibleScreens; - QVector mPendingVisibleScreens; ToplevelHandle* mParent = nullptr; bool mActivated = false; bool mMaximized = false; diff --git a/src/wayland/toplevel_management/manager.cpp b/src/wayland/toplevel_management/manager.cpp index bd477b4..471d7e2 100644 --- a/src/wayland/toplevel_management/manager.cpp +++ b/src/wayland/toplevel_management/manager.cpp @@ -7,12 +7,13 @@ #include #include +#include "../../core/logcat.hpp" #include "handle.hpp" #include "wayland-wlr-foreign-toplevel-management-unstable-v1-client-protocol.h" namespace qs::wayland::toplevel_management::impl { -Q_LOGGING_CATEGORY(logToplevelManagement, "quickshell.wayland.toplevelManagement", QtWarningMsg); +QS_LOGGING_CATEGORY(logToplevelManagement, "quickshell.wayland.toplevelManagement", QtWarningMsg); ToplevelManager::ToplevelManager(): QWaylandClientExtensionTemplate(3) { this->initialize(); } diff --git a/src/wayland/toplevel_management/manager.hpp b/src/wayland/toplevel_management/manager.hpp index 41848de..83e3e09 100644 --- a/src/wayland/toplevel_management/manager.hpp +++ b/src/wayland/toplevel_management/manager.hpp @@ -6,13 +6,14 @@ #include #include +#include "../../core/logcat.hpp" #include "wayland-wlr-foreign-toplevel-management-unstable-v1-client-protocol.h" namespace qs::wayland::toplevel_management::impl { class ToplevelHandle; -Q_DECLARE_LOGGING_CATEGORY(logToplevelManagement); +QS_DECLARE_LOGGING_CATEGORY(logToplevelManagement); class ToplevelManager : public QWaylandClientExtensionTemplate @@ -32,8 +33,8 @@ signals: protected: explicit ToplevelManager(); - void zwlr_foreign_toplevel_manager_v1_toplevel(::zwlr_foreign_toplevel_handle_v1* toplevel - ) override; + void + zwlr_foreign_toplevel_manager_v1_toplevel(::zwlr_foreign_toplevel_handle_v1* toplevel) override; private slots: void onToplevelReady(); diff --git a/src/wayland/toplevel_management/qml.cpp b/src/wayland/toplevel_management/qml.cpp index 0d14d4d..cb53381 100644 --- a/src/wayland/toplevel_management/qml.cpp +++ b/src/wayland/toplevel_management/qml.cpp @@ -9,7 +9,7 @@ #include "../../core/qmlscreen.hpp" #include "../../core/util.hpp" #include "../../window/proxywindow.hpp" -#include "../../window/windowinterface.hpp" +#include "../output_tracking.hpp" #include "handle.hpp" #include "manager.hpp" @@ -22,8 +22,8 @@ Toplevel::Toplevel(impl::ToplevelHandle* handle, QObject* parent): QObject(paren QObject::connect(handle, &impl::ToplevelHandle::titleChanged, this, &Toplevel::titleChanged); QObject::connect(handle, &impl::ToplevelHandle::parentChanged, this, &Toplevel::parentChanged); QObject::connect(handle, &impl::ToplevelHandle::activatedChanged, this, &Toplevel::activatedChanged); - QObject::connect(handle, &impl::ToplevelHandle::visibleScreenAdded, this, &Toplevel::screensChanged); - QObject::connect(handle, &impl::ToplevelHandle::visibleScreenRemoved, this, &Toplevel::screensChanged); + QObject::connect(&handle->visibleScreens, &WlOutputTracker::screenAdded, this, &Toplevel::screensChanged); + QObject::connect(&handle->visibleScreens, &WlOutputTracker::screenRemoved, this, &Toplevel::screensChanged); QObject::connect(handle, &impl::ToplevelHandle::maximizedChanged, this, &Toplevel::maximizedChanged); QObject::connect(handle, &impl::ToplevelHandle::minimizedChanged, this, &Toplevel::minimizedChanged); QObject::connect(handle, &impl::ToplevelHandle::fullscreenChanged, this, &Toplevel::fullscreenChanged); @@ -50,7 +50,7 @@ bool Toplevel::activated() const { return this->handle->activated(); } QList Toplevel::screens() const { QList screens; - for (auto* screen: this->handle->visibleScreens()) { + for (auto* screen: this->handle->visibleScreens.screens()) { screens.push_back(QuickshellTracked::instance()->screenInfo(screen)); } @@ -72,13 +72,7 @@ void Toplevel::fullscreenOn(QuickshellScreenInfo* screen) { } void Toplevel::setRectangle(QObject* window, QRect rect) { - auto* proxyWindow = qobject_cast(window); - - if (proxyWindow == nullptr) { - if (auto* iface = qobject_cast(window)) { - proxyWindow = iface->proxyWindow(); - } - } + auto* proxyWindow = ProxyWindowBase::forObject(window); if (proxyWindow != this->rectWindow) { if (this->rectWindow != nullptr) { @@ -160,7 +154,11 @@ void ToplevelManager::onToplevelReady(impl::ToplevelHandle* handle) { void ToplevelManager::onToplevelActiveChanged() { auto* toplevel = qobject_cast(this->sender()); - if (toplevel->activated()) this->setActiveToplevel(toplevel); + if (toplevel->activated()) { + this->setActiveToplevel(toplevel); + } else if (toplevel == this->mActiveToplevel) { + this->setActiveToplevel(nullptr); + } } void ToplevelManager::onToplevelClosed() { diff --git a/src/wayland/windowmanager/CMakeLists.txt b/src/wayland/windowmanager/CMakeLists.txt new file mode 100644 index 0000000..76d1d89 --- /dev/null +++ b/src/wayland/windowmanager/CMakeLists.txt @@ -0,0 +1,19 @@ +qt_add_library(quickshell-wayland-windowsystem STATIC + windowmanager.cpp + windowset.cpp + ext_workspace.cpp +) + +add_library(quickshell-wayland-windowsystem-init OBJECT init.cpp) +target_link_libraries(quickshell-wayland-windowsystem-init PRIVATE Qt::Quick) + +wl_proto(wlp-ext-workspace ext-workspace-v1 "${WAYLAND_PROTOCOLS}/staging/ext-workspace") + +target_link_libraries(quickshell-wayland-windowsystem PRIVATE + Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client + wlp-ext-workspace +) + +qs_pch(quickshell-wayland-windowsystem SET large) + +target_link_libraries(quickshell PRIVATE quickshell-wayland-windowsystem quickshell-wayland-windowsystem-init) diff --git a/src/wayland/windowmanager/ext_workspace.cpp b/src/wayland/windowmanager/ext_workspace.cpp new file mode 100644 index 0000000..fcb9ffa --- /dev/null +++ b/src/wayland/windowmanager/ext_workspace.cpp @@ -0,0 +1,176 @@ +#include "ext_workspace.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" + +namespace qs::wayland::workspace { + +QS_LOGGING_CATEGORY(logWorkspace, "quickshell.wm.wayland.workspace", QtWarningMsg); + +WorkspaceManager::WorkspaceManager(): QWaylandClientExtensionTemplate(1) { this->initialize(); } + +WorkspaceManager* WorkspaceManager::instance() { + static auto* instance = new WorkspaceManager(); + return instance; +} + +void WorkspaceManager::ext_workspace_manager_v1_workspace_group( + ::ext_workspace_group_handle_v1* handle +) { + auto* group = new WorkspaceGroup(handle); + qCDebug(logWorkspace) << "Created group" << group; + this->mGroups.insert(handle, group); + emit this->groupCreated(group); +} + +void WorkspaceManager::ext_workspace_manager_v1_workspace(::ext_workspace_handle_v1* handle) { + auto* workspace = new Workspace(handle); + qCDebug(logWorkspace) << "Created workspace" << workspace; + this->mWorkspaces.insert(handle, workspace); + emit this->workspaceCreated(workspace); +}; + +void WorkspaceManager::destroyWorkspace(Workspace* workspace) { + this->mWorkspaces.remove(workspace->object()); + this->destroyedWorkspaces.append(workspace); + emit this->workspaceDestroyed(workspace); +} + +void WorkspaceManager::destroyGroup(WorkspaceGroup* group) { + this->mGroups.remove(group->object()); + this->destroyedGroups.append(group); + emit this->groupDestroyed(group); +} + +void WorkspaceManager::ext_workspace_manager_v1_done() { + qCDebug(logWorkspace) << "Workspace changes done"; + emit this->serverCommit(); + + for (auto* workspace: this->destroyedWorkspaces) delete workspace; + for (auto* group: this->destroyedGroups) delete group; + this->destroyedWorkspaces.clear(); + this->destroyedGroups.clear(); +} + +void WorkspaceManager::ext_workspace_manager_v1_finished() { + qCWarning(logWorkspace) << "ext_workspace_manager_v1.finished() was received"; +} + +Workspace::~Workspace() { + if (this->isInitialized()) this->destroy(); +} + +void Workspace::ext_workspace_handle_v1_id(const QString& id) { + qCDebug(logWorkspace) << "Updated id for workspace" << this << "to" << id; + this->id = id; +} + +void Workspace::ext_workspace_handle_v1_name(const QString& name) { + qCDebug(logWorkspace) << "Updated name for workspace" << this << "to" << name; + this->name = name; +} + +void Workspace::ext_workspace_handle_v1_coordinates(wl_array* coordinates) { + this->coordinates.clear(); + + auto* data = static_cast(coordinates->data); + auto size = static_cast(coordinates->size / sizeof(qint32)); + + for (auto i = 0; i != size; ++i) { + this->coordinates.append(data[i]); // NOLINT + } + + qCDebug(logWorkspace) << "Updated coordinates for workspace" << this << "to" << this->coordinates; +} + +void Workspace::ext_workspace_handle_v1_state(quint32 state) { + this->active = state & ext_workspace_handle_v1::state_active; + this->urgent = state & ext_workspace_handle_v1::state_urgent; + this->hidden = state & ext_workspace_handle_v1::state_hidden; + + qCDebug(logWorkspace).nospace() << "Updated state for workspace " << this + << " to [active: " << this->active << ", urgent: " << this->urgent + << ", hidden: " << this->hidden << ']'; +} + +void Workspace::ext_workspace_handle_v1_capabilities(quint32 capabilities) { + this->canActivate = capabilities & ext_workspace_handle_v1::workspace_capabilities_activate; + this->canDeactivate = capabilities & ext_workspace_handle_v1::workspace_capabilities_deactivate; + this->canRemove = capabilities & ext_workspace_handle_v1::workspace_capabilities_remove; + this->canAssign = capabilities & ext_workspace_handle_v1::workspace_capabilities_assign; + + qCDebug(logWorkspace).nospace() << "Updated capabilities for workspace " << this + << " to [activate: " << this->canActivate + << ", deactivate: " << this->canDeactivate + << ", remove: " << this->canRemove + << ", assign: " << this->canAssign << ']'; +} + +void Workspace::ext_workspace_handle_v1_removed() { + qCDebug(logWorkspace) << "Destroyed workspace" << this; + WorkspaceManager::instance()->destroyWorkspace(this); + this->destroy(); +} + +void Workspace::enterGroup(WorkspaceGroup* group) { this->group = group; } + +void Workspace::leaveGroup(WorkspaceGroup* group) { + if (this->group == group) this->group = nullptr; +} + +WorkspaceGroup::~WorkspaceGroup() { + if (this->isInitialized()) this->destroy(); +} + +void WorkspaceGroup::ext_workspace_group_handle_v1_capabilities(quint32 capabilities) { + this->canCreateWorkspace = + capabilities & ext_workspace_group_handle_v1::group_capabilities_create_workspace; + + qCDebug(logWorkspace).nospace() << "Updated capabilities for group " << this + << " to [create_workspace: " << this->canCreateWorkspace << ']'; +} + +void WorkspaceGroup::ext_workspace_group_handle_v1_output_enter(::wl_output* output) { + qCDebug(logWorkspace) << "Output" << output << "added to group" << this; + this->screens.addOutput(output); +} + +void WorkspaceGroup::ext_workspace_group_handle_v1_output_leave(::wl_output* output) { + qCDebug(logWorkspace) << "Output" << output << "removed from group" << this; + this->screens.removeOutput(output); +} + +void WorkspaceGroup::ext_workspace_group_handle_v1_workspace_enter( + ::ext_workspace_handle_v1* handle +) { + auto* workspace = WorkspaceManager::instance()->mWorkspaces.value(handle); + qCDebug(logWorkspace) << "Workspace" << workspace << "added to group" << this; + + if (workspace) workspace->enterGroup(this); +} + +void WorkspaceGroup::ext_workspace_group_handle_v1_workspace_leave( + ::ext_workspace_handle_v1* handle +) { + auto* workspace = WorkspaceManager::instance()->mWorkspaces.value(handle); + qCDebug(logWorkspace) << "Workspace" << workspace << "removed from group" << this; + + if (workspace) workspace->leaveGroup(this); +} + +void WorkspaceGroup::ext_workspace_group_handle_v1_removed() { + qCDebug(logWorkspace) << "Destroyed group" << this; + WorkspaceManager::instance()->destroyGroup(this); + this->destroy(); +} + +} // namespace qs::wayland::workspace diff --git a/src/wayland/windowmanager/ext_workspace.hpp b/src/wayland/windowmanager/ext_workspace.hpp new file mode 100644 index 0000000..6aff209 --- /dev/null +++ b/src/wayland/windowmanager/ext_workspace.hpp @@ -0,0 +1,117 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/logcat.hpp" +#include "../output_tracking.hpp" + +namespace qs::wayland::workspace { + +QS_DECLARE_LOGGING_CATEGORY(logWorkspace); + +class WorkspaceGroup; +class Workspace; + +class WorkspaceManager + : public QWaylandClientExtensionTemplate + , public QtWayland::ext_workspace_manager_v1 { + Q_OBJECT; + +public: + static WorkspaceManager* instance(); + + [[nodiscard]] QList workspaces() { return this->mWorkspaces.values(); } + +signals: + void serverCommit(); + void workspaceCreated(Workspace* workspace); + void workspaceDestroyed(Workspace* workspace); + void groupCreated(WorkspaceGroup* group); + void groupDestroyed(WorkspaceGroup* group); + +protected: + void ext_workspace_manager_v1_workspace_group(::ext_workspace_group_handle_v1* handle) override; + void ext_workspace_manager_v1_workspace(::ext_workspace_handle_v1* handle) override; + void ext_workspace_manager_v1_done() override; + void ext_workspace_manager_v1_finished() override; + +private: + WorkspaceManager(); + + void destroyGroup(WorkspaceGroup* group); + void destroyWorkspace(Workspace* workspace); + + QHash<::ext_workspace_handle_v1*, Workspace*> mWorkspaces; + QHash<::ext_workspace_group_handle_v1*, WorkspaceGroup*> mGroups; + QList destroyedGroups; + QList destroyedWorkspaces; + + friend class Workspace; + friend class WorkspaceGroup; +}; + +class Workspace: public QtWayland::ext_workspace_handle_v1 { +public: + Workspace(::ext_workspace_handle_v1* handle): QtWayland::ext_workspace_handle_v1(handle) {} + ~Workspace() override; + Q_DISABLE_COPY_MOVE(Workspace); + + QString id; + QString name; + QList coordinates; + WorkspaceGroup* group = nullptr; + + bool active : 1 = false; + bool urgent : 1 = false; + bool hidden : 1 = false; + + bool canActivate : 1 = false; + bool canDeactivate : 1 = false; + bool canRemove : 1 = false; + bool canAssign : 1 = false; + +protected: + void ext_workspace_handle_v1_id(const QString& id) override; + void ext_workspace_handle_v1_name(const QString& name) override; + void ext_workspace_handle_v1_coordinates(wl_array* coordinates) override; + void ext_workspace_handle_v1_state(quint32 state) override; + void ext_workspace_handle_v1_capabilities(quint32 capabilities) override; + void ext_workspace_handle_v1_removed() override; + +private: + void enterGroup(WorkspaceGroup* group); + void leaveGroup(WorkspaceGroup* group); + + friend class WorkspaceGroup; +}; + +class WorkspaceGroup: public QtWayland::ext_workspace_group_handle_v1 { +public: + WorkspaceGroup(::ext_workspace_group_handle_v1* handle) + : QtWayland::ext_workspace_group_handle_v1(handle) {} + + ~WorkspaceGroup() override; + Q_DISABLE_COPY_MOVE(WorkspaceGroup); + + WlOutputTracker screens; + bool canCreateWorkspace : 1 = false; + +protected: + void ext_workspace_group_handle_v1_capabilities(quint32 capabilities) override; + void ext_workspace_group_handle_v1_output_enter(::wl_output* output) override; + void ext_workspace_group_handle_v1_output_leave(::wl_output* output) override; + void ext_workspace_group_handle_v1_workspace_enter(::ext_workspace_handle_v1* handle) override; + void ext_workspace_group_handle_v1_workspace_leave(::ext_workspace_handle_v1* handle) override; + void ext_workspace_group_handle_v1_removed() override; +}; + +} // namespace qs::wayland::workspace diff --git a/src/wayland/windowmanager/init.cpp b/src/wayland/windowmanager/init.cpp new file mode 100644 index 0000000..88be01a --- /dev/null +++ b/src/wayland/windowmanager/init.cpp @@ -0,0 +1,23 @@ +#include +#include +#include + +#include "../../core/plugin.hpp" + +namespace qs::wm::wayland { +void installWmProvider(); +} + +namespace { + +class WaylandWmPlugin: public QsEnginePlugin { + QList dependencies() override { return {"window"}; } + + bool applies() override { return QGuiApplication::platformName() == "wayland"; } + + void init() override { qs::wm::wayland::installWmProvider(); } +}; + +QS_REGISTER_PLUGIN(WaylandWmPlugin); + +} // namespace diff --git a/src/wayland/windowmanager/windowmanager.cpp b/src/wayland/windowmanager/windowmanager.cpp new file mode 100644 index 0000000..16245d0 --- /dev/null +++ b/src/wayland/windowmanager/windowmanager.cpp @@ -0,0 +1,21 @@ +#include "windowmanager.hpp" + +#include "../../windowmanager/windowmanager.hpp" +#include "windowset.hpp" + +namespace qs::wm::wayland { + +WaylandWindowManager* WaylandWindowManager::instance() { + static auto* instance = []() { + auto* wm = new WaylandWindowManager(); + WindowsetManager::instance(); + return wm; + }(); + return instance; +} + +void installWmProvider() { // NOLINT (misc-use-internal-linkage) + qs::wm::WindowManager::setProvider([]() { return WaylandWindowManager::instance(); }); +} + +} // namespace qs::wm::wayland diff --git a/src/wayland/windowmanager/windowmanager.hpp b/src/wayland/windowmanager/windowmanager.hpp new file mode 100644 index 0000000..9d48efd --- /dev/null +++ b/src/wayland/windowmanager/windowmanager.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "../../windowmanager/windowmanager.hpp" +#include "windowset.hpp" + +namespace qs::wm::wayland { + +class WaylandWindowManager: public WindowManager { + Q_OBJECT; + +public: + static WaylandWindowManager* instance(); +}; + +} // namespace qs::wm::wayland diff --git a/src/wayland/windowmanager/windowset.cpp b/src/wayland/windowmanager/windowset.cpp new file mode 100644 index 0000000..74e273d --- /dev/null +++ b/src/wayland/windowmanager/windowset.cpp @@ -0,0 +1,252 @@ +#include "windowset.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../windowmanager/screenprojection.hpp" +#include "../../windowmanager/windowmanager.hpp" +#include "../../windowmanager/windowset.hpp" +#include "ext_workspace.hpp" + +namespace qs::wm::wayland { + +WindowsetManager::WindowsetManager() { + auto* impl = impl::WorkspaceManager::instance(); + + QObject::connect( + impl, + &impl::WorkspaceManager::serverCommit, + this, + &WindowsetManager::onServerCommit + ); + + QObject::connect( + impl, + &impl::WorkspaceManager::workspaceCreated, + this, + &WindowsetManager::onWindowsetCreated + ); + + QObject::connect( + impl, + &impl::WorkspaceManager::workspaceDestroyed, + this, + &WindowsetManager::onWindowsetDestroyed + ); + + QObject::connect( + impl, + &impl::WorkspaceManager::groupCreated, + this, + &WindowsetManager::onProjectionCreated + ); + + QObject::connect( + impl, + &impl::WorkspaceManager::groupDestroyed, + this, + &WindowsetManager::onProjectionDestroyed + ); +} + +void WindowsetManager::scheduleCommit() { + if (this->commitScheduled) { + qCDebug(impl::logWorkspace) << "Workspace commit already scheduled."; + return; + } + + qCDebug(impl::logWorkspace) << "Scheduling workspace commit..."; + this->commitScheduled = true; + QMetaObject::invokeMethod(this, &WindowsetManager::doCommit, Qt::QueuedConnection); +} + +void WindowsetManager::doCommit() { // NOLINT + qCDebug(impl::logWorkspace) << "Committing workspaces..."; + impl::WorkspaceManager::instance()->commit(); + this->commitScheduled = false; +} + +void WindowsetManager::onServerCommit() { + // Projections are created/destroyed around windowsets to avoid any nulls making it + // to the qml engine. + + Qt::beginPropertyUpdateGroup(); + + auto* wm = WindowManager::instance(); + auto windowsets = wm->bWindowsets.value(); + auto projections = wm->bWindowsetProjections.value(); + + for (auto* projImpl: this->pendingProjectionCreations) { + auto* projection = new WlWindowsetProjection(this, projImpl); + this->projectionsByImpl.insert(projImpl, projection); + projections.append(projection); + } + + for (auto* wsImpl: this->pendingWindowsetCreations) { + auto* ws = new WlWindowset(this, wsImpl); + this->windowsetByImpl.insert(wsImpl, ws); + windowsets.append(ws); + } + + for (auto* wsImpl: this->pendingWindowsetDestructions) { + windowsets.removeOne(this->windowsetByImpl.value(wsImpl)); + this->windowsetByImpl.remove(wsImpl); + } + + for (auto* projImpl: this->pendingProjectionDestructions) { + projections.removeOne(this->projectionsByImpl.value(projImpl)); + this->projectionsByImpl.remove(projImpl); + } + + for (auto* ws: windowsets) { + static_cast(ws)->commitImpl(); // NOLINT + } + + for (auto* projection: projections) { + static_cast(projection)->commitImpl(); // NOLINT + } + + this->pendingWindowsetCreations.clear(); + this->pendingWindowsetDestructions.clear(); + this->pendingProjectionCreations.clear(); + this->pendingProjectionDestructions.clear(); + + wm->bWindowsets = windowsets; + wm->bWindowsetProjections = projections; + + Qt::endPropertyUpdateGroup(); +} + +void WindowsetManager::onWindowsetCreated(impl::Workspace* workspace) { + this->pendingWindowsetCreations.append(workspace); +} + +void WindowsetManager::onWindowsetDestroyed(impl::Workspace* workspace) { + if (!this->pendingWindowsetCreations.removeOne(workspace)) { + this->pendingWindowsetDestructions.append(workspace); + } +} + +void WindowsetManager::onProjectionCreated(impl::WorkspaceGroup* group) { + this->pendingProjectionCreations.append(group); +} + +void WindowsetManager::onProjectionDestroyed(impl::WorkspaceGroup* group) { + if (!this->pendingProjectionCreations.removeOne(group)) { + this->pendingProjectionDestructions.append(group); + } +} + +WindowsetManager* WindowsetManager::instance() { + static auto* instance = new WindowsetManager(); + return instance; +} + +WlWindowset::WlWindowset(WindowsetManager* manager, impl::Workspace* impl) + : Windowset(manager) + , impl(impl) { + this->commitImpl(); +} + +void WlWindowset::commitImpl() { + Qt::beginPropertyUpdateGroup(); + this->bId = this->impl->id; + this->bName = this->impl->name; + this->bCoordinates = this->impl->coordinates; + this->bActive = this->impl->active; + this->bShouldDisplay = !this->impl->hidden; + this->bUrgent = this->impl->urgent; + this->bCanActivate = this->impl->canActivate; + this->bCanDeactivate = this->impl->canDeactivate; + this->bCanSetProjection = this->impl->canAssign; + this->bProjection = this->manager()->projectionsByImpl.value(this->impl->group); + Qt::endPropertyUpdateGroup(); +} + +void WlWindowset::activate() { + if (!this->bCanActivate) { + qCritical(logWorkspace) << this << "cannot be activated"; + return; + } + + qCDebug(impl::logWorkspace) << "Calling activate() for" << this; + this->impl->activate(); + WindowsetManager::instance()->scheduleCommit(); +} + +void WlWindowset::deactivate() { + if (!this->bCanDeactivate) { + qCritical(logWorkspace) << this << "cannot be deactivated"; + return; + } + + qCDebug(impl::logWorkspace) << "Calling deactivate() for" << this; + this->impl->deactivate(); + WindowsetManager::instance()->scheduleCommit(); +} + +void WlWindowset::remove() { + if (!this->bCanRemove) { + qCritical(logWorkspace) << this << "cannot be removed"; + return; + } + + qCDebug(impl::logWorkspace) << "Calling remove() for" << this; + this->impl->remove(); + WindowsetManager::instance()->scheduleCommit(); +} + +void WlWindowset::setProjection(WindowsetProjection* projection) { + if (!this->bCanSetProjection) { + qCritical(logWorkspace) << this << "cannot be assigned to a projection"; + return; + } + + if (!projection) { + qCritical(logWorkspace) << "Cannot set a windowset's projection to null"; + return; + } + + WlWindowsetProjection* wlProjection = nullptr; + if (auto* p = dynamic_cast(projection)) { + wlProjection = p; + } else if (auto* p = dynamic_cast(projection)) { + // In the 99% case, there will only be a single windowset on a screen. + // In the 1% case, the oldest projection (first in list) is most likely the desired one. + auto* screen = p->screen(); + for (const auto& proj: WindowsetManager::instance()->projectionsByImpl.values()) { + if (proj->bQScreens.value().contains(screen)) { + wlProjection = proj; + break; + } + } + } + + if (!wlProjection) { + qCritical(logWorkspace) << "Cannot set a windowset's projection to" << projection + << "as no wayland projection could be derived."; + return; + } + + qCDebug(impl::logWorkspace) << "Assigning" << this << "to" << projection; + this->impl->assign(wlProjection->impl->object()); + WindowsetManager::instance()->scheduleCommit(); +} + +WlWindowsetProjection::WlWindowsetProjection(WindowsetManager* manager, impl::WorkspaceGroup* impl) + : WindowsetProjection(manager) + , impl(impl) { + this->commitImpl(); +} + +void WlWindowsetProjection::commitImpl() { + // TODO: will not commit the correct screens if missing qt repr at commit time + this->bQScreens = this->impl->screens.screens(); +} + +} // namespace qs::wm::wayland diff --git a/src/wayland/windowmanager/windowset.hpp b/src/wayland/windowmanager/windowset.hpp new file mode 100644 index 0000000..52d1c63 --- /dev/null +++ b/src/wayland/windowmanager/windowset.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../../windowmanager/windowset.hpp" +#include "ext_workspace.hpp" + +namespace qs::wm::wayland { +namespace impl = qs::wayland::workspace; + +class WlWindowset; +class WlWindowsetProjection; + +class WindowsetManager: public QObject { + Q_OBJECT; + +public: + static WindowsetManager* instance(); + + void scheduleCommit(); + +private slots: + void doCommit(); + void onServerCommit(); + void onWindowsetCreated(impl::Workspace* workspace); + void onWindowsetDestroyed(impl::Workspace* workspace); + void onProjectionCreated(impl::WorkspaceGroup* group); + void onProjectionDestroyed(impl::WorkspaceGroup* group); + +private: + WindowsetManager(); + + bool commitScheduled = false; + + QList pendingWindowsetCreations; + QList pendingWindowsetDestructions; + QHash windowsetByImpl; + + QList pendingProjectionCreations; + QList pendingProjectionDestructions; + QHash projectionsByImpl; + + friend class WlWindowset; +}; + +class WlWindowset: public Windowset { +public: + WlWindowset(WindowsetManager* manager, impl::Workspace* impl); + + void commitImpl(); + + void activate() override; + void deactivate() override; + void remove() override; + void setProjection(WindowsetProjection* projection) override; + + [[nodiscard]] WindowsetManager* manager() { + return static_cast(this->parent()); // NOLINT + } + +private: + impl::Workspace* impl = nullptr; +}; + +class WlWindowsetProjection: public WindowsetProjection { +public: + WlWindowsetProjection(WindowsetManager* manager, impl::WorkspaceGroup* impl); + + void commitImpl(); + + [[nodiscard]] WindowsetManager* manager() { + return static_cast(this->parent()); // NOLINT + } + +private: + impl::WorkspaceGroup* impl = nullptr; + + friend class WlWindowset; +}; + +} // namespace qs::wm::wayland diff --git a/src/wayland/wl_proxy_safe_deref.cpp b/src/wayland/wl_proxy_safe_deref.cpp new file mode 100644 index 0000000..2664a99 --- /dev/null +++ b/src/wayland/wl_proxy_safe_deref.cpp @@ -0,0 +1,41 @@ +#include +#include +#include +#include +#include + +#include "../core/logcat.hpp" + +namespace { +QS_LOGGING_CATEGORY(logDeref, "quickshell.wayland.safederef", QtWarningMsg); +using wl_proxy_get_listener_t = const void* (*) (wl_proxy*); +wl_proxy_get_listener_t original_wl_proxy_get_listener = nullptr; // NOLINT +} // namespace + +extern "C" { +WL_EXPORT const void* wl_proxy_get_listener(struct wl_proxy* proxy) { + // Avoid null derefs of protocol objects in qtbase. + // https://qt-project.atlassian.net/browse/QTBUG-145022 + if (!proxy) [[unlikely]] { + qCCritical(logDeref) << "wl_proxy_get_listener called with a null proxy!"; + return nullptr; + } + + return original_wl_proxy_get_listener(proxy); +} +} + +// NOLINTBEGIN (concurrency-mt-unsafe) +void installWlProxySafeDeref() { + dlerror(); // clear old errors + + original_wl_proxy_get_listener = + reinterpret_cast(dlsym(RTLD_NEXT, "wl_proxy_get_listener")); + + if (auto* error = dlerror()) { + qCCritical(logDeref) << "Failed to find wl_proxy_get_listener for hooking:" << error; + } else { + qCInfo(logDeref) << "Installed wl_proxy_get_listener hook."; + } +} +// NOLINTEND diff --git a/src/wayland/wlr_layershell/shell_integration.hpp b/src/wayland/wlr_layershell/shell_integration.hpp index e92b7c6..93cda01 100644 --- a/src/wayland/wlr_layershell/shell_integration.hpp +++ b/src/wayland/wlr_layershell/shell_integration.hpp @@ -15,8 +15,8 @@ public: ~LayerShellIntegration() override; Q_DISABLE_COPY_MOVE(LayerShellIntegration); - QtWaylandClient::QWaylandShellSurface* createShellSurface(QtWaylandClient::QWaylandWindow* window - ) override; + QtWaylandClient::QWaylandShellSurface* + createShellSurface(QtWaylandClient::QWaylandWindow* window) override; }; } // namespace qs::wayland::layershell diff --git a/src/wayland/wlr_layershell/surface.cpp b/src/wayland/wlr_layershell/surface.cpp index 3188c6b..0b0e7d7 100644 --- a/src/wayland/wlr_layershell/surface.cpp +++ b/src/wayland/wlr_layershell/surface.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include "../../window/panelinterface.hpp" #include "shell_integration.hpp" @@ -30,8 +31,8 @@ namespace qs::wayland::layershell { namespace { -[[nodiscard]] QtWayland::zwlr_layer_shell_v1::layer toWaylandLayer(const WlrLayer::Enum& layer -) noexcept { +[[nodiscard]] QtWayland::zwlr_layer_shell_v1::layer +toWaylandLayer(const WlrLayer::Enum& layer) noexcept { switch (layer) { case WlrLayer::Background: return QtWayland::zwlr_layer_shell_v1::layer_background; case WlrLayer::Bottom: return QtWayland::zwlr_layer_shell_v1::layer_bottom; @@ -42,8 +43,8 @@ namespace { return QtWayland::zwlr_layer_shell_v1::layer_top; } -[[nodiscard]] QtWayland::zwlr_layer_surface_v1::anchor toWaylandAnchors(const Anchors& anchors -) noexcept { +[[nodiscard]] QtWayland::zwlr_layer_surface_v1::anchor +toWaylandAnchors(const Anchors& anchors) noexcept { quint32 wl = 0; if (anchors.mLeft) wl |= QtWayland::zwlr_layer_surface_v1::anchor_left; if (anchors.mRight) wl |= QtWayland::zwlr_layer_surface_v1::anchor_right; @@ -143,11 +144,11 @@ LayerSurface::LayerSurface(LayerShellIntegration* shell, QtWaylandClient::QWayla auto* waylandScreen = dynamic_cast(qwindow->screen()->handle()); - if (waylandScreen != nullptr) { + if (waylandScreen != nullptr && !waylandScreen->isPlaceholder() && waylandScreen->output()) { output = waylandScreen->output(); } else { - qWarning( - ) << "Layershell screen does not corrospond to a real screen. Letting the compositor pick."; + qWarning() + << "Layershell screen does not correspond to a real screen. Letting the compositor pick."; } } @@ -174,7 +175,10 @@ LayerSurface::LayerSurface(LayerShellIntegration* shell, QtWaylandClient::QWayla } LayerSurface::~LayerSurface() { - delete this->bridge; + if (this->bridge && this->bridge->surface == this) { + this->bridge->surface = nullptr; + } + this->destroy(); } @@ -244,9 +248,19 @@ void LayerSurface::commit() { } void LayerSurface::attachPopup(QtWaylandClient::QWaylandShellSurface* popup) { - std::any role = popup->surfaceRole(); - - if (auto* popupRole = std::any_cast<::xdg_popup*>(&role)) { // NOLINT +#ifdef __FreeBSD__ + // FreeBSD uses an alternate RTTI matching strategy by default which does + // not work across modules, preventing std::any from downcasting. On + // FreeBSD, Qt is built with a patch to expose the surface role through a + // pointer instead of an any, which does not have this problem. + // See https://bugs.kde.org/show_bug.cgi?id=479679 + if (auto* xdgPopup = static_cast<::xdg_popup*>(popup->nativeResource("xdg_popup"))) { + this->get_popup(xdgPopup); + return; + } +#endif + auto role = popup->surfaceRole(); // NOLINT + if (auto* popupRole = std::any_cast<::xdg_popup*>(&role)) { this->get_popup(*popupRole); } else { qWarning() << "Cannot attach popup" << popup << "to shell surface" << this diff --git a/src/wayland/wlr_layershell/wlr_layershell.cpp b/src/wayland/wlr_layershell/wlr_layershell.cpp index 4e61530..947c51a 100644 --- a/src/wayland/wlr_layershell/wlr_layershell.cpp +++ b/src/wayland/wlr_layershell/wlr_layershell.cpp @@ -1,10 +1,10 @@ #include "wlr_layershell.hpp" #include +#include #include #include #include -#include #include #include "../../core/qmlscreen.hpp" @@ -20,13 +20,21 @@ WlrLayershell::WlrLayershell(QObject* parent): ProxyWindowBase(parent) { case ExclusionMode::Ignore: return -1; case ExclusionMode::Normal: return this->bExclusiveZone; case ExclusionMode::Auto: - const auto anchors = this->bAnchors.value(); + const auto margins = this->bMargins.value(); - if (anchors.horizontalConstraint()) return this->bImplicitHeight; - else if (anchors.verticalConstraint()) return this->bImplicitWidth; - else return 0; + // add reverse edge margins which are ignored by wlr-layer-shell + switch (this->bcExclusionEdge.value()) { + case Qt::TopEdge: return this->bImplicitHeight + margins.bottom; + case Qt::BottomEdge: return this->bImplicitHeight + margins.top; + case Qt::LeftEdge: return this->bImplicitWidth + margins.right; + case Qt::RightEdge: return this->bImplicitWidth + margins.left; + } } + + return 0; }); + + this->bcExclusionEdge.setBinding([this] { return this->bAnchors.value().exclusionEdge(); }); } ProxiedWindow* WlrLayershell::retrieveWindow(QObject* oldInstance) { @@ -165,23 +173,9 @@ WlrLayershell* WlrLayershell::qmlAttachedProperties(QObject* object) { WaylandPanelInterface::WaylandPanelInterface(QObject* parent) : PanelWindowInterface(parent) , layer(new WlrLayershell(this)) { + this->connectSignals(); // clang-format off - QObject::connect(this->layer, &ProxyWindowBase::windowConnected, this, &WaylandPanelInterface::windowConnected); - QObject::connect(this->layer, &ProxyWindowBase::visibleChanged, this, &WaylandPanelInterface::visibleChanged); - QObject::connect(this->layer, &ProxyWindowBase::backerVisibilityChanged, this, &WaylandPanelInterface::backingWindowVisibleChanged); - QObject::connect(this->layer, &ProxyWindowBase::implicitHeightChanged, this, &WaylandPanelInterface::implicitHeightChanged); - QObject::connect(this->layer, &ProxyWindowBase::implicitWidthChanged, this, &WaylandPanelInterface::implicitWidthChanged); - QObject::connect(this->layer, &ProxyWindowBase::heightChanged, this, &WaylandPanelInterface::heightChanged); - QObject::connect(this->layer, &ProxyWindowBase::widthChanged, this, &WaylandPanelInterface::widthChanged); - QObject::connect(this->layer, &ProxyWindowBase::devicePixelRatioChanged, this, &WaylandPanelInterface::devicePixelRatioChanged); - QObject::connect(this->layer, &ProxyWindowBase::screenChanged, this, &WaylandPanelInterface::screenChanged); - QObject::connect(this->layer, &ProxyWindowBase::windowTransformChanged, this, &WaylandPanelInterface::windowTransformChanged); - QObject::connect(this->layer, &ProxyWindowBase::colorChanged, this, &WaylandPanelInterface::colorChanged); - QObject::connect(this->layer, &ProxyWindowBase::maskChanged, this, &WaylandPanelInterface::maskChanged); - QObject::connect(this->layer, &ProxyWindowBase::surfaceFormatChanged, this, &WaylandPanelInterface::surfaceFormatChanged); - - // panel specific QObject::connect(this->layer, &WlrLayershell::anchorsChanged, this, &WaylandPanelInterface::anchorsChanged); QObject::connect(this->layer, &WlrLayershell::marginsChanged, this, &WaylandPanelInterface::marginsChanged); QObject::connect(this->layer, &WlrLayershell::exclusiveZoneChanged, this, &WaylandPanelInterface::exclusiveZoneChanged); @@ -198,32 +192,13 @@ void WaylandPanelInterface::onReload(QObject* oldInstance) { this->layer->reload(old != nullptr ? old->layer : nullptr); } -QQmlListProperty WaylandPanelInterface::data() { return this->layer->data(); } ProxyWindowBase* WaylandPanelInterface::proxyWindow() const { return this->layer; } -QQuickItem* WaylandPanelInterface::contentItem() const { return this->layer->contentItem(); } - -bool WaylandPanelInterface::isBackingWindowVisible() const { - return this->layer->isVisibleDirect(); -} - -qreal WaylandPanelInterface::devicePixelRatio() const { return this->layer->devicePixelRatio(); } // NOLINTBEGIN #define proxyPair(type, get, set) \ type WaylandPanelInterface::get() const { return this->layer->get(); } \ void WaylandPanelInterface::set(type value) { this->layer->set(value); } -proxyPair(bool, isVisible, setVisible); -proxyPair(qint32, implicitWidth, setImplicitWidth); -proxyPair(qint32, implicitHeight, setImplicitHeight); -proxyPair(qint32, width, setWidth); -proxyPair(qint32, height, setHeight); -proxyPair(QuickshellScreenInfo*, screen, setScreen); -proxyPair(QColor, color, setColor); -proxyPair(PendingRegion*, mask, setMask); -proxyPair(QsSurfaceFormat, surfaceFormat, setSurfaceFormat); - -// panel specific proxyPair(Anchors, anchors, setAnchors); proxyPair(Margins, margins, setMargins); proxyPair(qint32, exclusiveZone, setExclusiveZone); diff --git a/src/wayland/wlr_layershell/wlr_layershell.hpp b/src/wayland/wlr_layershell/wlr_layershell.hpp index 739f5ff..0f5617f 100644 --- a/src/wayland/wlr_layershell/wlr_layershell.hpp +++ b/src/wayland/wlr_layershell/wlr_layershell.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -196,6 +197,7 @@ private: Q_OBJECT_BINDABLE_PROPERTY(WlrLayershell, WlrKeyboardFocus::Enum, bKeyboardFocus, &WlrLayershell::keyboardFocusChanged); Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(WlrLayershell, ExclusionMode::Enum, bExclusionMode, ExclusionMode::Auto, &WlrLayershell::exclusionModeChanged); Q_OBJECT_BINDABLE_PROPERTY(WlrLayershell, qint32, bcExclusiveZone); + Q_OBJECT_BINDABLE_PROPERTY(WlrLayershell, Qt::Edge, bcExclusionEdge); QS_BINDING_SUBSCRIBE_METHOD(WlrLayershell, bLayer, onStateChanged, onValueChanged); QS_BINDING_SUBSCRIBE_METHOD(WlrLayershell, bAnchors, onStateChanged, onValueChanged); @@ -214,43 +216,8 @@ public: void onReload(QObject* oldInstance) override; [[nodiscard]] ProxyWindowBase* proxyWindow() const override; - [[nodiscard]] QQuickItem* contentItem() const override; // NOLINTBEGIN - [[nodiscard]] bool isVisible() const override; - [[nodiscard]] bool isBackingWindowVisible() const override; - void setVisible(bool visible) override; - - [[nodiscard]] qint32 implicitWidth() const override; - void setImplicitWidth(qint32 implicitWidth) override; - - [[nodiscard]] qint32 implicitHeight() const override; - void setImplicitHeight(qint32 implicitHeight) override; - - [[nodiscard]] qint32 width() const override; - void setWidth(qint32 width) override; - - [[nodiscard]] qint32 height() const override; - void setHeight(qint32 height) override; - - [[nodiscard]] virtual qreal devicePixelRatio() const override; - - [[nodiscard]] QuickshellScreenInfo* screen() const override; - void setScreen(QuickshellScreenInfo* screen) override; - - [[nodiscard]] QColor color() const override; - void setColor(QColor color) override; - - [[nodiscard]] PendingRegion* mask() const override; - void setMask(PendingRegion* mask) override; - - [[nodiscard]] QsSurfaceFormat surfaceFormat() const override; - void setSurfaceFormat(QsSurfaceFormat mask) override; - - [[nodiscard]] QQmlListProperty data() override; - - // panel specific - [[nodiscard]] Anchors anchors() const override; void setAnchors(Anchors anchors) override; diff --git a/src/widgets/ClippingRectangle.qml b/src/widgets/ClippingRectangle.qml index 86fe601..604f346 100644 --- a/src/widgets/ClippingRectangle.qml +++ b/src/widgets/ClippingRectangle.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick ///! Rectangle capable of clipping content inside its border. @@ -72,6 +74,12 @@ Item { } } + ShaderEffectSource { + id: shaderSource + hideSource: true + sourceItem: contentItemContainer + } + ShaderEffect { id: shader anchors.fill: root @@ -79,10 +87,6 @@ Item { property Rectangle rect: rectangle property color backgroundColor: "white" property color borderColor: root.border.color - - property ShaderEffectSource content: ShaderEffectSource { - hideSource: true - sourceItem: contentItemContainer - } + property ShaderEffectSource content: shaderSource } } diff --git a/src/widgets/marginwrapper.cpp b/src/widgets/marginwrapper.cpp index 020e80a..b7d410c 100644 --- a/src/widgets/marginwrapper.cpp +++ b/src/widgets/marginwrapper.cpp @@ -1,4 +1,5 @@ #include "marginwrapper.hpp" +#include #include #include @@ -11,8 +12,8 @@ namespace qs::widgets { MarginWrapperManager::MarginWrapperManager(QObject* parent): WrapperManager(parent) { this->bTopMargin.setBinding([this] { return this->bExtraMargin - + (this->bOverrides.value().testFlag(TopMargin) ? this->bTopMarginOverride : this->bMargin - ); + + (this->bOverrides.value().testFlag(TopMargin) ? this->bTopMarginOverride + : this->bMargin); }); this->bBottomMargin.setBinding([this] { @@ -39,7 +40,7 @@ MarginWrapperManager::MarginWrapperManager(QObject* parent): WrapperManager(pare auto total = this->bLeftMargin + this->bRightMargin; auto mul = total == 0 ? 0.5 : this->bLeftMargin / total; auto margin = this->bWrapperWidth - this->bChildImplicitWidth; - return margin * mul; + return std::round(margin * mul); }); this->bChildY.setBinding([this] { @@ -48,7 +49,7 @@ MarginWrapperManager::MarginWrapperManager(QObject* parent): WrapperManager(pare auto total = this->bTopMargin + this->bBottomMargin; auto mul = total == 0 ? 0.5 : this->bTopMargin / total; auto margin = this->bWrapperHeight - this->bChildImplicitHeight; - return margin * mul; + return std::round(margin * mul); }); this->bChildWidth.setBinding([this] { diff --git a/src/widgets/wrapper.hpp b/src/widgets/wrapper.hpp index d506750..aca4172 100644 --- a/src/widgets/wrapper.hpp +++ b/src/widgets/wrapper.hpp @@ -85,6 +85,9 @@ class WrapperManager : public QObject , public QQmlParserStatus { Q_OBJECT; + QML_ELEMENT; + Q_INTERFACES(QQmlParserStatus); + // clang-format off /// The wrapper component's selected child. /// @@ -102,7 +105,6 @@ class WrapperManager /// This property may not be changed after Component.onCompleted. Q_PROPERTY(QQuickItem* wrapper READ wrapper WRITE setWrapper NOTIFY wrapperChanged FINAL); // clang-format on - QML_ELEMENT; public: explicit WrapperManager(QObject* parent = nullptr): QObject(parent) {} @@ -137,8 +139,8 @@ protected: void printChildCountWarning() const; void updateGeometry(); - virtual void disconnectChild() {}; - virtual void connectChild() {}; + virtual void disconnectChild() {} + virtual void connectChild() {} QQuickItem* mWrapper = nullptr; QQuickItem* mAssignedWrapper = nullptr; diff --git a/src/window/floatingwindow.cpp b/src/window/floatingwindow.cpp index 6a3a708..7a46bbf 100644 --- a/src/window/floatingwindow.cpp +++ b/src/window/floatingwindow.cpp @@ -1,22 +1,64 @@ #include "floatingwindow.hpp" +#include #include #include -#include -#include +#include #include #include +#include #include "proxywindow.hpp" #include "windowinterface.hpp" +ProxyFloatingWindow::ProxyFloatingWindow(QObject* parent): ProxyWindowBase(parent) { + this->bTargetVisible.setBinding([this] { + if (!this->bWantsVisible) return false; + auto* parent = this->bParentProxyWindow.value(); + if (!parent) return true; + return parent->bindableBackerVisibility().value(); + }); +} + +void ProxyFloatingWindow::targetVisibleChanged() { + if (this->window && this->bParentProxyWindow) { + auto* bw = this->bParentProxyWindow.value()->backingWindow(); + + if (bw != this->window->transientParent()) { + this->window->setTransientParent(bw); + } + } + + this->ProxyWindowBase::setVisible(this->bTargetVisible); +} + void ProxyFloatingWindow::connectWindow() { this->ProxyWindowBase::connectWindow(); + this->window->setTitle(this->bTitle); this->window->setMinimumSize(this->bMinimumSize); this->window->setMaximumSize(this->bMaximumSize); } +void ProxyFloatingWindow::completeWindow() { + this->ProxyWindowBase::completeWindow(); + + auto* parent = this->bParentProxyWindow.value(); + this->window->setTransientParent(parent ? parent->backingWindow() : nullptr); +} + +void ProxyFloatingWindow::postCompleteWindow() { + this->ProxyWindowBase::setVisible(this->bTargetVisible); +} + +void ProxyFloatingWindow::onParentDestroyed() { + this->mParentWindow = nullptr; + this->bParentProxyWindow = nullptr; + emit this->parentWindowChanged(); +} + +void ProxyFloatingWindow::setVisible(bool visible) { this->bWantsVisible = visible; } + void ProxyFloatingWindow::trySetWidth(qint32 implicitWidth) { if (!this->window->isVisible()) { this->ProxyWindowBase::trySetWidth(implicitWidth); @@ -29,6 +71,11 @@ void ProxyFloatingWindow::trySetHeight(qint32 implicitHeight) { } } +void ProxyFloatingWindow::onTitleChanged() { + if (this->window) this->window->setTitle(this->bTitle); + emit this->titleChanged(); +} + void ProxyFloatingWindow::onMinimumSizeChanged() { if (this->window) this->window->setMinimumSize(this->bMinimumSize); emit this->minimumSizeChanged(); @@ -39,28 +86,55 @@ void ProxyFloatingWindow::onMaximumSizeChanged() { emit this->maximumSizeChanged(); } +QObject* ProxyFloatingWindow::parentWindow() const { return this->mParentWindow; } + +void ProxyFloatingWindow::setParentWindow(QObject* window) { + if (window == this->mParentWindow) return; + + if (this->window && this->window->isVisible()) { + qmlWarning(this) << "parentWindow cannot be changed after the window is visible."; + return; + } + + if (this->bParentProxyWindow) { + QObject::disconnect(this->bParentProxyWindow, nullptr, this, nullptr); + } + + if (this->mParentWindow) { + QObject::disconnect(this->mParentWindow, nullptr, this, nullptr); + } + + this->mParentWindow = nullptr; + this->bParentProxyWindow = nullptr; + + if (auto* proxy = ProxyWindowBase::forObject(window)) { + this->mParentWindow = window; + this->bParentProxyWindow = proxy; + + QObject::connect( + this->mParentWindow, + &QObject::destroyed, + this, + &ProxyFloatingWindow::onParentDestroyed + ); + } + + emit this->parentWindowChanged(); +} + // FloatingWindowInterface FloatingWindowInterface::FloatingWindowInterface(QObject* parent) : WindowInterface(parent) , window(new ProxyFloatingWindow(this)) { - // clang-format off - QObject::connect(this->window, &ProxyWindowBase::windowConnected, this, &FloatingWindowInterface::windowConnected); - QObject::connect(this->window, &ProxyWindowBase::visibleChanged, this, &FloatingWindowInterface::visibleChanged); - QObject::connect(this->window, &ProxyWindowBase::backerVisibilityChanged, this, &FloatingWindowInterface::backingWindowVisibleChanged); - QObject::connect(this->window, &ProxyWindowBase::heightChanged, this, &FloatingWindowInterface::heightChanged); - QObject::connect(this->window, &ProxyWindowBase::widthChanged, this, &FloatingWindowInterface::widthChanged); - QObject::connect(this->window, &ProxyWindowBase::implicitHeightChanged, this, &FloatingWindowInterface::implicitHeightChanged); - QObject::connect(this->window, &ProxyWindowBase::implicitWidthChanged, this, &FloatingWindowInterface::implicitWidthChanged); - QObject::connect(this->window, &ProxyWindowBase::devicePixelRatioChanged, this, &FloatingWindowInterface::devicePixelRatioChanged); - QObject::connect(this->window, &ProxyWindowBase::screenChanged, this, &FloatingWindowInterface::screenChanged); - QObject::connect(this->window, &ProxyWindowBase::windowTransformChanged, this, &FloatingWindowInterface::windowTransformChanged); - QObject::connect(this->window, &ProxyWindowBase::colorChanged, this, &FloatingWindowInterface::colorChanged); - QObject::connect(this->window, &ProxyWindowBase::maskChanged, this, &FloatingWindowInterface::maskChanged); - QObject::connect(this->window, &ProxyWindowBase::surfaceFormatChanged, this, &FloatingWindowInterface::surfaceFormatChanged); + this->connectSignals(); + // clang-format off + QObject::connect(this->window, &ProxyFloatingWindow::titleChanged, this, &FloatingWindowInterface::titleChanged); QObject::connect(this->window, &ProxyFloatingWindow::minimumSizeChanged, this, &FloatingWindowInterface::minimumSizeChanged); QObject::connect(this->window, &ProxyFloatingWindow::maximumSizeChanged, this, &FloatingWindowInterface::maximumSizeChanged); + QObject::connect(this->window, &ProxyFloatingWindow::parentWindowChanged, this, &FloatingWindowInterface::parentWindowChanged); + QObject::connect(this->window, &ProxyWindowBase::windowConnected, this, &FloatingWindowInterface::onWindowConnected); // clang-format on } @@ -71,30 +145,110 @@ void FloatingWindowInterface::onReload(QObject* oldInstance) { this->window->reload(old != nullptr ? old->window : nullptr); } -QQmlListProperty FloatingWindowInterface::data() { return this->window->data(); } ProxyWindowBase* FloatingWindowInterface::proxyWindow() const { return this->window; } -QQuickItem* FloatingWindowInterface::contentItem() const { return this->window->contentItem(); } -bool FloatingWindowInterface::isBackingWindowVisible() const { - return this->window->isVisibleDirect(); +void FloatingWindowInterface::onWindowConnected() { + auto* qw = this->window->backingWindow(); + if (qw) { + QObject::connect( + qw, + &QWindow::windowStateChanged, + this, + &FloatingWindowInterface::onWindowStateChanged + ); + this->setMinimized(this->mMinimized); + this->setMaximized(this->mMaximized); + this->setFullscreen(this->mFullscreen); + this->onWindowStateChanged(); + } } -qreal FloatingWindowInterface::devicePixelRatio() const { return this->window->devicePixelRatio(); } +void FloatingWindowInterface::onWindowStateChanged() { + auto* qw = this->window->backingWindow(); + auto states = qw ? qw->windowStates() : Qt::WindowStates(); -// NOLINTBEGIN -#define proxyPair(type, get, set) \ - type FloatingWindowInterface::get() const { return this->window->get(); } \ - void FloatingWindowInterface::set(type value) { this->window->set(value); } + auto minimized = states.testFlag(Qt::WindowMinimized); + auto maximized = states.testFlag(Qt::WindowMaximized); + auto fullscreen = states.testFlag(Qt::WindowFullScreen); -proxyPair(bool, isVisible, setVisible); -proxyPair(qint32, implicitWidth, setImplicitWidth); -proxyPair(qint32, implicitHeight, setImplicitHeight); -proxyPair(qint32, width, setWidth); -proxyPair(qint32, height, setHeight); -proxyPair(QuickshellScreenInfo*, screen, setScreen); -proxyPair(QColor, color, setColor); -proxyPair(PendingRegion*, mask, setMask); -proxyPair(QsSurfaceFormat, surfaceFormat, setSurfaceFormat); + if (minimized != this->mWasMinimized) { + this->mWasMinimized = minimized; + emit this->minimizedChanged(); + } -#undef proxyPair -// NOLINTEND + if (maximized != this->mWasMaximized) { + this->mWasMaximized = maximized; + emit this->maximizedChanged(); + } + + if (fullscreen != this->mWasFullscreen) { + this->mWasFullscreen = fullscreen; + emit this->fullscreenChanged(); + } +} + +bool FloatingWindowInterface::isMinimized() const { + auto* qw = this->window->backingWindow(); + if (!qw) return this->mWasMinimized; + return qw->windowStates().testFlag(Qt::WindowMinimized); +} + +void FloatingWindowInterface::setMinimized(bool minimized) { + this->mMinimized = minimized; + + if (auto* qw = this->window->backingWindow()) { + auto states = qw->windowStates(); + states.setFlag(Qt::WindowMinimized, minimized); + qw->setWindowStates(states); + } +} + +bool FloatingWindowInterface::isMaximized() const { + auto* qw = this->window->backingWindow(); + if (!qw) return this->mWasMaximized; + return qw->windowStates().testFlag(Qt::WindowMaximized); +} + +void FloatingWindowInterface::setMaximized(bool maximized) { + this->mMaximized = maximized; + + if (auto* qw = this->window->backingWindow()) { + auto states = qw->windowStates(); + states.setFlag(Qt::WindowMaximized, maximized); + qw->setWindowStates(states); + } +} + +bool FloatingWindowInterface::isFullscreen() const { + auto* qw = this->window->backingWindow(); + if (!qw) return this->mWasFullscreen; + return qw->windowStates().testFlag(Qt::WindowFullScreen); +} + +void FloatingWindowInterface::setFullscreen(bool fullscreen) { + this->mFullscreen = fullscreen; + + if (auto* qw = this->window->backingWindow()) { + auto states = qw->windowStates(); + states.setFlag(Qt::WindowFullScreen, fullscreen); + qw->setWindowStates(states); + } +} + +bool FloatingWindowInterface::startSystemMove() const { + auto* qw = this->window->backingWindow(); + if (!qw) return false; + return qw->startSystemMove(); +} + +bool FloatingWindowInterface::startSystemResize(Qt::Edges edges) const { + auto* qw = this->window->backingWindow(); + if (!qw) return false; + return qw->startSystemResize(edges); +} + +QObject* FloatingWindowInterface::parentWindow() const { return this->window->parentWindow(); } + +void FloatingWindowInterface::setParentWindow(QObject* window) { + this->window->setParentWindow(window); +} diff --git a/src/window/floatingwindow.hpp b/src/window/floatingwindow.hpp index a175a1a..e9e536a 100644 --- a/src/window/floatingwindow.hpp +++ b/src/window/floatingwindow.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -8,13 +9,22 @@ #include "proxywindow.hpp" #include "windowinterface.hpp" +// see #include +static const int QWINDOWSIZE_MAX = ((1 << 24) - 1); + class ProxyFloatingWindow: public ProxyWindowBase { Q_OBJECT; public: - explicit ProxyFloatingWindow(QObject* parent = nullptr): ProxyWindowBase(parent) {} + explicit ProxyFloatingWindow(QObject* parent = nullptr); void connectWindow() override; + void completeWindow() override; + void postCompleteWindow() override; + void setVisible(bool visible) override; + + [[nodiscard]] QObject* parentWindow() const; + void setParentWindow(QObject* window); // Setting geometry while the window is visible makes the content item shrink but not the window // which is awful so we disable it for floating windows. @@ -24,12 +34,38 @@ public: signals: void minimumSizeChanged(); void maximumSizeChanged(); + void titleChanged(); + void parentWindowChanged(); + +private slots: + void onParentDestroyed(); private: void onMinimumSizeChanged(); void onMaximumSizeChanged(); + void onTitleChanged(); + void targetVisibleChanged(); + + QObject* mParentWindow = nullptr; + + Q_OBJECT_BINDABLE_PROPERTY(ProxyFloatingWindow, ProxyWindowBase*, bParentProxyWindow); + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(ProxyFloatingWindow, bool, bWantsVisible, true); + + Q_OBJECT_BINDABLE_PROPERTY( + ProxyFloatingWindow, + bool, + bTargetVisible, + &ProxyFloatingWindow::targetVisibleChanged + ); public: + Q_OBJECT_BINDABLE_PROPERTY( + ProxyFloatingWindow, + QString, + bTitle, + &ProxyFloatingWindow::onTitleChanged + ); + Q_OBJECT_BINDABLE_PROPERTY( ProxyFloatingWindow, QSize, @@ -37,10 +73,11 @@ public: &ProxyFloatingWindow::onMinimumSizeChanged ); - Q_OBJECT_BINDABLE_PROPERTY( + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS( ProxyFloatingWindow, QSize, bMaximumSize, + QSize(QWINDOWSIZE_MAX, QWINDOWSIZE_MAX), &ProxyFloatingWindow::onMaximumSizeChanged ); }; @@ -49,10 +86,23 @@ public: class FloatingWindowInterface: public WindowInterface { Q_OBJECT; // clang-format off + /// Window title. + Q_PROPERTY(QString title READ default WRITE default NOTIFY titleChanged BINDABLE bindableTitle); /// Minimum window size given to the window system. Q_PROPERTY(QSize minimumSize READ default WRITE default NOTIFY minimumSizeChanged BINDABLE bindableMinimumSize); /// Maximum window size given to the window system. Q_PROPERTY(QSize maximumSize READ default WRITE default NOTIFY maximumSizeChanged BINDABLE bindableMaximumSize); + /// Whether the window is currently minimized. + Q_PROPERTY(bool minimized READ isMinimized WRITE setMinimized NOTIFY minimizedChanged); + /// Whether the window is currently maximized. + Q_PROPERTY(bool maximized READ isMaximized WRITE setMaximized NOTIFY maximizedChanged); + /// Whether the window is currently fullscreen. + Q_PROPERTY(bool fullscreen READ isFullscreen WRITE setFullscreen NOTIFY fullscreenChanged); + /// The parent window of this window. Setting this makes the window a child of the parent, + /// which affects window stacking behavior. + /// + /// > [!NOTE] This property cannot be changed after the window is visible. + Q_PROPERTY(QObject* parentWindow READ parentWindow WRITE setParentWindow NOTIFY parentWindowChanged); // clang-format on QML_NAMED_ELEMENT(FloatingWindow); @@ -62,49 +112,45 @@ public: void onReload(QObject* oldInstance) override; [[nodiscard]] ProxyWindowBase* proxyWindow() const override; - [[nodiscard]] QQuickItem* contentItem() const override; - // NOLINTBEGIN - [[nodiscard]] bool isVisible() const override; - [[nodiscard]] bool isBackingWindowVisible() const override; - void setVisible(bool visible) override; + [[nodiscard]] QBindable bindableMinimumSize() { return &this->window->bMinimumSize; } + [[nodiscard]] QBindable bindableMaximumSize() { return &this->window->bMaximumSize; } + [[nodiscard]] QBindable bindableTitle() { return &this->window->bTitle; } - [[nodiscard]] qint32 implicitWidth() const override; - void setImplicitWidth(qint32 implicitWidth) override; + [[nodiscard]] bool isMinimized() const; + void setMinimized(bool minimized); + [[nodiscard]] bool isMaximized() const; + void setMaximized(bool maximized); + [[nodiscard]] bool isFullscreen() const; + void setFullscreen(bool fullscreen); - [[nodiscard]] qint32 implicitHeight() const override; - void setImplicitHeight(qint32 implicitHeight) override; + /// Start a system move operation. Must be called during a pointer press/drag. + Q_INVOKABLE [[nodiscard]] bool startSystemMove() const; + /// Start a system resize operation. Must be called during a pointer press/drag. + Q_INVOKABLE [[nodiscard]] bool startSystemResize(Qt::Edges edges) const; - [[nodiscard]] qint32 width() const override; - void setWidth(qint32 width) override; - - [[nodiscard]] qint32 height() const override; - void setHeight(qint32 height) override; - - [[nodiscard]] virtual qreal devicePixelRatio() const override; - - [[nodiscard]] QuickshellScreenInfo* screen() const override; - void setScreen(QuickshellScreenInfo* screen) override; - - [[nodiscard]] QColor color() const override; - void setColor(QColor color) override; - - [[nodiscard]] PendingRegion* mask() const override; - void setMask(PendingRegion* mask) override; - - [[nodiscard]] QsSurfaceFormat surfaceFormat() const override; - void setSurfaceFormat(QsSurfaceFormat mask) override; - - [[nodiscard]] QQmlListProperty data() override; - // NOLINTEND - - QBindable bindableMinimumSize() { return &this->window->bMinimumSize; } - QBindable bindableMaximumSize() { return &this->window->bMaximumSize; } + [[nodiscard]] QObject* parentWindow() const; + void setParentWindow(QObject* window); signals: void minimumSizeChanged(); void maximumSizeChanged(); + void titleChanged(); + void minimizedChanged(); + void maximizedChanged(); + void fullscreenChanged(); + void parentWindowChanged(); + +private slots: + void onWindowConnected(); + void onWindowStateChanged(); private: ProxyFloatingWindow* window; + bool mMinimized = false; + bool mMaximized = false; + bool mFullscreen = false; + bool mWasMinimized = false; + bool mWasMaximized = false; + bool mWasFullscreen = false; }; diff --git a/src/window/panelinterface.hpp b/src/window/panelinterface.hpp index 22c47c9..64dff50 100644 --- a/src/window/panelinterface.hpp +++ b/src/window/panelinterface.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -21,6 +22,21 @@ public: [[nodiscard]] bool horizontalConstraint() const noexcept { return this->mLeft && this->mRight; } [[nodiscard]] bool verticalConstraint() const noexcept { return this->mTop && this->mBottom; } + [[nodiscard]] Qt::Edge exclusionEdge() const noexcept { + auto hasHEdge = this->mLeft ^ this->mRight; + auto hasVEdge = this->mTop ^ this->mBottom; + + if (hasVEdge && !hasHEdge) { + if (this->mTop) return Qt::TopEdge; + if (this->mBottom) return Qt::BottomEdge; + } else if (hasHEdge && !hasVEdge) { + if (this->mLeft) return Qt::LeftEdge; + if (this->mRight) return Qt::RightEdge; + } + + return static_cast(0); + } + [[nodiscard]] bool operator==(const Anchors& other) const noexcept { // clang-format off return this->mLeft == other.mLeft diff --git a/src/window/popupwindow.cpp b/src/window/popupwindow.cpp index ec2be7e..bfe261e 100644 --- a/src/window/popupwindow.cpp +++ b/src/window/popupwindow.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -12,29 +13,74 @@ ProxyPopupWindow::ProxyPopupWindow(QObject* parent): ProxyWindowBase(parent) { this->mVisible = false; + // clang-format off - QObject::connect(&this->mAnchor, &PopupAnchor::windowChanged, this, &ProxyPopupWindow::parentWindowChanged); + QObject::connect(&this->mAnchor, &PopupAnchor::windowChanged, this, &ProxyPopupWindow::onParentWindowChanged); QObject::connect(&this->mAnchor, &PopupAnchor::windowRectChanged, this, &ProxyPopupWindow::reposition); QObject::connect(&this->mAnchor, &PopupAnchor::edgesChanged, this, &ProxyPopupWindow::reposition); QObject::connect(&this->mAnchor, &PopupAnchor::gravityChanged, this, &ProxyPopupWindow::reposition); QObject::connect(&this->mAnchor, &PopupAnchor::adjustmentChanged, this, &ProxyPopupWindow::reposition); - QObject::connect(&this->mAnchor, &PopupAnchor::backingWindowVisibilityChanged, this, &ProxyPopupWindow::onParentUpdated); // clang-format on + + this->bTargetVisible.setBinding([this] { + auto* window = this->mAnchor.bindableProxyWindow().value(); + + if (window == this) { + qmlWarning(this) << "Anchor assigned to current window"; + return false; + } + + if (!window) return false; + + if (!this->bWantsVisible) return false; + return window->bindableBackerVisibility().value(); + }); +} + +void ProxyPopupWindow::targetVisibleChanged() { + this->ProxyWindowBase::setVisible(this->bTargetVisible); } void ProxyPopupWindow::completeWindow() { this->ProxyWindowBase::completeWindow(); // clang-format off - QObject::connect(this->window, &QWindow::visibleChanged, this, &ProxyPopupWindow::onVisibleChanged); + QObject::connect(this, &ProxyWindowBase::closed, this, &ProxyPopupWindow::onClosed); QObject::connect(this->window, &QWindow::widthChanged, this, &ProxyPopupWindow::reposition); QObject::connect(this->window, &QWindow::heightChanged, this, &ProxyPopupWindow::reposition); // clang-format on - this->window->setFlag(Qt::ToolTip); + auto* bw = this->mAnchor.backingWindow(); + + if (bw && PopupPositioner::instance()->shouldRepositionOnMove()) { + QObject::connect(bw, &QWindow::xChanged, this, &ProxyPopupWindow::reposition); + QObject::connect(bw, &QWindow::yChanged, this, &ProxyPopupWindow::reposition); + QObject::connect(bw, &QWindow::widthChanged, this, &ProxyPopupWindow::reposition); + QObject::connect(bw, &QWindow::heightChanged, this, &ProxyPopupWindow::reposition); + } + + this->window->setTransientParent(bw); + this->window->setFlag(this->bWantsGrab ? Qt::Popup : Qt::ToolTip); + + this->mAnchor.markDirty(); + PopupPositioner::instance()->reposition(&this->mAnchor, this->window); } -void ProxyPopupWindow::postCompleteWindow() { this->updateTransientParent(); } +void ProxyPopupWindow::postCompleteWindow() { + this->ProxyWindowBase::setVisible(this->bTargetVisible); +} + +void ProxyPopupWindow::onClosed() { this->bWantsVisible = false; } + +void ProxyPopupWindow::onParentWindowChanged() { + // recreate for new parent + if (this->bTargetVisible && this->isVisibleDirect()) { + this->ProxyWindowBase::setVisibleDirect(false); + this->ProxyWindowBase::setVisibleDirect(true); + } + + emit this->parentWindowChanged(); +} void ProxyPopupWindow::setParentWindow(QObject* parent) { qmlWarning(this) << "PopupWindow.parentWindow is deprecated. Use PopupWindow.anchor.window."; @@ -43,59 +89,13 @@ void ProxyPopupWindow::setParentWindow(QObject* parent) { QObject* ProxyPopupWindow::parentWindow() const { return this->mAnchor.window(); } -void ProxyPopupWindow::updateTransientParent() { - auto* bw = this->mAnchor.backingWindow(); - - if (this->window != nullptr && bw != this->window->transientParent()) { - if (this->window->transientParent()) { - QObject::disconnect(this->window->transientParent(), nullptr, this, nullptr); - } - - if (bw && PopupPositioner::instance()->shouldRepositionOnMove()) { - QObject::connect(bw, &QWindow::xChanged, this, &ProxyPopupWindow::reposition); - QObject::connect(bw, &QWindow::yChanged, this, &ProxyPopupWindow::reposition); - QObject::connect(bw, &QWindow::widthChanged, this, &ProxyPopupWindow::reposition); - QObject::connect(bw, &QWindow::heightChanged, this, &ProxyPopupWindow::reposition); - } - - this->window->setTransientParent(bw); - } - - this->updateVisible(); -} - -void ProxyPopupWindow::onParentUpdated() { this->updateTransientParent(); } - void ProxyPopupWindow::setScreen(QuickshellScreenInfo* /*unused*/) { - qmlWarning(this + qmlWarning( + this ) << "Cannot set screen of popup window, as that is controlled by the parent window"; } -void ProxyPopupWindow::setVisible(bool visible) { - if (visible == this->wantsVisible) return; - this->wantsVisible = visible; - this->updateVisible(); -} - -void ProxyPopupWindow::updateVisible() { - auto target = this->wantsVisible && this->mAnchor.window() != nullptr - && this->mAnchor.proxyWindow()->isVisibleDirect(); - - if (target && this->window != nullptr && !this->window->isVisible()) { - PopupPositioner::instance()->reposition(&this->mAnchor, this->window); - } - - this->ProxyWindowBase::setVisible(target); -} - -void ProxyPopupWindow::onVisibleChanged() { - // If the window was made invisible without its parent becoming invisible - // the compositor probably destroyed it. Without this the window won't ever - // be able to become visible again. - if (this->window->transientParent() && this->window->transientParent()->isVisible()) { - this->wantsVisible = this->window->isVisible(); - } -} +void ProxyPopupWindow::setVisible(bool visible) { this->bWantsVisible = visible; } void ProxyPopupWindow::setRelativeX(qint32 x) { qmlWarning(this) << "PopupWindow.relativeX is deprecated. Use PopupWindow.anchor.rect.x."; @@ -143,3 +143,5 @@ void ProxyPopupWindow::onPolished() { } } } + +bool ProxyPopupWindow::deleteOnInvisible() const { return true; } diff --git a/src/window/popupwindow.hpp b/src/window/popupwindow.hpp index e00495c..d95eac0 100644 --- a/src/window/popupwindow.hpp +++ b/src/window/popupwindow.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -75,6 +76,15 @@ class ProxyPopupWindow: public ProxyWindowBase { /// /// The popup will not be shown until @@anchor is valid, regardless of this property. QSDOC_PROPERTY_OVERRIDE(bool visible READ isVisible WRITE setVisible NOTIFY visibleChanged); + /// If true, the popup window will be dismissed and @@visible will change to false + /// if the user clicks outside of the popup or it is otherwise closed. + /// + /// > [!WARNING] Changes to this property while the window is open will only take + /// > effect after the window is hidden and shown again. + /// + /// > [!NOTE] Under Hyprland, @@Quickshell.Hyprland.HyprlandFocusGrab provides more advanced + /// > functionality such as detecting clicks outside without closing the popup. + Q_PROPERTY(bool grabFocus READ default WRITE default NOTIFY grabFocusChanged BINDABLE bindableGrabFocus); /// The screen that the window currently occupies. /// /// This may be modified to move the window to the given screen. @@ -88,6 +98,7 @@ public: void completeWindow() override; void postCompleteWindow() override; void onPolished() override; + bool deleteOnInvisible() const override; void setScreen(QuickshellScreenInfo* screen) override; void setVisible(bool visible) override; @@ -101,24 +112,37 @@ public: [[nodiscard]] qint32 relativeY() const; void setRelativeY(qint32 y); + [[nodiscard]] QBindable bindableGrabFocus() { return &this->bWantsGrab; } + [[nodiscard]] PopupAnchor* anchor(); signals: void parentWindowChanged(); void relativeXChanged(); void relativeYChanged(); + void grabFocusChanged(); private slots: - void onVisibleChanged(); - void onParentUpdated(); + void onParentWindowChanged(); + void onClosed(); void reposition(); private: + void targetVisibleChanged(); + QQuickWindow* parentBackingWindow(); - void updateTransientParent(); - void updateVisible(); PopupAnchor mAnchor {this}; - bool wantsVisible = false; bool pendingReposition = false; + + Q_OBJECT_BINDABLE_PROPERTY(ProxyPopupWindow, bool, bWantsVisible); + + Q_OBJECT_BINDABLE_PROPERTY( + ProxyPopupWindow, + bool, + bTargetVisible, + &ProxyPopupWindow::targetVisibleChanged + ); + + Q_OBJECT_BINDABLE_PROPERTY(ProxyPopupWindow, bool, bWantsGrab); }; diff --git a/src/window/proxywindow.cpp b/src/window/proxywindow.cpp index df9b6b3..8a20dfa 100644 --- a/src/window/proxywindow.cpp +++ b/src/window/proxywindow.cpp @@ -1,6 +1,7 @@ #include "proxywindow.hpp" #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -55,10 +57,17 @@ ProxyWindowBase::ProxyWindowBase(QObject* parent) ProxyWindowBase::~ProxyWindowBase() { this->deleteWindow(true); } +ProxyWindowBase* ProxyWindowBase::forObject(QObject* obj) { + if (auto* proxy = qobject_cast(obj)) return proxy; + if (auto* iface = qobject_cast(obj)) return iface->proxyWindow(); + return nullptr; +} + void ProxyWindowBase::onReload(QObject* oldInstance) { - this->window = this->retrieveWindow(oldInstance); + if (this->mVisible) this->window = this->retrieveWindow(oldInstance); auto wasVisible = this->window != nullptr && this->window->isVisible(); - this->ensureQWindow(); + + if (this->mVisible) this->ensureQWindow(); // The qml engine will leave the WindowInterface as owner of everything // nested in an item, so we have to make sure the interface's children @@ -75,17 +84,21 @@ void ProxyWindowBase::onReload(QObject* oldInstance) { Reloadable::reloadChildrenRecursive(this, oldInstance); - this->connectWindow(); - this->completeWindow(); + if (this->mVisible) { + this->connectWindow(); + this->completeWindow(); + } this->reloadComplete = true; - emit this->windowConnected(); - this->postCompleteWindow(); + if (this->mVisible) { + emit this->windowConnected(); + this->postCompleteWindow(); - if (wasVisible && this->isVisibleDirect()) { - emit this->backerVisibilityChanged(); - this->runLints(); + if (wasVisible && this->isVisibleDirect()) { + this->bBackerVisibility = true; + this->onExposed(); + } } } @@ -112,6 +125,8 @@ void ProxyWindowBase::ensureQWindow() { auto opaque = this->qsSurfaceFormat.opaqueModified ? this->qsSurfaceFormat.opaque : this->mColor.alpha() >= 255; + format.setOption(QSurfaceFormat::ResetNotification); + if (opaque) format.setAlphaBufferSize(0); else format.setAlphaBufferSize(8); @@ -139,6 +154,15 @@ void ProxyWindowBase::ensureQWindow() { this->window = nullptr; // createQQuickWindow may indirectly reference this->window this->window = this->createQQuickWindow(); this->window->setFormat(format); + + // needed for vulkan dmabuf import, qt ignores these if not applicable + auto graphicsConfig = this->window->graphicsConfiguration(); + graphicsConfig.setDeviceExtensions({ + "VK_KHR_external_memory_fd", + "VK_EXT_external_memory_dma_buf", + "VK_EXT_image_drm_format_modifier", + }); + this->window->setGraphicsConfiguration(graphicsConfig); } void ProxyWindowBase::createWindow() { @@ -150,13 +174,7 @@ void ProxyWindowBase::createWindow() { void ProxyWindowBase::deleteWindow(bool keepItemOwnership) { if (this->window != nullptr) emit this->windowDestroyed(); - if (auto* window = this->disownWindow(keepItemOwnership)) { - if (auto* generation = EngineGeneration::findObjectGeneration(this)) { - generation->deregisterIncubationController(window->incubationController()); - } - - window->deleteLater(); - } + if (auto* window = this->disownWindow(keepItemOwnership)) window->deleteLater(); } ProxiedWindow* ProxyWindowBase::disownWindow(bool keepItemOwnership) { @@ -182,20 +200,21 @@ void ProxyWindowBase::connectWindow() { if (auto* generation = EngineGeneration::findObjectGeneration(this)) { // All windows have effectively the same incubation controller so it dosen't matter // which window it belongs to. We do want to replace the delay one though. - generation->registerIncubationController(this->window->incubationController()); + generation->trackWindowIncubationController(this->window); } this->window->setProxy(this); // clang-format off - QObject::connect(this->window, &QWindow::visibilityChanged, this, &ProxyWindowBase::visibleChanged); + QObject::connect(this->window, &QWindow::visibilityChanged, this, &ProxyWindowBase::onVisibleChanged); QObject::connect(this->window, &QWindow::xChanged, this, &ProxyWindowBase::xChanged); QObject::connect(this->window, &QWindow::yChanged, this, &ProxyWindowBase::yChanged); QObject::connect(this->window, &QWindow::widthChanged, this, &ProxyWindowBase::widthChanged); QObject::connect(this->window, &QWindow::heightChanged, this, &ProxyWindowBase::heightChanged); QObject::connect(this->window, &QWindow::screenChanged, this, &ProxyWindowBase::screenChanged); QObject::connect(this->window, &QQuickWindow::colorChanged, this, &ProxyWindowBase::colorChanged); - QObject::connect(this->window, &ProxiedWindow::exposed, this, &ProxyWindowBase::runLints); + QObject::connect(this->window, &QQuickWindow::sceneGraphError, this, &ProxyWindowBase::onSceneGraphError); + QObject::connect(this->window, &ProxiedWindow::exposed, this, &ProxyWindowBase::onExposed); QObject::connect(this->window, &ProxiedWindow::devicePixelRatioChanged, this, &ProxyWindowBase::devicePixelRatioChanged); // clang-format on } @@ -210,6 +229,7 @@ void ProxyWindowBase::completeWindow() { this->trySetHeight(this->implicitHeight()); this->setColor(this->mColor); this->updateMask(); + QQuickWindowPrivate::get(this->window)->updatesEnabled = this->mUpdatesEnabled; // notify initial / post-connection geometry emit this->xChanged(); @@ -226,6 +246,32 @@ void ProxyWindowBase::completeWindow() { emit this->screenChanged(); } +void ProxyWindowBase::onSceneGraphError( + QQuickWindow::SceneGraphError error, + const QString& message +) { + if (error == QQuickWindow::ContextNotAvailable) { + qCritical().nospace() << "Failed to create graphics context for " << this << ": " << message; + } else { + qCritical().nospace() << "Scene graph error " << error << " occurred for " << this << ": " + << message; + } + + emit this->resourcesLost(); + this->mVisible = false; + this->setVisibleDirect(false); +} + +void ProxyWindowBase::onVisibleChanged() { + if (this->mVisible && !this->window->isVisible()) { + this->mVisible = false; + this->setVisibleDirect(false); + emit this->closed(); + } + + emit this->visibleChanged(); +} + bool ProxyWindowBase::deleteOnInvisible() const { return false; } QQuickWindow* ProxyWindowBase::backingWindow() const { return this->window; } @@ -248,24 +294,27 @@ void ProxyWindowBase::setVisible(bool visible) { void ProxyWindowBase::setVisibleDirect(bool visible) { if (this->deleteOnInvisible()) { - if (visible == this->isVisibleDirect()) return; - if (visible) { + if (visible == this->isVisibleDirect()) return; this->createWindow(); this->polishItems(); this->window->setVisible(true); - emit this->backerVisibilityChanged(); + this->bBackerVisibility = true; } else { - if (this->window != nullptr) { - this->window->setVisible(false); - emit this->backerVisibilityChanged(); - this->deleteWindow(); - } + if (this->window != nullptr) this->window->setVisible(false); + this->bBackerVisibility = false; + this->deleteWindow(); + } + } else { + if (visible && this->window == nullptr) { + this->createWindow(); + } + + if (this->window != nullptr) { + if (visible) this->polishItems(); + this->window->setVisible(visible); + this->bBackerVisibility = visible; } - } else if (this->window != nullptr) { - if (visible) this->polishItems(); - this->window->setVisible(visible); - emit this->backerVisibilityChanged(); } } @@ -285,7 +334,9 @@ void ProxyWindowBase::polishItems() { QQuickWindowPrivate::get(this->window)->polishItems(); } -void ProxyWindowBase::runLints() { +void ProxyWindowBase::onExposed() { + this->onPolished(); + if (!this->ranLints) { qs::debug::lintItemTree(this->mContentItem); this->ranLints = true; @@ -435,6 +486,19 @@ void ProxyWindowBase::setSurfaceFormat(QsSurfaceFormat format) { emit this->surfaceFormatChanged(); } +bool ProxyWindowBase::updatesEnabled() const { return this->mUpdatesEnabled; } + +void ProxyWindowBase::setUpdatesEnabled(bool updatesEnabled) { + if (updatesEnabled == this->mUpdatesEnabled) return; + this->mUpdatesEnabled = updatesEnabled; + + if (this->window != nullptr) { + QQuickWindowPrivate::get(this->window)->updatesEnabled = updatesEnabled; + } + + emit this->updatesEnabledChanged(); +} + qreal ProxyWindowBase::devicePixelRatio() const { if (this->window != nullptr) return this->window->devicePixelRatio(); if (this->mScreen != nullptr) return this->mScreen->devicePixelRatio(); @@ -542,7 +606,8 @@ void ProxyWindowAttached::updateWindow() { void ProxyWindowAttached::setWindow(ProxyWindowBase* window) { if (window == this->mWindow) return; this->mWindow = window; - this->mWindowInterface = window ? qobject_cast(window->parent()) : nullptr; + auto* parentInterface = window ? qobject_cast(window->parent()) : nullptr; + this->mWindowInterface = parentInterface ? static_cast(parentInterface) : window; emit this->windowChanged(); } diff --git a/src/window/proxywindow.hpp b/src/window/proxywindow.hpp index 4203941..9ff66c4 100644 --- a/src/window/proxywindow.hpp +++ b/src/window/proxywindow.hpp @@ -57,6 +57,7 @@ class ProxyWindowBase: public Reloadable { Q_PROPERTY(QObject* windowTransform READ windowTransform NOTIFY windowTransformChanged); Q_PROPERTY(bool backingWindowVisible READ isVisibleDirect NOTIFY backerVisibilityChanged); Q_PROPERTY(QsSurfaceFormat surfaceFormat READ surfaceFormat WRITE setSurfaceFormat NOTIFY surfaceFormatChanged); + Q_PROPERTY(bool updatesEnabled READ updatesEnabled WRITE setUpdatesEnabled NOTIFY updatesEnabledChanged); Q_PROPERTY(QQmlListProperty data READ data); // clang-format on Q_CLASSINFO("DefaultProperty", "data"); @@ -65,6 +66,8 @@ public: explicit ProxyWindowBase(QObject* parent = nullptr); ~ProxyWindowBase() override; + static ProxyWindowBase* forObject(QObject* obj); + ProxyWindowBase(ProxyWindowBase&) = delete; ProxyWindowBase(ProxyWindowBase&&) = delete; void operator=(ProxyWindowBase&) = delete; @@ -101,6 +104,10 @@ public: virtual void setVisible(bool visible); virtual void setVisibleDirect(bool visible); + [[nodiscard]] QBindable bindableBackerVisibility() const { + return &this->bBackerVisibility; + } + void schedulePolish(); [[nodiscard]] virtual qint32 x() const; @@ -136,11 +143,16 @@ public: [[nodiscard]] QsSurfaceFormat surfaceFormat() const { return this->qsSurfaceFormat; } void setSurfaceFormat(QsSurfaceFormat format); + [[nodiscard]] bool updatesEnabled() const; + void setUpdatesEnabled(bool updatesEnabled); + [[nodiscard]] QObject* windowTransform() const { return nullptr; } // NOLINT [[nodiscard]] QQmlListProperty data(); signals: + void closed(); + void resourcesLost(); void windowConnected(); void windowDestroyed(); void visibleChanged(); @@ -157,16 +169,21 @@ signals: void colorChanged(); void maskChanged(); void surfaceFormatChanged(); + void updatesEnabledChanged(); void polished(); protected slots: virtual void onWidthChanged(); virtual void onHeightChanged(); + virtual void onPolished(); + +private slots: + void onSceneGraphError(QQuickWindow::SceneGraphError error, const QString& message); + void onVisibleChanged(); void onMaskChanged(); void onMaskDestroyed(); void onScreenDestroyed(); - virtual void onPolished(); - void runLints(); + void onExposed(); protected: bool mVisible = true; @@ -177,6 +194,7 @@ protected: ProxyWindowContentItem* mContentItem = nullptr; bool reloadComplete = false; bool ranLints = false; + bool mUpdatesEnabled = true; QsSurfaceFormat qsSurfaceFormat; QSurfaceFormat mSurfaceFormat; @@ -200,6 +218,13 @@ protected: &ProxyWindowBase::implicitHeightChanged ); + Q_OBJECT_BINDABLE_PROPERTY( + ProxyWindowBase, + bool, + bBackerVisibility, + &ProxyWindowBase::backerVisibilityChanged + ); + private: void polishItems(); void updateMask(); @@ -220,7 +245,7 @@ protected: private: ProxyWindowBase* mWindow = nullptr; - WindowInterface* mWindowInterface = nullptr; + QObject* mWindowInterface = nullptr; void setWindow(ProxyWindowBase* window); }; diff --git a/src/window/test/CMakeLists.txt b/src/window/test/CMakeLists.txt index 7061511..8a0d65c 100644 --- a/src/window/test/CMakeLists.txt +++ b/src/window/test/CMakeLists.txt @@ -1,6 +1,6 @@ function (qs_test name) add_executable(${name} ${ARGN}) - target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-window quickshell-core quickshell-ui) + target_link_libraries(${name} PRIVATE Qt::Quick Qt::Test quickshell-window quickshell-core quickshell-ui quickshell-io) add_test(NAME ${name} WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" COMMAND $) endfunction() diff --git a/src/window/test/manual/panel.qml b/src/window/test/manual/panel.qml new file mode 100644 index 0000000..5c4868c --- /dev/null +++ b/src/window/test/manual/panel.qml @@ -0,0 +1,151 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell + +Scope { + FloatingWindow { + color: contentItem.palette.window + minimumSize.width: layout.implicitWidth + minimumSize.height: layout.implicitHeight + + ColumnLayout { + id: layout + + RowLayout { + CheckBox { + id: visibleCb + text: "Visible" + checked: true + } + + CheckBox { + id: aboveCb + text: "Above Windows" + checked: true + } + } + + RowLayout { + ColumnLayout { + CheckBox { + id: leftAnchorCb + text: "Left" + } + + SpinBox { + id: leftMarginSb + editable: true + value: 0 + to: 1000 + } + } + + ColumnLayout { + CheckBox { + id: rightAnchorCb + text: "Right" + } + + SpinBox { + id: rightMarginSb + editable: true + value: 0 + to: 1000 + } + } + + ColumnLayout { + CheckBox { + id: topAnchorCb + text: "Top" + } + + SpinBox { + id: topMarginSb + editable: true + value: 0 + to: 1000 + } + } + + ColumnLayout { + CheckBox { + id: bottomAnchorCb + text: "Bottom" + } + + SpinBox { + id: bottomMarginSb + editable: true + value: 0 + to: 1000 + } + } + } + + RowLayout { + ComboBox { + id: exclusiveModeCb + model: [ "Normal", "Ignore", "Auto" ] + currentIndex: w.exclusionMode + } + + SpinBox { + id: exclusiveZoneSb + editable: true + value: 100 + to: 1000 + } + } + + RowLayout { + Label { text: "Width" } + + SpinBox { + id: widthSb + editable: true + value: 100 + to: 1000 + } + } + + RowLayout { + Label { text: "Height" } + + SpinBox { + id: heightSb + editable: true + value: 100 + to: 1000 + } + } + } + } + + PanelWindow { + id: w + visible: visibleCb.checked + aboveWindows: aboveCb.checked + + anchors { + left: leftAnchorCb.checked + right: rightAnchorCb.checked + top: topAnchorCb.checked + bottom: bottomAnchorCb.checked + } + + margins { + left: leftMarginSb.value + right: rightMarginSb.value + top: topMarginSb.value + bottom: bottomMarginSb.value + } + + exclusionMode: exclusiveModeCb.currentIndex + exclusiveZone: exclusiveZoneSb.value + + implicitWidth: widthSb.value + implicitHeight: heightSb.value + } +} diff --git a/src/window/test/manual/parentwindow.qml b/src/window/test/manual/parentwindow.qml new file mode 100644 index 0000000..214ee25 --- /dev/null +++ b/src/window/test/manual/parentwindow.qml @@ -0,0 +1,42 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Fusion +import Quickshell + +Scope { + FloatingWindow { + id: control + color: contentItem.palette.window + ColumnLayout { + CheckBox { + id: parentCb + text: "Show parent" + } + + CheckBox { + id: dialogCb + text: "Show dialog" + } + } + } + + FloatingWindow { + id: parentw + Text { + text: "parent" + } + visible: parentCb.checked + color: contentItem.palette.window + + FloatingWindow { + id: dialog + parentWindow: parentw + visible: dialogCb.checked + color: contentItem.palette.window + + Text { + text: "dialog" + } + } + } +} diff --git a/src/window/test/popupwindow.cpp b/src/window/test/popupwindow.cpp index 1262044..f9498d2 100644 --- a/src/window/test/popupwindow.cpp +++ b/src/window/test/popupwindow.cpp @@ -13,7 +13,7 @@ void TestPopupWindow::initiallyVisible() { // NOLINT auto parent = ProxyWindowBase(); auto popup = ProxyPopupWindow(); - popup.setParentWindow(&parent); + popup.anchor()->setWindow(&parent); popup.setVisible(true); parent.reload(); @@ -33,7 +33,7 @@ void TestPopupWindow::reloadReparent() { // NOLINT win2->setVisible(true); parent.setVisible(true); - popup.setParentWindow(&parent); + popup.anchor()->setWindow(&parent); popup.setVisible(true); parent.reload(); @@ -43,7 +43,7 @@ void TestPopupWindow::reloadReparent() { // NOLINT auto newParent = ProxyWindowBase(); auto newPopup = ProxyPopupWindow(); - newPopup.setParentWindow(&newParent); + newPopup.anchor()->setWindow(&newParent); newPopup.setVisible(true); auto* oldWindow = popup.backingWindow(); @@ -66,7 +66,7 @@ void TestPopupWindow::reloadUnparent() { // NOLINT auto parent = ProxyWindowBase(); auto popup = ProxyPopupWindow(); - popup.setParentWindow(&parent); + popup.anchor()->setWindow(&parent); popup.setVisible(true); parent.reload(); @@ -80,8 +80,7 @@ void TestPopupWindow::reloadUnparent() { // NOLINT newPopup.reload(&popup); QVERIFY(!newPopup.isVisible()); - QVERIFY(!newPopup.backingWindow()->isVisible()); - QCOMPARE(newPopup.backingWindow()->transientParent(), nullptr); + QVERIFY(!newPopup.backingWindow() || !newPopup.backingWindow()->isVisible()); } void TestPopupWindow::invisibleWithoutParent() { // NOLINT @@ -97,9 +96,11 @@ void TestPopupWindow::moveWithParent() { // NOLINT auto parent = ProxyWindowBase(); auto popup = ProxyPopupWindow(); - popup.setParentWindow(&parent); - popup.setRelativeX(10); - popup.setRelativeY(10); + popup.anchor()->setWindow(&parent); + auto rect = popup.anchor()->rect(); + rect.x = 10; + rect.y = 10; + popup.anchor()->setRect(rect); popup.setVisible(true); parent.reload(); @@ -126,7 +127,7 @@ void TestPopupWindow::attachParentLate() { // NOLINT QVERIFY(!popup.isVisible()); - popup.setParentWindow(&parent); + popup.anchor()->setWindow(&parent); QVERIFY(popup.isVisible()); QVERIFY(popup.backingWindow()->isVisible()); QCOMPARE(popup.backingWindow()->transientParent(), parent.backingWindow()); @@ -136,7 +137,7 @@ void TestPopupWindow::reparentLate() { // NOLINT auto parent = ProxyWindowBase(); auto popup = ProxyPopupWindow(); - popup.setParentWindow(&parent); + popup.anchor()->setWindow(&parent); popup.setVisible(true); parent.reload(); @@ -151,7 +152,7 @@ void TestPopupWindow::reparentLate() { // NOLINT parent2.backingWindow()->setX(10); parent2.backingWindow()->setY(10); - popup.setParentWindow(&parent2); + popup.anchor()->setWindow(&parent2); QVERIFY(popup.isVisible()); QVERIFY(popup.backingWindow()->isVisible()); QCOMPARE(popup.backingWindow()->transientParent(), parent2.backingWindow()); @@ -163,7 +164,7 @@ void TestPopupWindow::xMigrationFix() { // NOLINT auto parent = ProxyWindowBase(); auto popup = ProxyPopupWindow(); - popup.setParentWindow(&parent); + popup.anchor()->setWindow(&parent); popup.setVisible(true); parent.reload(); diff --git a/src/window/windowinterface.cpp b/src/window/windowinterface.cpp index 20057d6..e41afc2 100644 --- a/src/window/windowinterface.cpp +++ b/src/window/windowinterface.cpp @@ -2,9 +2,12 @@ #include #include +#include #include #include +#include "../core/qmlscreen.hpp" +#include "../core/region.hpp" #include "proxywindow.hpp" QPointF WindowInterface::itemPosition(QQuickItem* item) const { @@ -91,6 +94,67 @@ QsWindowAttached::mapFromItem(QQuickItem* item, qreal x, qreal y, qreal width, q } } +// clang-format off +QQuickItem* WindowInterface::contentItem() const { return this->proxyWindow()->contentItem(); } + +bool WindowInterface::isVisible() const { return this->proxyWindow()->isVisible(); }; +bool WindowInterface::isBackingWindowVisible() const { return this->proxyWindow()->isVisibleDirect(); }; +void WindowInterface::setVisible(bool visible) const { this->proxyWindow()->setVisible(visible); }; + +qint32 WindowInterface::implicitWidth() const { return this->proxyWindow()->implicitWidth(); }; +void WindowInterface::setImplicitWidth(qint32 implicitWidth) const { this->proxyWindow()->setImplicitWidth(implicitWidth); }; + +qint32 WindowInterface::implicitHeight() const { return this->proxyWindow()->implicitHeight(); }; +void WindowInterface::setImplicitHeight(qint32 implicitHeight) const { this->proxyWindow()->setImplicitHeight(implicitHeight); }; + +qint32 WindowInterface::width() const { return this->proxyWindow()->width(); }; +void WindowInterface::setWidth(qint32 width) const { this->proxyWindow()->setWidth(width); }; + +qint32 WindowInterface::height() const { return this->proxyWindow()->height(); }; +void WindowInterface::setHeight(qint32 height) const { this->proxyWindow()->setHeight(height); }; + +qreal WindowInterface::devicePixelRatio() const { return this->proxyWindow()->devicePixelRatio(); }; + +QuickshellScreenInfo* WindowInterface::screen() const { return this->proxyWindow()->screen(); }; +void WindowInterface::setScreen(QuickshellScreenInfo* screen) const { this->proxyWindow()->setScreen(screen); }; + +QColor WindowInterface::color() const { return this->proxyWindow()->color(); }; +void WindowInterface::setColor(QColor color) const { this->proxyWindow()->setColor(color); }; + +PendingRegion* WindowInterface::mask() const { return this->proxyWindow()->mask(); }; +void WindowInterface::setMask(PendingRegion* mask) const { this->proxyWindow()->setMask(mask); }; + +QsSurfaceFormat WindowInterface::surfaceFormat() const { return this->proxyWindow()->surfaceFormat(); }; +void WindowInterface::setSurfaceFormat(QsSurfaceFormat format) const { this->proxyWindow()->setSurfaceFormat(format); }; + +bool WindowInterface::updatesEnabled() const { return this->proxyWindow()->updatesEnabled(); }; +void WindowInterface::setUpdatesEnabled(bool updatesEnabled) const { this->proxyWindow()->setUpdatesEnabled(updatesEnabled); }; + +QQmlListProperty WindowInterface::data() const { return this->proxyWindow()->data(); }; +// clang-format on + +void WindowInterface::connectSignals() const { + auto* window = this->proxyWindow(); + // clang-format off + QObject::connect(window, &ProxyWindowBase::closed, this, &WindowInterface::closed); + QObject::connect(window, &ProxyWindowBase::resourcesLost, this, &WindowInterface::resourcesLost); + QObject::connect(window, &ProxyWindowBase::windowConnected, this, &WindowInterface::windowConnected); + QObject::connect(window, &ProxyWindowBase::visibleChanged, this, &WindowInterface::visibleChanged); + QObject::connect(window, &ProxyWindowBase::backerVisibilityChanged, this, &WindowInterface::backingWindowVisibleChanged); + QObject::connect(window, &ProxyWindowBase::implicitHeightChanged, this, &WindowInterface::implicitHeightChanged); + QObject::connect(window, &ProxyWindowBase::implicitWidthChanged, this, &WindowInterface::implicitWidthChanged); + QObject::connect(window, &ProxyWindowBase::heightChanged, this, &WindowInterface::heightChanged); + QObject::connect(window, &ProxyWindowBase::widthChanged, this, &WindowInterface::widthChanged); + QObject::connect(window, &ProxyWindowBase::devicePixelRatioChanged, this, &WindowInterface::devicePixelRatioChanged); + QObject::connect(window, &ProxyWindowBase::screenChanged, this, &WindowInterface::screenChanged); + QObject::connect(window, &ProxyWindowBase::windowTransformChanged, this, &WindowInterface::windowTransformChanged); + QObject::connect(window, &ProxyWindowBase::colorChanged, this, &WindowInterface::colorChanged); + QObject::connect(window, &ProxyWindowBase::maskChanged, this, &WindowInterface::maskChanged); + QObject::connect(window, &ProxyWindowBase::surfaceFormatChanged, this, &WindowInterface::surfaceFormatChanged); + QObject::connect(window, &ProxyWindowBase::updatesEnabledChanged, this, &WindowInterface::updatesEnabledChanged); + // clang-format on +} + QsWindowAttached* WindowInterface::qmlAttachedProperties(QObject* object) { while (object && !qobject_cast(object)) { object = object->parent(); diff --git a/src/window/windowinterface.hpp b/src/window/windowinterface.hpp index b8edff2..6f3db20 100644 --- a/src/window/windowinterface.hpp +++ b/src/window/windowinterface.hpp @@ -143,6 +143,12 @@ class WindowInterface: public Reloadable { /// /// > [!NOTE] The surface format cannot be changed after the window is created. Q_PROPERTY(QsSurfaceFormat surfaceFormat READ surfaceFormat WRITE setSurfaceFormat NOTIFY surfaceFormatChanged); + /// If the window should receive render updates. Defaults to true. + /// + /// When set to false, the window will not re-render in response to animations + /// or other visual updates from other windows. This is useful for static windows + /// such as wallpapers that do not need to update frequently, saving GPU cycles. + Q_PROPERTY(bool updatesEnabled READ updatesEnabled WRITE setUpdatesEnabled NOTIFY updatesEnabledChanged); Q_PROPERTY(QQmlListProperty data READ data); // clang-format on Q_CLASSINFO("DefaultProperty", "data"); @@ -197,45 +203,57 @@ public: // clang-format on [[nodiscard]] virtual ProxyWindowBase* proxyWindow() const = 0; - [[nodiscard]] virtual QQuickItem* contentItem() const = 0; + [[nodiscard]] QQuickItem* contentItem() const; - [[nodiscard]] virtual bool isVisible() const = 0; - [[nodiscard]] virtual bool isBackingWindowVisible() const = 0; - virtual void setVisible(bool visible) = 0; + [[nodiscard]] bool isVisible() const; + [[nodiscard]] bool isBackingWindowVisible() const; + void setVisible(bool visible) const; - [[nodiscard]] virtual qint32 implicitWidth() const = 0; - virtual void setImplicitWidth(qint32 implicitWidth) = 0; + [[nodiscard]] qint32 implicitWidth() const; + void setImplicitWidth(qint32 implicitWidth) const; - [[nodiscard]] virtual qint32 implicitHeight() const = 0; - virtual void setImplicitHeight(qint32 implicitHeight) = 0; + [[nodiscard]] qint32 implicitHeight() const; + void setImplicitHeight(qint32 implicitHeight) const; - [[nodiscard]] virtual qint32 width() const = 0; - virtual void setWidth(qint32 width) = 0; + [[nodiscard]] qint32 width() const; + void setWidth(qint32 width) const; - [[nodiscard]] virtual qint32 height() const = 0; - virtual void setHeight(qint32 height) = 0; + [[nodiscard]] qint32 height() const; + void setHeight(qint32 height) const; - [[nodiscard]] virtual qreal devicePixelRatio() const = 0; + [[nodiscard]] qreal devicePixelRatio() const; - [[nodiscard]] virtual QuickshellScreenInfo* screen() const = 0; - virtual void setScreen(QuickshellScreenInfo* screen) = 0; + [[nodiscard]] QuickshellScreenInfo* screen() const; + void setScreen(QuickshellScreenInfo* screen) const; [[nodiscard]] QObject* windowTransform() const { return nullptr; } // NOLINT - [[nodiscard]] virtual QColor color() const = 0; - virtual void setColor(QColor color) = 0; + [[nodiscard]] QColor color() const; + void setColor(QColor color) const; - [[nodiscard]] virtual PendingRegion* mask() const = 0; - virtual void setMask(PendingRegion* mask) = 0; + [[nodiscard]] PendingRegion* mask() const; + void setMask(PendingRegion* mask) const; - [[nodiscard]] virtual QsSurfaceFormat surfaceFormat() const = 0; - virtual void setSurfaceFormat(QsSurfaceFormat format) = 0; + [[nodiscard]] QsSurfaceFormat surfaceFormat() const; + void setSurfaceFormat(QsSurfaceFormat format) const; - [[nodiscard]] virtual QQmlListProperty data() = 0; + [[nodiscard]] bool updatesEnabled() const; + void setUpdatesEnabled(bool updatesEnabled) const; + + [[nodiscard]] QQmlListProperty data() const; static QsWindowAttached* qmlAttachedProperties(QObject* object); signals: + /// This signal is emitted when the window is closed by the user, the display server, + /// or an error. It is not emitted when @@visible is set to false. + void closed(); + /// This signal is emitted when resources a window depends on to display are lost, + /// or could not be acquired during window creation. The most common trigger for + /// this signal is a lack of VRAM when creating or resizing a window. + /// + /// Following this signal, @@closed(s) will be sent. + void resourcesLost(); void windowConnected(); void visibleChanged(); void backingWindowVisibleChanged(); @@ -249,6 +267,10 @@ signals: void colorChanged(); void maskChanged(); void surfaceFormatChanged(); + void updatesEnabledChanged(); + +protected: + void connectSignals() const; }; class QsWindowAttached: public QObject { diff --git a/src/windowmanager/CMakeLists.txt b/src/windowmanager/CMakeLists.txt new file mode 100644 index 0000000..3c032f4 --- /dev/null +++ b/src/windowmanager/CMakeLists.txt @@ -0,0 +1,20 @@ +qt_add_library(quickshell-windowmanager STATIC + screenprojection.cpp + windowmanager.cpp + windowset.cpp +) + +qt_add_qml_module(quickshell-windowmanager + URI Quickshell.WindowManager + VERSION 0.1 + DEPENDENCIES QtQuick +) + +qs_add_module_deps_light(quickshell-windowmanager Quickshell) + +install_qml_module(quickshell-windowmanager) + +qs_module_pch(quickshell-windowmanager SET large) + +target_link_libraries(quickshell-windowmanager PRIVATE Qt::Quick) +target_link_libraries(quickshell PRIVATE quickshell-windowmanagerplugin) diff --git a/src/windowmanager/module.md b/src/windowmanager/module.md new file mode 100644 index 0000000..3480d60 --- /dev/null +++ b/src/windowmanager/module.md @@ -0,0 +1,10 @@ +name = "Quickshell.WindowManager" +description = "Window manager interface" +headers = [ + "windowmanager.hpp", + "windowset.hpp", + "screenprojection.hpp", +] +----- +Currently only supports the [ext-workspace-v1](https://wayland.app/protocols/ext-workspace-v1) wayland protocol. +Support will be expanded in future releases. diff --git a/src/windowmanager/screenprojection.cpp b/src/windowmanager/screenprojection.cpp new file mode 100644 index 0000000..c09e6f0 --- /dev/null +++ b/src/windowmanager/screenprojection.cpp @@ -0,0 +1,30 @@ +#include "screenprojection.hpp" + +#include +#include +#include + +#include "windowmanager.hpp" +#include "windowset.hpp" + +namespace qs::wm { + +ScreenProjection::ScreenProjection(QScreen* screen, QObject* parent) + : WindowsetProjection(parent) + , mScreen(screen) { + this->bQScreens = {screen}; + this->bWindowsets.setBinding([this]() { + QList result; + for (auto* ws: WindowManager::instance()->bindableWindowsets().value()) { + auto* proj = ws->bindableProjection().value(); + if (proj && proj->bindableQScreens().value().contains(this->mScreen)) { + result.append(ws); + } + } + return result; + }); +} + +QScreen* ScreenProjection::screen() const { return this->mScreen; } + +} // namespace qs::wm diff --git a/src/windowmanager/screenprojection.hpp b/src/windowmanager/screenprojection.hpp new file mode 100644 index 0000000..6b0f31e --- /dev/null +++ b/src/windowmanager/screenprojection.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +#include "windowset.hpp" + +namespace qs::wm { + +///! WindowsetProjection covering one specific screen. +/// A ScreenProjection is a special type of @@WindowsetProjection which aggregates +/// all windowsets across all projections covering a specific screen. +/// +/// When used with @@Windowset.setProjection(), an arbitrary projection on the screen +/// will be picked. Usually there is only one. +/// +/// Use @@WindowManager.screenProjection() to get a ScreenProjection for a given screen. +class ScreenProjection: public WindowsetProjection { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + +public: + ScreenProjection(QScreen* screen, QObject* parent); + + [[nodiscard]] QScreen* screen() const; + +private: + QScreen* mScreen; +}; + +} // namespace qs::wm diff --git a/src/windowmanager/test/manual/WorkspaceDelegate.qml b/src/windowmanager/test/manual/WorkspaceDelegate.qml new file mode 100644 index 0000000..4ebd7f2 --- /dev/null +++ b/src/windowmanager/test/manual/WorkspaceDelegate.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Controls.Fusion +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.WindowManager + +WrapperRectangle { + id: delegate + required property Windowset modelData; + color: modelData.active ? "green" : "gray" + + ColumnLayout { + Label { text: delegate.modelData.toString() } + Label { text: `Id: ${delegate.modelData.id} Name: ${delegate.modelData.name}` } + Label { text: `Coordinates: ${delegate.modelData.coordinates.toString()}`} + + RowLayout { + Label { text: "Group:" } + ComboBox { + Layout.fillWidth: true + implicitContentWidthPolicy: ComboBox.WidestText + enabled: delegate.modelData.canSetProjection + model: [...WindowManager.windowsetProjections].map(w => w.toString()) + currentIndex: WindowManager.windowsetProjections.indexOf(delegate.modelData.projection) + onActivated: i => delegate.modelData.setProjection(WindowManager.windowsetProjections[i]) + } + } + + RowLayout { + Label { text: "Screen:" } + ComboBox { + Layout.fillWidth: true + implicitContentWidthPolicy: ComboBox.WidestText + enabled: delegate.modelData.canSetProjection + model: [...Quickshell.screens].map(w => w.name) + currentIndex: Quickshell.screens.indexOf(delegate.modelData.projection.screens[0]) + onActivated: i => delegate.modelData.setProjection(WindowManager.screenProjection(Quickshell.screens[i])) + } + } + + + RowLayout { + DisplayCheckBox { + text: "Active" + checked: delegate.modelData.active + } + + DisplayCheckBox { + text: "Urgent" + checked: delegate.modelData.urgent + } + + DisplayCheckBox { + text: "Should Display" + checked: delegate.modelData.shouldDisplay + } + } + + RowLayout { + Button { + text: "Activate" + enabled: delegate.modelData.canActivate + onClicked: delegate.modelData.activate() + } + + Button { + text: "Deactivate" + enabled: delegate.modelData.canDeactivate + onClicked: delegate.modelData.deactivate() + } + + Button { + text: "Remove" + enabled: delegate.modelData.canRemove + onClicked: delegate.modelData.remove() + } + } + } + + component DisplayCheckBox: CheckBox { + enabled: false + palette.disabled.text: parent.palette.active.text + palette.disabled.windowText: parent.palette.active.windowText + } +} diff --git a/src/windowmanager/test/manual/screenproj.qml b/src/windowmanager/test/manual/screenproj.qml new file mode 100644 index 0000000..d06036c --- /dev/null +++ b/src/windowmanager/test/manual/screenproj.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Controls.Fusion +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.WindowManager + +FloatingWindow { + ScrollView { + anchors.fill: parent + + ColumnLayout { + Repeater { + model: Quickshell.screens + + WrapperRectangle { + id: delegate + required property ShellScreen modelData + color: "slategray" + margin: 5 + + ColumnLayout { + Label { text: `Screen: ${delegate.modelData.name}` } + + Repeater { + model: ScriptModel { + values: WindowManager.screenProjection(delegate.modelData).windowsets + } + + WorkspaceDelegate {} + } + } + } + } + + Repeater { + model: ScriptModel { + values: WindowManager.windowsets.filter(w => w.projection == null) + } + + WorkspaceDelegate {} + } + } + } +} diff --git a/src/windowmanager/test/manual/workspaces.qml b/src/windowmanager/test/manual/workspaces.qml new file mode 100644 index 0000000..d6fdf05 --- /dev/null +++ b/src/windowmanager/test/manual/workspaces.qml @@ -0,0 +1,46 @@ +import QtQuick +import QtQuick.Controls.Fusion +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.WindowManager + +FloatingWindow { + ScrollView { + anchors.fill: parent + + ColumnLayout { + Repeater { + model: WindowManager.windowsetProjections + + WrapperRectangle { + id: delegate + required property WindowsetProjection modelData + color: "slategray" + margin: 5 + + ColumnLayout { + Label { text: delegate.modelData.toString() } + Label { text: `Screens: ${delegate.modelData.screens.map(s => s.name)}` } + + Repeater { + model: ScriptModel { + values: delegate.modelData.windowsets + } + + WorkspaceDelegate {} + } + } + } + } + + Repeater { + model: ScriptModel { + values: WindowManager.windowsets.filter(w => w.projection == null) + } + + WorkspaceDelegate {} + } + } + } +} diff --git a/src/windowmanager/windowmanager.cpp b/src/windowmanager/windowmanager.cpp new file mode 100644 index 0000000..511e8ec --- /dev/null +++ b/src/windowmanager/windowmanager.cpp @@ -0,0 +1,43 @@ +#include "windowmanager.hpp" +#include +#include + +#include + +#include "../core/qmlscreen.hpp" +#include "screenprojection.hpp" + +namespace qs::wm { + +std::function WindowManager::provider; + +void WindowManager::setProvider(std::function provider) { + WindowManager::provider = std::move(provider); +} + +WindowManager* WindowManager::instance() { + static auto* instance = WindowManager::provider(); + return instance; +} + +ScreenProjection* WindowManager::screenProjection(QuickshellScreenInfo* screen) { + if (!screen) return nullptr; + + auto* qscreen = screen->screen; + auto it = this->mScreenProjections.find(qscreen); + if (it != this->mScreenProjections.end()) { + return *it; + } + + auto* projection = new ScreenProjection(qscreen, this); + this->mScreenProjections.insert(qscreen, projection); + + QObject::connect(qscreen, &QObject::destroyed, this, [this, projection, qscreen]() { + this->mScreenProjections.remove(qscreen); + delete projection; + }); + + return projection; +} + +} // namespace qs::wm diff --git a/src/windowmanager/windowmanager.hpp b/src/windowmanager/windowmanager.hpp new file mode 100644 index 0000000..054e485 --- /dev/null +++ b/src/windowmanager/windowmanager.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "../core/qmlscreen.hpp" +#include "screenprojection.hpp" +#include "windowset.hpp" + +namespace qs::wm { + +class WindowManager: public QObject { + Q_OBJECT; + +public: + static void setProvider(std::function provider); + static WindowManager* instance(); + + Q_INVOKABLE ScreenProjection* screenProjection(QuickshellScreenInfo* screen); + + [[nodiscard]] QBindable> bindableWindowsets() const { + return &this->bWindowsets; + } + + [[nodiscard]] QBindable> bindableWindowsetProjections() const { + return &this->bWindowsetProjections; + } + +signals: + void windowsetsChanged(); + void windowsetProjectionsChanged(); + +public: + Q_OBJECT_BINDABLE_PROPERTY( + WindowManager, + QList, + bWindowsets, + &WindowManager::windowsetsChanged + ); + + Q_OBJECT_BINDABLE_PROPERTY( + WindowManager, + QList, + bWindowsetProjections, + &WindowManager::windowsetProjectionsChanged + ); + +private: + static std::function provider; + QHash mScreenProjections; +}; + +///! Window management interfaces exposed by the window manager. +class WindowManagerQml: public QObject { + Q_OBJECT; + QML_NAMED_ELEMENT(WindowManager); + QML_SINGLETON; + // clang-format off + /// All windowsets tracked by the WM across all projections. + Q_PROPERTY(QList windowsets READ default BINDABLE bindableWindowsets); + /// All windowset projections tracked by the WM. Does not include + /// internal projections from @@screenProjection(). + Q_PROPERTY(QList windowsetProjections READ default BINDABLE bindableWindowsetProjections); + // clang-format on + +public: + /// Returns an internal WindowsetProjection that covers a single screen and contains all + /// windowsets on that screen, regardless of the WM-specified projection. Depending on + /// how the WM lays out its actual projections, multiple ScreenProjections may contain + /// the same Windowsets. + Q_INVOKABLE static ScreenProjection* screenProjection(QuickshellScreenInfo* screen) { + return WindowManager::instance()->screenProjection(screen); + } + + [[nodiscard]] static QBindable> bindableWindowsets() { + return WindowManager::instance()->bindableWindowsets(); + } + + [[nodiscard]] static QBindable> bindableWindowsetProjections() { + return WindowManager::instance()->bindableWindowsetProjections(); + } +}; + +} // namespace qs::wm diff --git a/src/windowmanager/windowset.cpp b/src/windowmanager/windowset.cpp new file mode 100644 index 0000000..6231c40 --- /dev/null +++ b/src/windowmanager/windowset.cpp @@ -0,0 +1,45 @@ +#include "windowset.hpp" + +#include +#include +#include +#include + +#include "../core/qmlglobal.hpp" +#include "windowmanager.hpp" + +namespace qs::wm { + +Q_LOGGING_CATEGORY(logWorkspace, "quickshell.wm.workspace", QtWarningMsg); + +void Windowset::activate() { qCCritical(logWorkspace) << this << "cannot be activated"; } +void Windowset::deactivate() { qCCritical(logWorkspace) << this << "cannot be deactivated"; } +void Windowset::remove() { qCCritical(logWorkspace) << this << "cannot be removed"; } + +void Windowset::setProjection(WindowsetProjection* /*projection*/) { + qCCritical(logWorkspace) << this << "cannot be assigned to a projection"; +} + +WindowsetProjection::WindowsetProjection(QObject* parent): QObject(parent) { + this->bWindowsets.setBinding([this] { + QList result; + for (auto* ws: WindowManager::instance()->bindableWindowsets().value()) { + if (ws->bindableProjection().value() == this) { + result.append(ws); + } + } + return result; + }); + + this->bScreens.setBinding([this] { + QList screens; + + for (auto* screen: this->bQScreens.value()) { + screens.append(QuickshellTracked::instance()->screenInfo(screen)); + } + + return screens; + }); +} + +} // namespace qs::wm diff --git a/src/windowmanager/windowset.hpp b/src/windowmanager/windowset.hpp new file mode 100644 index 0000000..51cbd9b --- /dev/null +++ b/src/windowmanager/windowset.hpp @@ -0,0 +1,175 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QuickshellScreenInfo; + +namespace qs::wm { + +Q_DECLARE_LOGGING_CATEGORY(logWorkspace); + +class WindowsetProjection; + +///! A group of windows worked with by a user, usually known as a Workspace or Tag. +/// A Windowset is a generic type that encompasses both "Workspaces" and "Tags" in window managers. +/// Because the definition encompasses both you may not necessarily need all features. +class Windowset: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + // clang-format off + /// A persistent internal identifier for the windowset. This property should be identical + /// across restarts and destruction/recreation of a windowset. + Q_PROPERTY(QString id READ default NOTIFY idChanged BINDABLE bindableId); + /// Human readable name of the windowset. + Q_PROPERTY(QString name READ default NOTIFY nameChanged BINDABLE bindableName); + /// Coordinates of the workspace, represented as an N-dimensional array. Most WMs + /// will only expose one coordinate. If more than one is exposed, the first is + /// conventionally X, the second Y, and the third Z. + Q_PROPERTY(QList coordinates READ default NOTIFY coordinatesChanged BINDABLE bindableCoordinates); + /// True if the windowset is currently active. In a workspace based WM, this means the + /// represented workspace is current. In a tag based WM, this means the represented tag + /// is active. + Q_PROPERTY(bool active READ default NOTIFY activeChanged BINDABLE bindableActive); + /// The projection this windowset is a member of. A projection is the set of screens covered by + /// a windowset. + Q_PROPERTY(WindowsetProjection* projection READ default NOTIFY projectionChanged BINDABLE bindableProjection); + /// If false, this windowset should generally be hidden from workspace pickers. + Q_PROPERTY(bool shouldDisplay READ default NOTIFY shouldDisplayChanged BINDABLE bindableShouldDisplay); + /// If true, a window in this windowset has been marked as urgent. + Q_PROPERTY(bool urgent READ default NOTIFY urgentChanged BINDABLE bindableUrgent); + /// If true, the windowset can be activated. In a workspace based WM, this will make the workspace + /// current, in a tag based wm, the tag will be activated. + Q_PROPERTY(bool canActivate READ default NOTIFY canActivateChanged BINDABLE bindableCanActivate); + /// If true, the windowset can be deactivated. In a workspace based WM, deactivation is usually implicit + /// and based on activation of another workspace. + Q_PROPERTY(bool canDeactivate READ default NOTIFY canDeactivateChanged BINDABLE bindableCanDeactivate); + /// If true, the windowset can be removed. This may be done implicitly by the WM as well. + Q_PROPERTY(bool canRemove READ default NOTIFY canRemoveChanged BINDABLE bindableCanRemove); + /// If true, the windowset can be moved to a different projection. + Q_PROPERTY(bool canSetProjection READ default NOTIFY canSetProjectionChanged BINDABLE bindableCanSetProjection); + // clang-format on + +public: + explicit Windowset(QObject* parent): QObject(parent) {} + + /// Activate the windowset, making it the current workspace on a workspace based WM, or activating + /// the tag on a tag based WM. Requires @@canActivate. + Q_INVOKABLE virtual void activate(); + /// Deactivate the windowset, hiding it. Requires @@canDeactivate. + Q_INVOKABLE virtual void deactivate(); + /// Remove or destroy the windowset. Requires @@canRemove. + Q_INVOKABLE virtual void remove(); + /// Move the windowset to a different projection. A projection represents the set of screens + /// a workspace spans. Requires @@canSetProjection. + Q_INVOKABLE virtual void setProjection(WindowsetProjection* projection); + + [[nodiscard]] QBindable bindableId() const { return &this->bId; } + [[nodiscard]] QBindable bindableName() const { return &this->bName; } + [[nodiscard]] QBindable> bindableCoordinates() const { return &this->bCoordinates; } + [[nodiscard]] QBindable bindableActive() const { return &this->bActive; } + + [[nodiscard]] QBindable bindableProjection() const { + return &this->bProjection; + } + + [[nodiscard]] QBindable bindableShouldDisplay() const { return &this->bShouldDisplay; } + [[nodiscard]] QBindable bindableUrgent() const { return &this->bUrgent; } + [[nodiscard]] QBindable bindableCanActivate() const { return &this->bCanActivate; } + [[nodiscard]] QBindable bindableCanDeactivate() const { return &this->bCanDeactivate; } + [[nodiscard]] QBindable bindableCanRemove() const { return &this->bCanRemove; } + + [[nodiscard]] QBindable bindableCanSetProjection() const { + return &this->bCanSetProjection; + } + +signals: + void idChanged(); + void nameChanged(); + void coordinatesChanged(); + void activeChanged(); + void projectionChanged(); + void shouldDisplayChanged(); + void urgentChanged(); + void canActivateChanged(); + void canDeactivateChanged(); + void canRemoveChanged(); + void canSetProjectionChanged(); + +protected: + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY(Windowset, QString, bId, &Windowset::idChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, QString, bName, &Windowset::nameChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, QList, bCoordinates); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, bool, bActive, &Windowset::activeChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, WindowsetProjection*, bProjection, &Windowset::projectionChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, bool, bShouldDisplay, &Windowset::shouldDisplayChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, bool, bUrgent, &Windowset::urgentChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, bool, bCanActivate, &Windowset::canActivateChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, bool, bCanDeactivate, &Windowset::canDeactivateChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, bool, bCanRemove, &Windowset::canRemoveChanged); + Q_OBJECT_BINDABLE_PROPERTY(Windowset, bool, bCanSetProjection, &Windowset::canSetProjectionChanged); + // clang-format on +}; + +///! A space occupiable by a Windowset. +/// A WindowsetProjection represents a space that can be occupied by one or more @@Windowset$s. +/// The space is one or more screens. Multiple projections may occupy the same screens. +/// +/// @@WindowManager.screenProjection() can be used to get a projection representing all +/// @@Windowset$s on a given screen regardless of the WM's actual projection layout. +class WindowsetProjection: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + // clang-format off + /// Screens the windowset projection spans, often a single screen or all screens. + Q_PROPERTY(QList screens READ default NOTIFY screensChanged BINDABLE bindableScreens); + /// Windowsets that are currently present on the projection. + Q_PROPERTY(QList windowsets READ default NOTIFY windowsetsChanged BINDABLE bindableWindowsets); + // clang-format on + +public: + explicit WindowsetProjection(QObject* parent); + + [[nodiscard]] QBindable> bindableScreens() const { + return &this->bScreens; + } + + [[nodiscard]] QBindable> bindableQScreens() const { return &this->bQScreens; } + + [[nodiscard]] QBindable> bindableWindowsets() const { + return &this->bWindowsets; + } + +signals: + void screensChanged(); + void windowsetsChanged(); + +protected: + Q_OBJECT_BINDABLE_PROPERTY(WindowsetProjection, QList, bQScreens); + + Q_OBJECT_BINDABLE_PROPERTY( + WindowsetProjection, + QList, + bScreens, + &WindowsetProjection::screensChanged + ); + + Q_OBJECT_BINDABLE_PROPERTY( + WindowsetProjection, + QList, + bWindowsets, + &WindowsetProjection::windowsetsChanged + ); +}; + +} // namespace qs::wm diff --git a/src/x11/i3/ipc/CMakeLists.txt b/src/x11/i3/ipc/CMakeLists.txt index 27a4484..a073459 100644 --- a/src/x11/i3/ipc/CMakeLists.txt +++ b/src/x11/i3/ipc/CMakeLists.txt @@ -3,6 +3,8 @@ qt_add_library(quickshell-i3-ipc STATIC qml.cpp workspace.cpp monitor.cpp + controller.cpp + listener.cpp ) qt_add_qml_module(quickshell-i3-ipc @@ -15,7 +17,7 @@ qs_add_module_deps_light(quickshell-i3-ipc Quickshell) install_qml_module(quickshell-i3-ipc) -target_link_libraries(quickshell-i3-ipc PRIVATE Qt::Quick) +target_link_libraries(quickshell-i3-ipc PRIVATE Qt::Quick quickshell-core) qs_module_pch(quickshell-i3-ipc SET large) diff --git a/src/x11/i3/ipc/connection.cpp b/src/x11/i3/ipc/connection.cpp index 3c1015f..976167b 100644 --- a/src/x11/i3/ipc/connection.cpp +++ b/src/x11/i3/ipc/connection.cpp @@ -1,4 +1,4 @@ -#include +#include "connection.hpp" #include #include #include @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -15,489 +14,20 @@ #include #include #include -#include #include -#include #include #include #include -#include "../../../core/model.hpp" -#include "../../../core/qmlscreen.hpp" -#include "connection.hpp" -#include "monitor.hpp" -#include "workspace.hpp" +#include "../../../core/logcat.hpp" namespace qs::i3::ipc { namespace { -Q_LOGGING_CATEGORY(logI3Ipc, "quickshell.I3.ipc", QtWarningMsg); -Q_LOGGING_CATEGORY(logI3IpcEvents, "quickshell.I3.ipc.events", QtWarningMsg); +QS_LOGGING_CATEGORY(logI3Ipc, "quickshell.I3.ipc", QtWarningMsg); +QS_LOGGING_CATEGORY(logI3IpcEvents, "quickshell.I3.ipc.events", QtWarningMsg); } // namespace -void I3Ipc::makeRequest(const QByteArray& request) { - if (!this->valid) { - qCWarning(logI3IpcEvents) << "IPC connection is not open, ignoring request."; - return; - } - this->liveEventSocket.write(request); - this->liveEventSocket.flush(); -} - -void I3Ipc::dispatch(const QString& payload) { - auto message = I3Ipc::buildRequestMessage(EventCode::RunCommand, payload.toLocal8Bit()); - - this->makeRequest(message); -} - -QByteArray I3Ipc::buildRequestMessage(EventCode cmd, const QByteArray& payload) { - auto payloadLength = static_cast(payload.length()); - - auto type = QByteArray(std::bit_cast>(cmd).data(), 4); - auto len = QByteArray(std::bit_cast>(payloadLength).data(), 4); - - return MAGIC.data() + len + type + payload; -} - -I3Ipc::I3Ipc() { - auto sock = qEnvironmentVariable("I3SOCK"); - - if (sock.isEmpty()) { - qCWarning(logI3Ipc) << "$I3SOCK is unset. Trying $SWAYSOCK."; - - sock = qEnvironmentVariable("SWAYSOCK"); - - if (sock.isEmpty()) { - qCWarning(logI3Ipc) << "$SWAYSOCK and I3SOCK are unset. Cannot connect to socket."; - return; - } - } - - this->bFocusedWorkspace.setBinding([this]() -> I3Workspace* { - if (!this->bFocusedMonitor) return nullptr; - return this->bFocusedMonitor->bindableActiveWorkspace().value(); - }); - - this->mSocketPath = sock; - - // clang-format off - QObject::connect(&this->liveEventSocket, &QLocalSocket::errorOccurred, this, &I3Ipc::eventSocketError); - QObject::connect(&this->liveEventSocket, &QLocalSocket::stateChanged, this, &I3Ipc::eventSocketStateChanged); - QObject::connect(&this->liveEventSocket, &QLocalSocket::readyRead, this, &I3Ipc::eventSocketReady); - QObject::connect(&this->liveEventSocket, &QLocalSocket::connected, this, &I3Ipc::subscribe); - // clang-format on - - this->liveEventSocketDs.setDevice(&this->liveEventSocket); - this->liveEventSocketDs.setByteOrder(static_cast(QSysInfo::ByteOrder)); - - this->liveEventSocket.connectToServer(this->mSocketPath); -} - -void I3Ipc::subscribe() { - auto payload = QByteArray(R"(["workspace","output"])"); - auto message = I3Ipc::buildRequestMessage(EventCode::Subscribe, payload); - - this->makeRequest(message); - - // Workspaces must be refreshed before monitors or no focus will be - // detected on launch. - this->refreshWorkspaces(); - this->refreshMonitors(); -} - -void I3Ipc::eventSocketReady() { - for (auto& [type, data]: this->parseResponse()) { - this->event.mCode = type; - this->event.mData = data; - - this->onEvent(&this->event); - emit this->rawEvent(&this->event); - } -} - -void I3Ipc::reconnectIPC() { - qCWarning(logI3Ipc) << "Fatal IPC error occured, recreating connection"; - this->liveEventSocket.disconnectFromServer(); - this->liveEventSocket.connectToServer(this->mSocketPath); -} - -QVector I3Ipc::parseResponse() { - QVector> events; - const int magicLen = 6; - - while (!this->liveEventSocketDs.atEnd()) { - this->liveEventSocketDs.startTransaction(); - this->liveEventSocketDs.startTransaction(); - - std::array buffer = {}; - qint32 size = 0; - qint32 type = EventCode::Unknown; - - this->liveEventSocketDs.readRawData(buffer.data(), magicLen); - this->liveEventSocketDs >> size; - this->liveEventSocketDs >> type; - - if (!this->liveEventSocketDs.commitTransaction()) break; - - QByteArray payload(size, Qt::Uninitialized); - - this->liveEventSocketDs.readRawData(payload.data(), size); - - if (!this->liveEventSocketDs.commitTransaction()) break; - - if (strncmp(buffer.data(), MAGIC.data(), 6) != 0) { - qCWarning(logI3Ipc) << "No magic sequence found in string."; - this->reconnectIPC(); - break; - }; - - if (I3IpcEvent::intToEvent(type) == EventCode::Unknown) { - qCWarning(logI3Ipc) << "Received unknown event"; - break; - } - - QJsonParseError e; - - auto data = QJsonDocument::fromJson(payload, &e); - if (e.error != QJsonParseError::NoError) { - qCWarning(logI3Ipc) << "Invalid JSON value:" << e.errorString(); - break; - } else { - events.push_back(std::tuple(I3IpcEvent::intToEvent(type), data)); - } - } - - return events; -} - -void I3Ipc::eventSocketError(QLocalSocket::LocalSocketError error) const { - if (!this->valid) { - qCWarning(logI3Ipc) << "Unable to connect to I3 socket:" << error; - } else { - qCWarning(logI3Ipc) << "I3 socket error:" << error; - } -} - -void I3Ipc::eventSocketStateChanged(QLocalSocket::LocalSocketState state) { - if (state == QLocalSocket::ConnectedState) { - qCInfo(logI3Ipc) << "I3 event socket connected."; - emit this->connected(); - } else if (state == QLocalSocket::UnconnectedState && this->valid) { - qCWarning(logI3Ipc) << "I3 event socket disconnected."; - } - - this->valid = state == QLocalSocket::ConnectedState; -} - -QString I3Ipc::socketPath() const { return this->mSocketPath; } - -void I3Ipc::setFocusedMonitor(I3Monitor* monitor) { - auto* oldMonitor = this->bFocusedMonitor.value(); - if (monitor == oldMonitor) return; - - if (oldMonitor != nullptr) { - QObject::disconnect(oldMonitor, nullptr, this, nullptr); - } - - if (monitor != nullptr) { - QObject::connect(monitor, &QObject::destroyed, this, &I3Ipc::onFocusedMonitorDestroyed); - } - - this->bFocusedMonitor = monitor; -} - -void I3Ipc::onFocusedMonitorDestroyed() { this->bFocusedMonitor = nullptr; } - -I3Ipc* I3Ipc::instance() { - static I3Ipc* instance = nullptr; // NOLINT - - if (instance == nullptr) { - instance = new I3Ipc(); - } - - return instance; -} - -void I3Ipc::refreshWorkspaces() { - this->makeRequest(I3Ipc::buildRequestMessage(EventCode::GetWorkspaces)); -} - -void I3Ipc::handleGetWorkspacesEvent(I3IpcEvent* event) { - auto data = event->mData; - - auto workspaces = data.array(); - - const auto& mList = this->mWorkspaces.valueList(); - auto names = QVector(); - - qCDebug(logI3Ipc) << "There are" << workspaces.toVariantList().length() << "workspaces"; - for (auto entry: workspaces) { - auto object = entry.toObject().toVariantMap(); - auto name = object["name"].toString(); - - auto workspaceIter = std::ranges::find_if(mList, [name](I3Workspace* m) { - return m->bindableName().value() == name; - }); - - auto* workspace = workspaceIter == mList.end() ? nullptr : *workspaceIter; - auto existed = workspace != nullptr; - - if (workspace == nullptr) { - workspace = new I3Workspace(this); - } - - workspace->updateFromObject(object); - - if (!existed) { - this->mWorkspaces.insertObjectSorted(workspace, &I3Ipc::compareWorkspaces); - } - - if (!this->bFocusedWorkspace && object.value("focused").value()) { - this->bFocusedMonitor = workspace->bindableMonitor().value(); - } - - names.push_back(name); - } - - auto removedWorkspaces = QVector(); - - for (auto* workspace: mList) { - if (!names.contains(workspace->bindableName().value())) { - removedWorkspaces.push_back(workspace); - } - } - - qCDebug(logI3Ipc) << "Removing" << removedWorkspaces.length() << "deleted workspaces."; - - for (auto* workspace: removedWorkspaces) { - this->mWorkspaces.removeObject(workspace); - delete workspace; - } -} - -void I3Ipc::refreshMonitors() { - this->makeRequest(I3Ipc::buildRequestMessage(EventCode::GetOutputs)); -} - -void I3Ipc::handleGetOutputsEvent(I3IpcEvent* event) { - auto data = event->mData; - - auto monitors = data.array(); - const auto& mList = this->mMonitors.valueList(); - auto names = QVector(); - - qCDebug(logI3Ipc) << "There are" << monitors.toVariantList().length() << "monitors"; - - for (auto elem: monitors) { - auto object = elem.toObject().toVariantMap(); - auto name = object["name"].toString(); - - auto monitorIter = std::ranges::find_if(mList, [name](I3Monitor* m) { - return m->bindableName().value() == name; - }); - - auto* monitor = monitorIter == mList.end() ? nullptr : *monitorIter; - auto existed = monitor != nullptr; - - if (monitor == nullptr) { - monitor = new I3Monitor(this); - } - - monitor->updateFromObject(object); - - if (monitor->bindableFocused().value()) { - this->setFocusedMonitor(monitor); - } - - if (!existed) { - this->mMonitors.insertObject(monitor); - } - - names.push_back(name); - } - - auto removedMonitors = QVector(); - - for (auto* monitor: mList) { - if (!names.contains(monitor->bindableName().value())) { - removedMonitors.push_back(monitor); - } - } - - qCDebug(logI3Ipc) << "Removing" << removedMonitors.length() << "disconnected monitors."; - - for (auto* monitor: removedMonitors) { - this->mMonitors.removeObject(monitor); - delete monitor; - } -} - -void I3Ipc::onEvent(I3IpcEvent* event) { - switch (event->mCode) { - case EventCode::Workspace: this->handleWorkspaceEvent(event); return; - case EventCode::Output: - /// I3 only sends an "unspecified" event, so we have to query the data changes ourselves - qCInfo(logI3Ipc) << "Refreshing Monitors..."; - this->refreshMonitors(); - return; - case EventCode::Subscribe: qCInfo(logI3Ipc) << "Connected to IPC"; return; - case EventCode::GetOutputs: this->handleGetOutputsEvent(event); return; - case EventCode::GetWorkspaces: this->handleGetWorkspacesEvent(event); return; - case EventCode::RunCommand: I3Ipc::handleRunCommand(event); return; - case EventCode::Unknown: - qCWarning(logI3Ipc) << "Unknown event:" << event->type() << event->data(); - return; - default: qCWarning(logI3Ipc) << "Unhandled event:" << event->type(); - } -} - -void I3Ipc::handleRunCommand(I3IpcEvent* event) { - for (auto r: event->mData.array()) { - auto obj = r.toObject(); - const bool success = obj["success"].toBool(); - - if (!success) { - const QString error = obj["error"].toString(); - qCWarning(logI3Ipc) << "Error occured while running command:" << error; - } - } -} - -void I3Ipc::handleWorkspaceEvent(I3IpcEvent* event) { - // If a workspace doesn't exist, and is being switch to, no focus change event is emited, - // only the init one, which does not contain the previously focused workspace - auto change = event->mData["change"]; - - if (change == "init") { - qCInfo(logI3IpcEvents) << "New workspace has been created"; - - auto workspaceData = event->mData["current"]; - - auto* workspace = this->findWorkspaceByID(workspaceData["id"].toInt(-1)); - auto existed = workspace != nullptr; - - if (!existed) { - workspace = new I3Workspace(this); - } - - if (workspaceData.isObject()) { - workspace->updateFromObject(workspaceData.toObject().toVariantMap()); - } - - if (!existed) { - this->mWorkspaces.insertObjectSorted(workspace, &I3Ipc::compareWorkspaces); - qCInfo(logI3Ipc) << "Added workspace" << workspace->bindableName().value() << "to list"; - } - } else if (change == "focus") { - auto oldData = event->mData["old"]; - auto newData = event->mData["current"]; - auto oldName = oldData["name"].toString(); - auto newName = newData["name"].toString(); - - qCInfo(logI3IpcEvents) << "Focus changed: " << oldName << "->" << newName; - - if (auto* oldWorkspace = this->findWorkspaceByName(oldName)) { - oldWorkspace->updateFromObject(oldData.toObject().toVariantMap()); - } - - auto* newWorkspace = this->findWorkspaceByName(newName); - - if (newWorkspace == nullptr) { - newWorkspace = new I3Workspace(this); - } - - newWorkspace->updateFromObject(newData.toObject().toVariantMap()); - - if (newWorkspace->bindableMonitor().value()) { - auto* monitor = newWorkspace->bindableMonitor().value(); - monitor->setFocusedWorkspace(newWorkspace); - this->bFocusedMonitor = monitor; - } - } else if (change == "empty") { - auto name = event->mData["current"]["name"].toString(); - - auto* oldWorkspace = this->findWorkspaceByName(name); - - if (oldWorkspace != nullptr) { - qCInfo(logI3Ipc) << "Deleting" << oldWorkspace->bindableId().value() << name; - - if (this->bFocusedWorkspace == oldWorkspace) { - this->bFocusedMonitor->setFocusedWorkspace(nullptr); - } - - this->workspaces()->removeObject(oldWorkspace); - - delete oldWorkspace; - } else { - qCInfo(logI3Ipc) << "Workspace" << name << "has already been deleted"; - } - } else if (change == "move" || change == "rename" || change == "urgent") { - auto name = event->mData["current"]["name"].toString(); - - auto* workspace = this->findWorkspaceByName(name); - - if (workspace != nullptr) { - auto data = event->mData["current"].toObject().toVariantMap(); - - workspace->updateFromObject(data); - } else { - qCWarning(logI3Ipc) << "Workspace" << name << "doesn't exist"; - } - } else if (change == "reload") { - qCInfo(logI3Ipc) << "Refreshing Workspaces..."; - this->refreshWorkspaces(); - } -} - -I3Monitor* I3Ipc::monitorFor(QuickshellScreenInfo* screen) { - if (screen == nullptr) return nullptr; - - return this->findMonitorByName(screen->name()); -} - -I3Workspace* I3Ipc::findWorkspaceByID(qint32 id) { - auto list = this->mWorkspaces.valueList(); - auto workspaceIter = - std::ranges::find_if(list, [id](I3Workspace* m) { return m->bindableId().value() == id; }); - - return workspaceIter == list.end() ? nullptr : *workspaceIter; -} - -I3Workspace* I3Ipc::findWorkspaceByName(const QString& name) { - auto list = this->mWorkspaces.valueList(); - auto workspaceIter = std::ranges::find_if(list, [name](I3Workspace* m) { - return m->bindableName().value() == name; - }); - - return workspaceIter == list.end() ? nullptr : *workspaceIter; -} - -I3Monitor* I3Ipc::findMonitorByName(const QString& name, bool createIfMissing) { - auto list = this->mMonitors.valueList(); - auto monitorIter = std::ranges::find_if(list, [name](I3Monitor* m) { - return m->bindableName().value() == name; - }); - - if (monitorIter != list.end()) { - return *monitorIter; - } else if (createIfMissing) { - qCDebug(logI3Ipc) << "Monitor" << name << "requested before creation, performing early init"; - auto* monitor = new I3Monitor(this); - monitor->updateInitial(name); - this->mMonitors.insertObject(monitor); - return monitor; - } else { - return nullptr; - } -} - -ObjectModel* I3Ipc::monitors() { return &this->mMonitors; } -ObjectModel* I3Ipc::workspaces() { return &this->mWorkspaces; } - -bool I3Ipc::compareWorkspaces(I3Workspace* a, I3Workspace* b) { - return a->bindableNumber().value() > b->bindableNumber().value(); -} - QString I3IpcEvent::type() const { return I3IpcEvent::eventToString(this->mCode); } QString I3IpcEvent::data() const { return QString::fromUtf8(this->mData.toJson()); } @@ -530,8 +60,141 @@ QString I3IpcEvent::eventToString(EventCode event) { case EventCode::BarStateUpdate: return "bar_state_update"; break; case EventCode::Input: return "input"; break; - case EventCode::Unknown: return "unknown"; break; + default: return "unknown"; break; } } +I3Ipc::I3Ipc(const QList& events): mEvents(events) { + auto sock = qEnvironmentVariable("I3SOCK"); + + if (sock.isEmpty()) { + qCWarning(logI3Ipc) << "$I3SOCK is unset. Trying $SWAYSOCK."; + + sock = qEnvironmentVariable("SWAYSOCK"); + + if (sock.isEmpty()) { + qCWarning(logI3Ipc) << "$SWAYSOCK and I3SOCK are unset. Cannot connect to socket."; + return; + } + } + + this->mSocketPath = sock; + + // clang-format off + QObject::connect(&this->liveEventSocket, &QLocalSocket::errorOccurred, this, &I3Ipc::eventSocketError); + QObject::connect(&this->liveEventSocket, &QLocalSocket::stateChanged, this, &I3Ipc::eventSocketStateChanged); + QObject::connect(&this->liveEventSocket, &QLocalSocket::readyRead, this, &I3Ipc::eventSocketReady); + QObject::connect(&this->liveEventSocket, &QLocalSocket::connected, this, &I3Ipc::subscribe); + // clang-format on +} + +void I3Ipc::makeRequest(const QByteArray& request) { + if (!this->valid) { + qCWarning(logI3IpcEvents) << "IPC connection is not open, ignoring request."; + return; + } + this->liveEventSocket.write(request); + this->liveEventSocket.flush(); +} + +void I3Ipc::dispatch(const QString& payload) { + auto message = I3Ipc::buildRequestMessage(EventCode::RunCommand, payload.toLocal8Bit()); + + this->makeRequest(message); +} + +QByteArray I3Ipc::buildRequestMessage(EventCode cmd, const QByteArray& payload) { + auto payloadLength = static_cast(payload.length()); + + auto type = QByteArray(std::bit_cast>(cmd).data(), 4); + auto len = QByteArray(std::bit_cast>(payloadLength).data(), 4); + + return MAGIC.data() + len + type + payload; +} + +void I3Ipc::subscribe() { + auto jsonArray = QJsonArray::fromStringList(this->mEvents); + auto jsonDoc = QJsonDocument(jsonArray); + auto payload = jsonDoc.toJson(QJsonDocument::Compact); + auto message = I3Ipc::buildRequestMessage(EventCode::Subscribe, payload); + + this->makeRequest(message); +} + +void I3Ipc::eventSocketReady() { + for (auto& [type, data]: this->parseResponse()) { + this->event.mCode = type; + this->event.mData = data; + + emit this->rawEvent(&this->event); + } +} + +void I3Ipc::connect() { this->liveEventSocket.connectToServer(this->mSocketPath); } + +void I3Ipc::reconnectIPC() { + qCWarning(logI3Ipc) << "Fatal IPC error occured, recreating connection"; + this->liveEventSocket.disconnectFromServer(); + this->connect(); +} + +QVector I3Ipc::parseResponse() { + QVector events; + + while (true) { + this->eventReader.startTransaction(); + auto magic = this->eventReader.readBytes(6); + auto size = this->eventReader.readI32(); + auto type = this->eventReader.readI32(); + auto payload = this->eventReader.readBytes(size); + if (!this->eventReader.commitTransaction()) return events; + + if (magic.size() < 6 || strncmp(magic.data(), MAGIC.data(), 6) != 0) { + qCWarning(logI3Ipc) << "No magic sequence found in string."; + this->reconnectIPC(); + break; + } + + if (I3IpcEvent::intToEvent(type) == EventCode::Unknown) { + qCWarning(logI3Ipc) << "Received unknown event"; + break; + } + + // Importing this makes CI builds fail for some reason. + QJsonParseError e; // NOLINT (misc-include-cleaner) + + auto data = QJsonDocument::fromJson(payload, &e); + if (e.error != QJsonParseError::NoError) { + qCWarning(logI3Ipc) << "Invalid JSON value:" << e.errorString(); + break; + } else { + events.push_back(std::tuple(I3IpcEvent::intToEvent(type), data)); + } + } + + return events; +} + +void I3Ipc::eventSocketError(QLocalSocket::LocalSocketError error) const { + if (!this->valid) { + qCWarning(logI3Ipc) << "Unable to connect to I3 socket:" << error; + } else { + qCWarning(logI3Ipc) << "I3 socket error:" << error; + } +} + +void I3Ipc::eventSocketStateChanged(QLocalSocket::LocalSocketState state) { + if (state == QLocalSocket::ConnectedState) { + this->eventReader.setDevice(&this->liveEventSocket); + qCInfo(logI3Ipc) << "I3 event socket connected."; + emit this->connected(); + } else if (state == QLocalSocket::UnconnectedState && this->valid) { + qCWarning(logI3Ipc) << "I3 event socket disconnected."; + } + + this->valid = state == QLocalSocket::ConnectedState; +} + +QString I3Ipc::socketPath() const { return this->mSocketPath; } + } // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/connection.hpp b/src/x11/i3/ipc/connection.hpp index af480c5..7d03ecd 100644 --- a/src/x11/i3/ipc/connection.hpp +++ b/src/x11/i3/ipc/connection.hpp @@ -2,26 +2,13 @@ #include #include -#include #include #include -#include -#include #include #include #include -#include "../../../core/model.hpp" -#include "../../../core/qmlscreen.hpp" - -namespace qs::i3::ipc { - -class I3Workspace; -class I3Monitor; -} // namespace qs::i3::ipc - -Q_DECLARE_OPAQUE_POINTER(qs::i3::ipc::I3Workspace*); -Q_DECLARE_OPAQUE_POINTER(qs::i3::ipc::I3Monitor*); +#include "../../../core/streamreader.hpp" namespace qs::i3::ipc { @@ -54,9 +41,7 @@ using Event = std::tuple; class I3IpcEvent: public QObject { Q_OBJECT; - /// The name of the event Q_PROPERTY(QString type READ type CONSTANT); - /// The payload of the event in JSON format. Q_PROPERTY(QString data READ data CONSTANT); QML_NAMED_ELEMENT(I3Event); @@ -75,90 +60,48 @@ public: static QString eventToString(EventCode event); }; +/// Base class that manages the IPC socket, subscriptions and event reception. class I3Ipc: public QObject { Q_OBJECT; public: - static I3Ipc* instance(); + explicit I3Ipc(const QList& events); [[nodiscard]] QString socketPath() const; void makeRequest(const QByteArray& request); void dispatch(const QString& payload); + void connect(); - static QByteArray buildRequestMessage(EventCode cmd, const QByteArray& payload = QByteArray()); - - I3Workspace* findWorkspaceByName(const QString& name); - I3Monitor* findMonitorByName(const QString& name, bool createIfMissing = false); - I3Workspace* findWorkspaceByID(qint32 id); - - void setFocusedMonitor(I3Monitor* monitor); - - void refreshWorkspaces(); - void refreshMonitors(); - - I3Monitor* monitorFor(QuickshellScreenInfo* screen); - - [[nodiscard]] QBindable bindableFocusedMonitor() const { - return &this->bFocusedMonitor; - }; - - [[nodiscard]] QBindable bindableFocusedWorkspace() const { - return &this->bFocusedWorkspace; - }; - - [[nodiscard]] ObjectModel* monitors(); - [[nodiscard]] ObjectModel* workspaces(); + [[nodiscard]] QByteArray static buildRequestMessage( + EventCode cmd, + const QByteArray& payload = QByteArray() + ); signals: void connected(); void rawEvent(I3IpcEvent* event); - void focusedWorkspaceChanged(); - void focusedMonitorChanged(); -private slots: +protected slots: void eventSocketError(QLocalSocket::LocalSocketError error) const; void eventSocketStateChanged(QLocalSocket::LocalSocketState state); void eventSocketReady(); void subscribe(); - void onFocusedMonitorDestroyed(); - -private: - explicit I3Ipc(); - - void onEvent(I3IpcEvent* event); - - void handleWorkspaceEvent(I3IpcEvent* event); - void handleGetWorkspacesEvent(I3IpcEvent* event); - void handleGetOutputsEvent(I3IpcEvent* event); - static void handleRunCommand(I3IpcEvent* event); - static bool compareWorkspaces(I3Workspace* a, I3Workspace* b); - +protected: void reconnectIPC(); - QVector> parseResponse(); QLocalSocket liveEventSocket; - QDataStream liveEventSocketDs; + StreamReader eventReader; QString mSocketPath; - bool valid = false; - ObjectModel mMonitors {this}; - ObjectModel mWorkspaces {this}; - I3IpcEvent event {this}; - Q_OBJECT_BINDABLE_PROPERTY(I3Ipc, I3Monitor*, bFocusedMonitor, &I3Ipc::focusedMonitorChanged); - - Q_OBJECT_BINDABLE_PROPERTY( - I3Ipc, - I3Workspace*, - bFocusedWorkspace, - &I3Ipc::focusedWorkspaceChanged - ); +private: + QList mEvents; }; } // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/controller.cpp b/src/x11/i3/ipc/controller.cpp new file mode 100644 index 0000000..a83afd4 --- /dev/null +++ b/src/x11/i3/ipc/controller.cpp @@ -0,0 +1,361 @@ +#include "controller.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../../core/logcat.hpp" +#include "../../../core/model.hpp" +#include "../../../core/qmlscreen.hpp" +#include "connection.hpp" +#include "monitor.hpp" +#include "workspace.hpp" + +namespace qs::i3::ipc { + +namespace { +QS_LOGGING_CATEGORY(logI3Ipc, "quickshell.I3.ipc", QtWarningMsg); +QS_LOGGING_CATEGORY(logI3IpcEvents, "quickshell.I3.ipc.events", QtWarningMsg); +} // namespace + +I3IpcController::I3IpcController(): I3Ipc({"workspace", "output"}) { + // bind focused workspace to focused monitor's active workspace + this->bFocusedWorkspace.setBinding([this]() -> I3Workspace* { + if (!this->bFocusedMonitor) return nullptr; + return this->bFocusedMonitor->bindableActiveWorkspace().value(); + }); + + // clang-format off + QObject::connect(this, &I3Ipc::rawEvent, this, &I3IpcController::onEvent); + QObject::connect(&this->liveEventSocket, &QLocalSocket::connected, this, &I3IpcController::onConnected); + // clang-format on +} + +void I3IpcController::onConnected() { + // Workspaces must be refreshed before monitors or no focus will be + // detected on launch. + this->refreshWorkspaces(); + this->refreshMonitors(); +} + +void I3IpcController::setFocusedMonitor(I3Monitor* monitor) { + auto* oldMonitor = this->bFocusedMonitor.value(); + if (monitor == oldMonitor) return; + + if (oldMonitor != nullptr) { + QObject::disconnect(oldMonitor, nullptr, this, nullptr); + } + + if (monitor != nullptr) { + QObject::connect( + monitor, + &QObject::destroyed, + this, + &I3IpcController::onFocusedMonitorDestroyed + ); + } + + this->bFocusedMonitor = monitor; +} + +void I3IpcController::onFocusedMonitorDestroyed() { this->bFocusedMonitor = nullptr; } + +I3IpcController* I3IpcController::instance() { + static I3IpcController* instance = nullptr; // NOLINT + + if (instance == nullptr) { + instance = new I3IpcController(); + instance->connect(); + } + + return instance; +} + +void I3IpcController::refreshWorkspaces() { + this->makeRequest(I3Ipc::buildRequestMessage(EventCode::GetWorkspaces)); +} + +void I3IpcController::handleGetWorkspacesEvent(I3IpcEvent* event) { + auto data = event->mData; + + auto workspaces = data.array(); + + const auto& mList = this->mWorkspaces.valueList(); + auto names = QVector(); + + qCDebug(logI3Ipc) << "There are" << workspaces.toVariantList().length() << "workspaces"; + for (auto entry: workspaces) { + auto object = entry.toObject().toVariantMap(); + auto name = object["name"].toString(); + + auto workspaceIter = std::ranges::find_if(mList, [name](I3Workspace* m) { + return m->bindableName().value() == name; + }); + + auto* workspace = workspaceIter == mList.end() ? nullptr : *workspaceIter; + auto existed = workspace != nullptr; + + if (workspace == nullptr) { + workspace = new I3Workspace(this); + } + + workspace->updateFromObject(object); + + if (!existed) { + this->mWorkspaces.insertObjectSorted(workspace, &I3IpcController::compareWorkspaces); + } + + if (!this->bFocusedWorkspace && object.value("focused").value()) { + this->bFocusedMonitor = workspace->bindableMonitor().value(); + } + + names.push_back(name); + } + + auto removedWorkspaces = QVector(); + + for (auto* workspace: mList) { + if (!names.contains(workspace->bindableName().value())) { + removedWorkspaces.push_back(workspace); + } + } + + qCDebug(logI3Ipc) << "Removing" << removedWorkspaces.length() << "deleted workspaces."; + + for (auto* workspace: removedWorkspaces) { + this->mWorkspaces.removeObject(workspace); + delete workspace; + } +} + +void I3IpcController::refreshMonitors() { + this->makeRequest(I3Ipc::buildRequestMessage(EventCode::GetOutputs)); +} + +void I3IpcController::handleGetOutputsEvent(I3IpcEvent* event) { + auto data = event->mData; + + auto monitors = data.array(); + const auto& mList = this->mMonitors.valueList(); + auto names = QVector(); + + qCDebug(logI3Ipc) << "There are" << monitors.toVariantList().length() << "monitors"; + + for (auto elem: monitors) { + auto object = elem.toObject().toVariantMap(); + auto name = object["name"].toString(); + + auto monitorIter = std::ranges::find_if(mList, [name](I3Monitor* m) { + return m->bindableName().value() == name; + }); + + auto* monitor = monitorIter == mList.end() ? nullptr : *monitorIter; + auto existed = monitor != nullptr; + + if (monitor == nullptr) { + monitor = new I3Monitor(this); + } + + monitor->updateFromObject(object); + + if (monitor->bindableFocused().value()) { + this->setFocusedMonitor(monitor); + } + + if (!existed) { + this->mMonitors.insertObject(monitor); + } + + names.push_back(name); + } + + auto removedMonitors = QVector(); + + for (auto* monitor: mList) { + if (!names.contains(monitor->bindableName().value())) { + removedMonitors.push_back(monitor); + } + } + + qCDebug(logI3Ipc) << "Removing" << removedMonitors.length() << "disconnected monitors."; + + for (auto* monitor: removedMonitors) { + this->mMonitors.removeObject(monitor); + delete monitor; + } +} + +void I3IpcController::onEvent(I3IpcEvent* event) { + switch (event->mCode) { + case EventCode::Workspace: this->handleWorkspaceEvent(event); return; + case EventCode::Output: + /// I3 only sends an "unspecified" event, so we have to query the data changes ourselves + qCInfo(logI3Ipc) << "Refreshing Monitors..."; + this->refreshMonitors(); + return; + case EventCode::Subscribe: qCInfo(logI3Ipc) << "Connected to IPC"; return; + case EventCode::GetOutputs: this->handleGetOutputsEvent(event); return; + case EventCode::GetWorkspaces: this->handleGetWorkspacesEvent(event); return; + case EventCode::RunCommand: I3IpcController::handleRunCommand(event); return; + case EventCode::Unknown: + qCWarning(logI3Ipc) << "Unknown event:" << event->type() << event->data(); + return; + default: qCWarning(logI3Ipc) << "Unhandled event:" << event->type(); + } +} + +void I3IpcController::handleRunCommand(I3IpcEvent* event) { + for (auto r: event->mData.array()) { + auto obj = r.toObject(); + const bool success = obj["success"].toBool(); + + if (!success) { + const QString error = obj["error"].toString(); + qCWarning(logI3Ipc) << "Error occured while running command:" << error; + } + } +} + +void I3IpcController::handleWorkspaceEvent(I3IpcEvent* event) { + // If a workspace doesn't exist, and is being switch to, no focus change event is emited, + // only the init one, which does not contain the previously focused workspace + auto change = event->mData["change"]; + + if (change == "init") { + qCInfo(logI3IpcEvents) << "New workspace has been created"; + + auto workspaceData = event->mData["current"]; + + auto* workspace = this->findWorkspaceByID(workspaceData["id"].toInt(-1)); + auto existed = workspace != nullptr; + + if (!existed) { + workspace = new I3Workspace(this); + } + + if (workspaceData.isObject()) { + workspace->updateFromObject(workspaceData.toObject().toVariantMap()); + } + + if (!existed) { + this->mWorkspaces.insertObjectSorted(workspace, &I3IpcController::compareWorkspaces); + qCInfo(logI3Ipc) << "Added workspace" << workspace->bindableName().value() << "to list"; + } + } else if (change == "focus") { + auto oldData = event->mData["old"]; + auto newData = event->mData["current"]; + auto oldName = oldData["name"].toString(); + auto newName = newData["name"].toString(); + + qCInfo(logI3IpcEvents) << "Focus changed: " << oldName << "->" << newName; + + if (auto* oldWorkspace = this->findWorkspaceByName(oldName)) { + oldWorkspace->updateFromObject(oldData.toObject().toVariantMap()); + } + + auto* newWorkspace = this->findWorkspaceByName(newName); + + if (newWorkspace == nullptr) { + newWorkspace = new I3Workspace(this); + } + + newWorkspace->updateFromObject(newData.toObject().toVariantMap()); + + if (newWorkspace->bindableMonitor().value()) { + auto* monitor = newWorkspace->bindableMonitor().value(); + monitor->setActiveWorkspace(newWorkspace); + this->bFocusedMonitor = monitor; + } + } else if (change == "empty") { + auto name = event->mData["current"]["name"].toString(); + + auto* oldWorkspace = this->findWorkspaceByName(name); + + if (oldWorkspace != nullptr) { + qCInfo(logI3Ipc) << "Deleting" << oldWorkspace->bindableId().value() << name; + this->workspaces()->removeObject(oldWorkspace); + delete oldWorkspace; + } else { + qCInfo(logI3Ipc) << "Workspace" << name << "has already been deleted"; + } + } else if (change == "move" || change == "rename" || change == "urgent") { + auto name = event->mData["current"]["name"].toString(); + + auto* workspace = this->findWorkspaceByName(name); + + if (workspace != nullptr) { + auto data = event->mData["current"].toObject().toVariantMap(); + + workspace->updateFromObject(data); + } else { + qCWarning(logI3Ipc) << "Workspace" << name << "doesn't exist"; + } + } else if (change == "reload") { + qCInfo(logI3Ipc) << "Refreshing Workspaces..."; + this->refreshWorkspaces(); + } +} + +I3Monitor* I3IpcController::monitorFor(QuickshellScreenInfo* screen) { + if (screen == nullptr) return nullptr; + + return this->findMonitorByName(screen->name()); +} + +I3Workspace* I3IpcController::findWorkspaceByID(qint32 id) { + auto list = this->mWorkspaces.valueList(); + auto workspaceIter = + std::ranges::find_if(list, [id](I3Workspace* m) { return m->bindableId().value() == id; }); + + return workspaceIter == list.end() ? nullptr : *workspaceIter; +} + +I3Workspace* I3IpcController::findWorkspaceByName(const QString& name) { + auto list = this->mWorkspaces.valueList(); + auto workspaceIter = std::ranges::find_if(list, [name](I3Workspace* m) { + return m->bindableName().value() == name; + }); + + return workspaceIter == list.end() ? nullptr : *workspaceIter; +} + +I3Monitor* I3IpcController::findMonitorByName(const QString& name, bool createIfMissing) { + auto list = this->mMonitors.valueList(); + auto monitorIter = std::ranges::find_if(list, [name](I3Monitor* m) { + return m->bindableName().value() == name; + }); + + if (monitorIter != list.end()) { + return *monitorIter; + } else if (createIfMissing) { + qCDebug(logI3Ipc) << "Monitor" << name << "requested before creation, performing early init"; + auto* monitor = new I3Monitor(this); + monitor->updateInitial(name); + this->mMonitors.insertObject(monitor); + return monitor; + } else { + return nullptr; + } +} + +ObjectModel* I3IpcController::monitors() { return &this->mMonitors; } +ObjectModel* I3IpcController::workspaces() { return &this->mWorkspaces; } + +bool I3IpcController::compareWorkspaces(I3Workspace* a, I3Workspace* b) { + return a->bindableNumber().value() > b->bindableNumber().value(); +} + +} // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/controller.hpp b/src/x11/i3/ipc/controller.hpp new file mode 100644 index 0000000..464f6f6 --- /dev/null +++ b/src/x11/i3/ipc/controller.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../../core/model.hpp" +#include "../../../core/qmlscreen.hpp" +#include "connection.hpp" + +namespace qs::i3::ipc { + +class I3Workspace; +class I3Monitor; +} // namespace qs::i3::ipc + +Q_DECLARE_OPAQUE_POINTER(qs::i3::ipc::I3Workspace*); +Q_DECLARE_OPAQUE_POINTER(qs::i3::ipc::I3Monitor*); + +namespace qs::i3::ipc { + +/// I3/Sway IPC controller that manages workspaces and monitors +class I3IpcController: public I3Ipc { + Q_OBJECT; + +public: + static I3IpcController* instance(); + + I3Workspace* findWorkspaceByName(const QString& name); + I3Monitor* findMonitorByName(const QString& name, bool createIfMissing = false); + I3Workspace* findWorkspaceByID(qint32 id); + + void setFocusedMonitor(I3Monitor* monitor); + + void refreshWorkspaces(); + void refreshMonitors(); + + I3Monitor* monitorFor(QuickshellScreenInfo* screen); + + [[nodiscard]] QBindable bindableFocusedMonitor() const { + return &this->bFocusedMonitor; + }; + + [[nodiscard]] QBindable bindableFocusedWorkspace() const { + return &this->bFocusedWorkspace; + }; + + [[nodiscard]] ObjectModel* monitors(); + [[nodiscard]] ObjectModel* workspaces(); + +signals: + void focusedWorkspaceChanged(); + void focusedMonitorChanged(); + +private slots: + void onFocusedMonitorDestroyed(); + + void onEvent(I3IpcEvent* event); + void onConnected(); + +private: + explicit I3IpcController(); + + void handleWorkspaceEvent(I3IpcEvent* event); + void handleGetWorkspacesEvent(I3IpcEvent* event); + void handleGetOutputsEvent(I3IpcEvent* event); + static void handleRunCommand(I3IpcEvent* event); + static bool compareWorkspaces(I3Workspace* a, I3Workspace* b); + + ObjectModel mMonitors {this}; + ObjectModel mWorkspaces {this}; + + Q_OBJECT_BINDABLE_PROPERTY( + I3IpcController, + I3Monitor*, + bFocusedMonitor, + &I3IpcController::focusedMonitorChanged + ); + + Q_OBJECT_BINDABLE_PROPERTY( + I3IpcController, + I3Workspace*, + bFocusedWorkspace, + &I3IpcController::focusedWorkspaceChanged + ); +}; + +} // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/listener.cpp b/src/x11/i3/ipc/listener.cpp new file mode 100644 index 0000000..aa7719c --- /dev/null +++ b/src/x11/i3/ipc/listener.cpp @@ -0,0 +1,49 @@ +#include "listener.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "connection.hpp" + +namespace qs::i3::ipc { + +I3IpcListener::~I3IpcListener() { this->freeI3Ipc(); } + +void I3IpcListener::onPostReload() { this->startListening(); } + +QList I3IpcListener::subscriptions() const { return this->mSubscriptions; } +void I3IpcListener::setSubscriptions(QList subscriptions) { + if (this->mSubscriptions == subscriptions) return; + this->mSubscriptions = std::move(subscriptions); + + emit this->subscriptionsChanged(); + this->startListening(); +} + +void I3IpcListener::startListening() { + this->freeI3Ipc(); + if (this->mSubscriptions.isEmpty()) return; + + this->i3Ipc = new I3Ipc(this->mSubscriptions); + + // clang-format off + QObject::connect(this->i3Ipc, &I3Ipc::rawEvent, this, &I3IpcListener::receiveEvent); + // clang-format on + + this->i3Ipc->connect(); +} + +void I3IpcListener::receiveEvent(I3IpcEvent* event) { emit this->ipcEvent(event); } + +void I3IpcListener::freeI3Ipc() { + delete this->i3Ipc; + this->i3Ipc = nullptr; +} + +} // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/listener.hpp b/src/x11/i3/ipc/listener.hpp new file mode 100644 index 0000000..9cb40bb --- /dev/null +++ b/src/x11/i3/ipc/listener.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include // NOLINT +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../../core/doc.hpp" +#include "../../../core/generation.hpp" +#include "../../../core/qmlglobal.hpp" +#include "../../../core/reload.hpp" +#include "connection.hpp" + +namespace qs::i3::ipc { + +///! I3/Sway IPC event listener +/// #### Example +/// ```qml +/// I3IpcListener { +/// subscriptions: ["input"] +/// onIpcEvent: function (event) { +/// handleInputEvent(event.data) +/// } +/// } +/// ``` +class I3IpcListener: public PostReloadHook { + Q_OBJECT; + // clang-format off + /// List of [I3/Sway events](https://man.archlinux.org/man/sway-ipc.7.en#EVENTS) to subscribe to. + Q_PROPERTY(QList subscriptions READ subscriptions WRITE setSubscriptions NOTIFY subscriptionsChanged); + // clang-format on + QML_ELEMENT; + +public: + explicit I3IpcListener(QObject* parent = nullptr): PostReloadHook(parent) {} + ~I3IpcListener() override; + Q_DISABLE_COPY_MOVE(I3IpcListener); + + void onPostReload() override; + + [[nodiscard]] QList subscriptions() const; + void setSubscriptions(QList subscriptions); + +signals: + void ipcEvent(I3IpcEvent* event); + void subscriptionsChanged(); + +private: + void startListening(); + void receiveEvent(I3IpcEvent* event); + + void freeI3Ipc(); + + QList mSubscriptions; + I3Ipc* i3Ipc = nullptr; +}; + +} // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/monitor.cpp b/src/x11/i3/ipc/monitor.cpp index 1bc593c..7afb68e 100644 --- a/src/x11/i3/ipc/monitor.cpp +++ b/src/x11/i3/ipc/monitor.cpp @@ -7,12 +7,12 @@ #include #include -#include "connection.hpp" +#include "controller.hpp" #include "workspace.hpp" namespace qs::i3::ipc { -I3Monitor::I3Monitor(I3Ipc* ipc): QObject(ipc), ipc(ipc) { +I3Monitor::I3Monitor(I3IpcController* ipc): QObject(ipc), ipc(ipc) { // clang-format off this->bFocused.setBinding([this]() { return this->ipc->bindableFocusedMonitor().value() == this; }); // clang-format on @@ -40,21 +40,37 @@ void I3Monitor::updateFromObject(const QVariantMap& obj) { this->bHeight = rect.value("height").value(); this->bScale = obj.value("scale").value(); - if (!this->bActiveWorkspace - || activeWorkspaceName != this->bActiveWorkspace->bindableName().value()) - { + auto* activeWorkspace = this->bActiveWorkspace.value(); + if (!activeWorkspace || activeWorkspaceName != activeWorkspace->bindableName().value()) { if (activeWorkspaceName.isEmpty()) { - this->bActiveWorkspace = nullptr; + activeWorkspace = nullptr; } else { - this->bActiveWorkspace = this->ipc->findWorkspaceByName(activeWorkspaceName); + activeWorkspace = this->ipc->findWorkspaceByName(activeWorkspaceName); } }; + this->setActiveWorkspace(activeWorkspace); + Qt::endPropertyUpdateGroup(); } void I3Monitor::updateInitial(const QString& name) { this->bName = name; } -void I3Monitor::setFocusedWorkspace(I3Workspace* workspace) { this->bActiveWorkspace = workspace; }; +void I3Monitor::setActiveWorkspace(I3Workspace* workspace) { + auto* oldWorkspace = this->bActiveWorkspace.value(); + if (oldWorkspace == workspace) return; + + if (oldWorkspace) { + QObject::disconnect(oldWorkspace, nullptr, this, nullptr); + } + + if (workspace) { + QObject::connect(workspace, &QObject::destroyed, this, &I3Monitor::onActiveWorkspaceDestroyed); + } + + this->bActiveWorkspace = workspace; +} + +void I3Monitor::onActiveWorkspaceDestroyed() { this->bActiveWorkspace = nullptr; } } // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/monitor.hpp b/src/x11/i3/ipc/monitor.hpp index 00269a1..c328d8b 100644 --- a/src/x11/i3/ipc/monitor.hpp +++ b/src/x11/i3/ipc/monitor.hpp @@ -4,6 +4,7 @@ #include #include "connection.hpp" +#include "controller.hpp" namespace qs::i3::ipc { @@ -39,10 +40,10 @@ class I3Monitor: public QObject { Q_PROPERTY(QVariantMap lastIpcObject READ lastIpcObject NOTIFY lastIpcObjectChanged); // clang-format on QML_ELEMENT; - QML_UNCREATABLE("I3Monitors must be retrieved from the I3Ipc object."); + QML_UNCREATABLE("I3Monitors must be retrieved from the I3IpcController object."); public: - explicit I3Monitor(I3Ipc* ipc); + explicit I3Monitor(I3IpcController* ipc); [[nodiscard]] QBindable bindableId() { return &this->bId; } [[nodiscard]] QBindable bindableName() { return &this->bName; } @@ -54,7 +55,7 @@ public: [[nodiscard]] QBindable bindableScale() { return &this->bScale; } [[nodiscard]] QBindable bindableFocused() { return &this->bFocused; } - [[nodiscard]] QBindable bindableActiveWorkspace() { + [[nodiscard]] QBindable bindableActiveWorkspace() const { return &this->bActiveWorkspace; } @@ -63,7 +64,7 @@ public: void updateFromObject(const QVariantMap& obj); void updateInitial(const QString& name); - void setFocusedWorkspace(I3Workspace* workspace); + void setActiveWorkspace(I3Workspace* workspace); signals: void idChanged(); @@ -78,8 +79,11 @@ signals: void lastIpcObjectChanged(); void focusedChanged(); +private slots: + void onActiveWorkspaceDestroyed(); + private: - I3Ipc* ipc; + I3IpcController* ipc; QVariantMap mLastIpcObject; diff --git a/src/x11/i3/ipc/qml.cpp b/src/x11/i3/ipc/qml.cpp index 2804161..d835cbd 100644 --- a/src/x11/i3/ipc/qml.cpp +++ b/src/x11/i3/ipc/qml.cpp @@ -7,46 +7,49 @@ #include "../../../core/model.hpp" #include "../../../core/qmlscreen.hpp" #include "connection.hpp" +#include "controller.hpp" #include "workspace.hpp" namespace qs::i3::ipc { I3IpcQml::I3IpcQml() { - auto* instance = I3Ipc::instance(); + auto* instance = I3IpcController::instance(); // clang-format off QObject::connect(instance, &I3Ipc::rawEvent, this, &I3IpcQml::rawEvent); QObject::connect(instance, &I3Ipc::connected, this, &I3IpcQml::connected); - QObject::connect(instance, &I3Ipc::focusedWorkspaceChanged, this, &I3IpcQml::focusedWorkspaceChanged); - QObject::connect(instance, &I3Ipc::focusedMonitorChanged, this, &I3IpcQml::focusedMonitorChanged); + QObject::connect(instance, &I3IpcController::focusedWorkspaceChanged, this, &I3IpcQml::focusedWorkspaceChanged); + QObject::connect(instance, &I3IpcController::focusedMonitorChanged, this, &I3IpcQml::focusedMonitorChanged); // clang-format on } -void I3IpcQml::dispatch(const QString& request) { I3Ipc::instance()->dispatch(request); } -void I3IpcQml::refreshMonitors() { I3Ipc::instance()->refreshMonitors(); } -void I3IpcQml::refreshWorkspaces() { I3Ipc::instance()->refreshWorkspaces(); } -QString I3IpcQml::socketPath() { return I3Ipc::instance()->socketPath(); } -ObjectModel* I3IpcQml::monitors() { return I3Ipc::instance()->monitors(); } -ObjectModel* I3IpcQml::workspaces() { return I3Ipc::instance()->workspaces(); } +void I3IpcQml::dispatch(const QString& request) { I3IpcController::instance()->dispatch(request); } +void I3IpcQml::refreshMonitors() { I3IpcController::instance()->refreshMonitors(); } +void I3IpcQml::refreshWorkspaces() { I3IpcController::instance()->refreshWorkspaces(); } +QString I3IpcQml::socketPath() { return I3IpcController::instance()->socketPath(); } +ObjectModel* I3IpcQml::monitors() { return I3IpcController::instance()->monitors(); } +ObjectModel* I3IpcQml::workspaces() { + return I3IpcController::instance()->workspaces(); +} QBindable I3IpcQml::bindableFocusedWorkspace() { - return I3Ipc::instance()->bindableFocusedWorkspace(); + return I3IpcController::instance()->bindableFocusedWorkspace(); } QBindable I3IpcQml::bindableFocusedMonitor() { - return I3Ipc::instance()->bindableFocusedMonitor(); + return I3IpcController::instance()->bindableFocusedMonitor(); } I3Workspace* I3IpcQml::findWorkspaceByName(const QString& name) { - return I3Ipc::instance()->findWorkspaceByName(name); + return I3IpcController::instance()->findWorkspaceByName(name); } I3Monitor* I3IpcQml::findMonitorByName(const QString& name) { - return I3Ipc::instance()->findMonitorByName(name); + return I3IpcController::instance()->findMonitorByName(name); } I3Monitor* I3IpcQml::monitorFor(QuickshellScreenInfo* screen) { - return I3Ipc::instance()->monitorFor(screen); + return I3IpcController::instance()->monitorFor(screen); } } // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/qml.hpp b/src/x11/i3/ipc/qml.hpp index 804e42a..2e7c81c 100644 --- a/src/x11/i3/ipc/qml.hpp +++ b/src/x11/i3/ipc/qml.hpp @@ -7,6 +7,7 @@ #include "../../../core/doc.hpp" #include "../../../core/qmlscreen.hpp" #include "connection.hpp" +#include "controller.hpp" namespace qs::i3::ipc { diff --git a/src/x11/i3/ipc/workspace.cpp b/src/x11/i3/ipc/workspace.cpp index 7d0b730..530f0a2 100644 --- a/src/x11/i3/ipc/workspace.cpp +++ b/src/x11/i3/ipc/workspace.cpp @@ -7,12 +7,12 @@ #include #include -#include "connection.hpp" +#include "controller.hpp" #include "monitor.hpp" namespace qs::i3::ipc { -I3Workspace::I3Workspace(I3Ipc* ipc): QObject(ipc), ipc(ipc) { +I3Workspace::I3Workspace(I3IpcController* ipc): QObject(ipc), ipc(ipc) { Qt::beginPropertyUpdateGroup(); this->bActive.setBinding([this]() { @@ -43,14 +43,17 @@ void I3Workspace::updateFromObject(const QVariantMap& obj) { auto monitorName = obj.value("output").value(); - if (!this->bMonitor || monitorName != this->bMonitor->bindableName().value()) { + auto* monitor = this->bMonitor.value(); + if (!monitor || monitorName != monitor->bindableName().value()) { if (monitorName.isEmpty()) { - this->bMonitor = nullptr; + monitor = nullptr; } else { - this->bMonitor = this->ipc->findMonitorByName(monitorName, true); + monitor = this->ipc->findMonitorByName(monitorName, true); } } + this->setMonitor(monitor); + Qt::endPropertyUpdateGroup(); } @@ -58,4 +61,21 @@ void I3Workspace::activate() { this->ipc->dispatch(QString("workspace number %1").arg(this->bNumber.value())); } +void I3Workspace::setMonitor(I3Monitor* monitor) { + auto* oldMonitor = this->bMonitor.value(); + if (oldMonitor == monitor) return; + + if (oldMonitor) { + QObject::disconnect(oldMonitor, nullptr, this, nullptr); + } + + if (monitor) { + QObject::connect(monitor, &QObject::destroyed, this, &I3Workspace::onMonitorDestroyed); + } + + this->bMonitor = monitor; +} + +void I3Workspace::onMonitorDestroyed() { this->bMonitor = nullptr; } + } // namespace qs::i3::ipc diff --git a/src/x11/i3/ipc/workspace.hpp b/src/x11/i3/ipc/workspace.hpp index c9cd029..c08e926 100644 --- a/src/x11/i3/ipc/workspace.hpp +++ b/src/x11/i3/ipc/workspace.hpp @@ -5,6 +5,7 @@ #include #include "connection.hpp" +#include "controller.hpp" namespace qs::i3::ipc { @@ -40,7 +41,7 @@ class I3Workspace: public QObject { QML_UNCREATABLE("I3Workspaces must be retrieved from the I3 object."); public: - I3Workspace(I3Ipc* ipc); + I3Workspace(I3IpcController* ipc); /// Activate the workspace. /// @@ -56,11 +57,13 @@ public: [[nodiscard]] QBindable bindableActive() { return &this->bActive; } [[nodiscard]] QBindable bindableFocused() { return &this->bFocused; } [[nodiscard]] QBindable bindableUrgent() { return &this->bUrgent; } - [[nodiscard]] QBindable bindableMonitor() { return &this->bMonitor; } + [[nodiscard]] QBindable bindableMonitor() const { return &this->bMonitor; } [[nodiscard]] QVariantMap lastIpcObject() const; void updateFromObject(const QVariantMap& obj); + void setMonitor(I3Monitor* monitor); + signals: void idChanged(); void nameChanged(); @@ -71,8 +74,11 @@ signals: void monitorChanged(); void lastIpcObjectChanged(); +private slots: + void onMonitorDestroyed(); + private: - I3Ipc* ipc; + I3IpcController* ipc; QVariantMap mLastIpcObject; diff --git a/src/x11/i3/module.md b/src/x11/i3/module.md index 10afb98..1dc6180 100644 --- a/src/x11/i3/module.md +++ b/src/x11/i3/module.md @@ -2,8 +2,10 @@ name = "Quickshell.I3" description = "I3 specific Quickshell types" headers = [ "ipc/connection.hpp", + "ipc/controller.hpp", "ipc/qml.hpp", "ipc/workspace.hpp", "ipc/monitor.hpp", + "ipc/listener.hpp", ] ----- diff --git a/src/x11/panel_window.cpp b/src/x11/panel_window.cpp index 9ffbeb5..c78b548 100644 --- a/src/x11/panel_window.cpp +++ b/src/x11/panel_window.cpp @@ -40,17 +40,20 @@ public: } void addPanel(XPanelWindow* panel) { - auto& panels = this->mPanels[EngineGeneration::findObjectGeneration(panel)]; + panel->engineGeneration = EngineGeneration::findObjectGeneration(panel); + auto& panels = this->mPanels[panel->engineGeneration]; if (!panels.contains(panel)) { panels.push_back(panel); } } void removePanel(XPanelWindow* panel) { - auto& panels = this->mPanels[EngineGeneration::findObjectGeneration(panel)]; + if (!panel->engineGeneration) return; + + auto& panels = this->mPanels[panel->engineGeneration]; if (panels.removeOne(panel)) { if (panels.isEmpty()) { - this->mPanels.erase(EngineGeneration::findObjectGeneration(panel)); + this->mPanels.erase(panel->engineGeneration); } // from the bottom up, update all panels @@ -61,7 +64,8 @@ public: } void updateLowerDimensions(XPanelWindow* exclude) { - auto& panels = this->mPanels[EngineGeneration::findObjectGeneration(exclude)]; + if (!exclude->engineGeneration) return; + auto& panels = this->mPanels[exclude->engineGeneration]; // update all panels lower than the one we start from auto found = false; @@ -94,6 +98,28 @@ XPanelWindow::XPanelWindow(QObject* parent): ProxyWindowBase(parent) { this, &XPanelWindow::xInit ); + + this->bcExclusiveZone.setBinding([this]() -> qint32 { + switch (this->bExclusionMode.value()) { + case ExclusionMode::Ignore: return 0; + case ExclusionMode::Normal: return this->bExclusiveZone; + case ExclusionMode::Auto: + auto edge = this->bcExclusionEdge.value(); + auto margins = this->bMargins.value(); + + if (edge == Qt::TopEdge || edge == Qt::BottomEdge) { + return this->bImplicitHeight + margins.top + margins.bottom; + } else if (edge == Qt::LeftEdge || edge == Qt::RightEdge) { + return this->bImplicitWidth + margins.left + margins.right; + } else { + return 0; + } + } + + return 0; + }); + + this->bcExclusionEdge.setBinding([this] { return this->bAnchors.value().exclusionEdge(); }); } XPanelWindow::~XPanelWindow() { XPanelStack::instance()->removePanel(this); } @@ -102,7 +128,7 @@ void XPanelWindow::connectWindow() { this->ProxyWindowBase::connectWindow(); this->window->installEventFilter(&this->eventFilter); - this->connectScreen(); + this->updateScreen(); QObject::connect( this->window, @@ -129,7 +155,7 @@ void XPanelWindow::connectWindow() { void XPanelWindow::trySetWidth(qint32 implicitWidth) { // only update the actual size if not blocked by anchors - if (!this->mAnchors.horizontalConstraint()) { + if (!this->bAnchors.value().horizontalConstraint()) { this->ProxyWindowBase::trySetWidth(implicitWidth); this->updateDimensions(); } @@ -137,7 +163,7 @@ void XPanelWindow::trySetWidth(qint32 implicitWidth) { void XPanelWindow::trySetHeight(qint32 implicitHeight) { // only update the actual size if not blocked by anchors - if (!this->mAnchors.verticalConstraint()) { + if (!this->bAnchors.value().verticalConstraint()) { this->ProxyWindowBase::trySetHeight(implicitHeight); this->updateDimensions(); } @@ -145,62 +171,7 @@ void XPanelWindow::trySetHeight(qint32 implicitHeight) { void XPanelWindow::setScreen(QuickshellScreenInfo* screen) { this->ProxyWindowBase::setScreen(screen); - this->connectScreen(); -} - -Anchors XPanelWindow::anchors() const { return this->mAnchors; } - -void XPanelWindow::setAnchors(Anchors anchors) { - if (this->mAnchors == anchors) return; - this->mAnchors = anchors; - this->updateDimensions(); - emit this->anchorsChanged(); -} - -qint32 XPanelWindow::exclusiveZone() const { return this->mExclusiveZone; } - -void XPanelWindow::setExclusiveZone(qint32 exclusiveZone) { - if (this->mExclusiveZone == exclusiveZone) return; - this->mExclusiveZone = exclusiveZone; - this->setExclusionMode(ExclusionMode::Normal); - this->updateStrut(); - emit this->exclusiveZoneChanged(); -} - -ExclusionMode::Enum XPanelWindow::exclusionMode() const { return this->mExclusionMode; } - -void XPanelWindow::setExclusionMode(ExclusionMode::Enum exclusionMode) { - if (this->mExclusionMode == exclusionMode) return; - this->mExclusionMode = exclusionMode; - this->updateStrut(); - emit this->exclusionModeChanged(); -} - -Margins XPanelWindow::margins() const { return this->mMargins; } - -void XPanelWindow::setMargins(Margins margins) { - if (this->mMargins == margins) return; - this->mMargins = margins; - this->updateDimensions(); - emit this->marginsChanged(); -} - -bool XPanelWindow::aboveWindows() const { return this->mAboveWindows; } - -void XPanelWindow::setAboveWindows(bool aboveWindows) { - if (this->mAboveWindows == aboveWindows) return; - this->mAboveWindows = aboveWindows; - this->updateAboveWindows(); - emit this->aboveWindowsChanged(); -} - -bool XPanelWindow::focusable() const { return this->mFocusable; } - -void XPanelWindow::setFocusable(bool focusable) { - if (this->mFocusable == focusable) return; - this->mFocusable = focusable; - this->updateFocusable(); - emit this->focusableChanged(); + this->updateScreen(); } void XPanelWindow::xInit() { @@ -223,12 +194,17 @@ void XPanelWindow::xInit() { ); } -void XPanelWindow::connectScreen() { +void XPanelWindow::updateScreen() { + auto* newScreen = + this->mScreen ? this->mScreen : (this->window ? this->window->screen() : nullptr); + + if (newScreen == this->mTrackedScreen) return; + if (this->mTrackedScreen != nullptr) { QObject::disconnect(this->mTrackedScreen, nullptr, this, nullptr); } - this->mTrackedScreen = this->mScreen; + this->mTrackedScreen = newScreen; if (this->mTrackedScreen != nullptr) { QObject::connect( @@ -243,7 +219,6 @@ void XPanelWindow::connectScreen() { &QScreen::virtualGeometryChanged, this, &XPanelWindow::onScreenVirtualGeometryChanged - ); } @@ -262,49 +237,48 @@ void XPanelWindow::onScreenVirtualGeometryChanged() { void XPanelWindow::updateDimensionsSlot() { this->updateDimensions(); } void XPanelWindow::updateDimensions(bool propagate) { - if (this->window == nullptr || this->window->handle() == nullptr || this->mScreen == nullptr) + if (this->window == nullptr || this->window->handle() == nullptr + || this->mTrackedScreen == nullptr) return; - auto screenGeometry = this->mScreen->geometry(); + auto screenGeometry = this->mTrackedScreen->geometry(); - if (this->mExclusionMode != ExclusionMode::Ignore) { + if (this->bExclusionMode != ExclusionMode::Ignore) { for (auto* panel: XPanelStack::instance()->panels(this)) { // we only care about windows below us if (panel == this) break; // we only care about windows in the same layer - if (panel->mAboveWindows != this->mAboveWindows) continue; + if (panel->bAboveWindows != this->bAboveWindows) continue; - if (panel->mScreen != this->mScreen) continue; + if (panel->mTrackedScreen != this->mTrackedScreen) continue; - int side = -1; - quint32 exclusiveZone = 0; - panel->getExclusion(side, exclusiveZone); - - if (exclusiveZone == 0) continue; - - auto zone = static_cast(exclusiveZone); + auto edge = panel->bcExclusionEdge.value(); + auto exclusiveZone = panel->bcExclusiveZone.value(); screenGeometry.adjust( - side == 0 ? zone : 0, - side == 2 ? zone : 0, - side == 1 ? -zone : 0, - side == 3 ? -zone : 0 + edge == Qt::LeftEdge ? exclusiveZone : 0, + edge == Qt::TopEdge ? exclusiveZone : 0, + edge == Qt::RightEdge ? -exclusiveZone : 0, + edge == Qt::BottomEdge ? -exclusiveZone : 0 ); } } auto geometry = QRect(); - if (this->mAnchors.horizontalConstraint()) { - geometry.setX(screenGeometry.x() + this->mMargins.left); - geometry.setWidth(screenGeometry.width() - this->mMargins.left - this->mMargins.right); + auto anchors = this->bAnchors.value(); + auto margins = this->bMargins.value(); + + if (anchors.horizontalConstraint()) { + geometry.setX(screenGeometry.x() + margins.left); + geometry.setWidth(screenGeometry.width() - margins.left - margins.right); } else { - if (this->mAnchors.mLeft) { - geometry.setX(screenGeometry.x() + this->mMargins.left); - } else if (this->mAnchors.mRight) { + if (anchors.mLeft) { + geometry.setX(screenGeometry.x() + margins.left); + } else if (anchors.mRight) { geometry.setX( - screenGeometry.x() + screenGeometry.width() - this->implicitWidth() - this->mMargins.right + screenGeometry.x() + screenGeometry.width() - this->implicitWidth() - margins.right ); } else { geometry.setX(screenGeometry.x() + screenGeometry.width() / 2 - this->implicitWidth() / 2); @@ -313,16 +287,15 @@ void XPanelWindow::updateDimensions(bool propagate) { geometry.setWidth(this->implicitWidth()); } - if (this->mAnchors.verticalConstraint()) { - geometry.setY(screenGeometry.y() + this->mMargins.top); - geometry.setHeight(screenGeometry.height() - this->mMargins.top - this->mMargins.bottom); + if (anchors.verticalConstraint()) { + geometry.setY(screenGeometry.y() + margins.top); + geometry.setHeight(screenGeometry.height() - margins.top - margins.bottom); } else { - if (this->mAnchors.mTop) { - geometry.setY(screenGeometry.y() + this->mMargins.top); - } else if (this->mAnchors.mBottom) { + if (anchors.mTop) { + geometry.setY(screenGeometry.y() + margins.top); + } else if (anchors.mBottom) { geometry.setY( - screenGeometry.y() + screenGeometry.height() - this->implicitHeight() - - this->mMargins.bottom + screenGeometry.y() + screenGeometry.height() - this->implicitHeight() - margins.bottom ); } else { geometry.setY(screenGeometry.y() + screenGeometry.height() / 2 - this->implicitHeight() / 2); @@ -351,42 +324,6 @@ void XPanelWindow::updatePanelStack() { } } -void XPanelWindow::getExclusion(int& side, quint32& exclusiveZone) { - if (this->mExclusionMode == ExclusionMode::Ignore) { - exclusiveZone = 0; - return; - } - - auto& anchors = this->mAnchors; - if (anchors.mLeft || anchors.mRight || anchors.mTop || anchors.mBottom) { - if (!anchors.horizontalConstraint() - && (anchors.verticalConstraint() || (!anchors.mTop && !anchors.mBottom))) - { - side = anchors.mLeft ? 0 : anchors.mRight ? 1 : -1; - } else if (!anchors.verticalConstraint() - && (anchors.horizontalConstraint() || (!anchors.mLeft && !anchors.mRight))) - { - side = anchors.mTop ? 2 : anchors.mBottom ? 3 : -1; - } - } - - if (side == -1) return; - - auto autoExclude = this->mExclusionMode == ExclusionMode::Auto; - - if (autoExclude) { - if (side == 0 || side == 1) { - exclusiveZone = - this->implicitWidth() + (side == 0 ? this->mMargins.left : this->mMargins.right); - } else { - exclusiveZone = - this->implicitHeight() + (side == 2 ? this->mMargins.top : this->mMargins.bottom); - } - } else { - exclusiveZone = this->mExclusiveZone; - } -} - // Disable xinerama structs to break multi monitor configurations with bad WMs less. // Usually this results in one monitor at the top left corner of the root window working // perfectly and all others being broken semi randomly. @@ -396,12 +333,10 @@ void XPanelWindow::updateStrut(bool propagate) { if (this->window == nullptr || this->window->handle() == nullptr) return; auto* conn = x11Connection(); - int side = -1; - quint32 exclusiveZone = 0; + auto edge = this->bcExclusionEdge.value(); + auto exclusiveZone = this->bcExclusiveZone.value(); - this->getExclusion(side, exclusiveZone); - - if (side == -1 || this->mExclusionMode == ExclusionMode::Ignore) { + if (edge == 0 || this->bExclusionMode == ExclusionMode::Ignore) { xcb_delete_property(conn, this->window->winId(), XAtom::_NET_WM_STRUT.atom()); xcb_delete_property(conn, this->window->winId(), XAtom::_NET_WM_STRUT_PARTIAL.atom()); return; @@ -409,18 +344,27 @@ void XPanelWindow::updateStrut(bool propagate) { auto rootGeometry = this->window->screen()->virtualGeometry(); auto screenGeometry = this->window->screen()->geometry(); - auto horizontal = side == 0 || side == 1; + auto horizontal = edge == Qt::LeftEdge || edge == Qt::RightEdge; if (XINERAMA_STRUTS) { - switch (side) { - case 0: exclusiveZone += screenGeometry.left(); break; - case 1: exclusiveZone += rootGeometry.right() - screenGeometry.right(); break; - case 2: exclusiveZone += screenGeometry.top(); break; - case 3: exclusiveZone += rootGeometry.bottom() - screenGeometry.bottom(); break; + switch (edge) { + case Qt::LeftEdge: exclusiveZone += screenGeometry.left(); break; + case Qt::RightEdge: exclusiveZone += rootGeometry.right() - screenGeometry.right(); break; + case Qt::TopEdge: exclusiveZone += screenGeometry.top(); break; + case Qt::BottomEdge: exclusiveZone += rootGeometry.bottom() - screenGeometry.bottom(); break; default: break; } } + quint32 side = -1; + + switch (edge) { + case Qt::LeftEdge: side = 0; break; + case Qt::RightEdge: side = 1; break; + case Qt::TopEdge: side = 2; break; + case Qt::BottomEdge: side = 3; break; + } + auto data = std::array(); data[side] = exclusiveZone; @@ -457,13 +401,14 @@ void XPanelWindow::updateStrut(bool propagate) { void XPanelWindow::updateAboveWindows() { if (this->window == nullptr) return; - this->window->setFlag(Qt::WindowStaysOnBottomHint, !this->mAboveWindows); - this->window->setFlag(Qt::WindowStaysOnTopHint, this->mAboveWindows); + auto above = this->bAboveWindows.value(); + this->window->setFlag(Qt::WindowStaysOnBottomHint, !above); + this->window->setFlag(Qt::WindowStaysOnTopHint, above); } void XPanelWindow::updateFocusable() { if (this->window == nullptr) return; - this->window->setFlag(Qt::WindowDoesNotAcceptFocus, !this->mFocusable); + this->window->setFlag(Qt::WindowDoesNotAcceptFocus, !this->bFocusable); } // XPanelInterface @@ -471,23 +416,9 @@ void XPanelWindow::updateFocusable() { XPanelInterface::XPanelInterface(QObject* parent) : PanelWindowInterface(parent) , panel(new XPanelWindow(this)) { + this->connectSignals(); // clang-format off - QObject::connect(this->panel, &ProxyWindowBase::windowConnected, this, &XPanelInterface::windowConnected); - QObject::connect(this->panel, &ProxyWindowBase::visibleChanged, this, &XPanelInterface::visibleChanged); - QObject::connect(this->panel, &ProxyWindowBase::backerVisibilityChanged, this, &XPanelInterface::backingWindowVisibleChanged); - QObject::connect(this->panel, &ProxyWindowBase::implicitHeightChanged, this, &XPanelInterface::implicitHeightChanged); - QObject::connect(this->panel, &ProxyWindowBase::implicitWidthChanged, this, &XPanelInterface::implicitWidthChanged); - QObject::connect(this->panel, &ProxyWindowBase::heightChanged, this, &XPanelInterface::heightChanged); - QObject::connect(this->panel, &ProxyWindowBase::widthChanged, this, &XPanelInterface::widthChanged); - QObject::connect(this->panel, &ProxyWindowBase::devicePixelRatioChanged, this, &XPanelInterface::devicePixelRatioChanged); - QObject::connect(this->panel, &ProxyWindowBase::screenChanged, this, &XPanelInterface::screenChanged); - QObject::connect(this->panel, &ProxyWindowBase::windowTransformChanged, this, &XPanelInterface::windowTransformChanged); - QObject::connect(this->panel, &ProxyWindowBase::colorChanged, this, &XPanelInterface::colorChanged); - QObject::connect(this->panel, &ProxyWindowBase::maskChanged, this, &XPanelInterface::maskChanged); - QObject::connect(this->panel, &ProxyWindowBase::surfaceFormatChanged, this, &XPanelInterface::surfaceFormatChanged); - - // panel specific QObject::connect(this->panel, &XPanelWindow::anchorsChanged, this, &XPanelInterface::anchorsChanged); QObject::connect(this->panel, &XPanelWindow::marginsChanged, this, &XPanelInterface::marginsChanged); QObject::connect(this->panel, &XPanelWindow::exclusiveZoneChanged, this, &XPanelInterface::exclusiveZoneChanged); @@ -504,28 +435,13 @@ void XPanelInterface::onReload(QObject* oldInstance) { this->panel->reload(old != nullptr ? old->panel : nullptr); } -QQmlListProperty XPanelInterface::data() { return this->panel->data(); } ProxyWindowBase* XPanelInterface::proxyWindow() const { return this->panel; } -QQuickItem* XPanelInterface::contentItem() const { return this->panel->contentItem(); } -bool XPanelInterface::isBackingWindowVisible() const { return this->panel->isVisibleDirect(); } -qreal XPanelInterface::devicePixelRatio() const { return this->panel->devicePixelRatio(); } // NOLINTBEGIN #define proxyPair(type, get, set) \ type XPanelInterface::get() const { return this->panel->get(); } \ void XPanelInterface::set(type value) { this->panel->set(value); } -proxyPair(bool, isVisible, setVisible); -proxyPair(qint32, implicitWidth, setImplicitWidth); -proxyPair(qint32, implicitHeight, setImplicitHeight); -proxyPair(qint32, width, setWidth); -proxyPair(qint32, height, setHeight); -proxyPair(QuickshellScreenInfo*, screen, setScreen); -proxyPair(QColor, color, setColor); -proxyPair(PendingRegion*, mask, setMask); -proxyPair(QsSurfaceFormat, surfaceFormat, setSurfaceFormat); - -// panel specific proxyPair(Anchors, anchors, setAnchors); proxyPair(Margins, margins, setMargins); proxyPair(qint32, exclusiveZone, setExclusiveZone); diff --git a/src/x11/panel_window.hpp b/src/x11/panel_window.hpp index d8cc966..ab36826 100644 --- a/src/x11/panel_window.hpp +++ b/src/x11/panel_window.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -8,6 +10,7 @@ #include #include "../core/doc.hpp" +#include "../core/util.hpp" #include "../window/panelinterface.hpp" #include "../window/proxywindow.hpp" @@ -51,23 +54,28 @@ public: void setScreen(QuickshellScreenInfo* screen) override; - [[nodiscard]] Anchors anchors() const; - void setAnchors(Anchors anchors); + [[nodiscard]] bool aboveWindows() const { return this->bAboveWindows; } + void setAboveWindows(bool aboveWindows) { this->bAboveWindows = aboveWindows; } - [[nodiscard]] qint32 exclusiveZone() const; - void setExclusiveZone(qint32 exclusiveZone); + [[nodiscard]] Anchors anchors() const { return this->bAnchors; } + void setAnchors(Anchors anchors) { this->bAnchors = anchors; } - [[nodiscard]] ExclusionMode::Enum exclusionMode() const; - void setExclusionMode(ExclusionMode::Enum exclusionMode); + [[nodiscard]] qint32 exclusiveZone() const { return this->bExclusiveZone; } + void setExclusiveZone(qint32 exclusiveZone) { + Qt::beginPropertyUpdateGroup(); + this->bExclusiveZone = exclusiveZone; + this->bExclusionMode = ExclusionMode::Normal; + Qt::endPropertyUpdateGroup(); + } - [[nodiscard]] Margins margins() const; - void setMargins(Margins margins); + [[nodiscard]] ExclusionMode::Enum exclusionMode() const { return this->bExclusionMode; } + void setExclusionMode(ExclusionMode::Enum exclusionMode) { this->bExclusionMode = exclusionMode; } - [[nodiscard]] bool aboveWindows() const; - void setAboveWindows(bool aboveWindows); + [[nodiscard]] Margins margins() const { return this->bMargins; } + void setMargins(Margins margins) { this->bMargins = margins; } - [[nodiscard]] bool focusable() const; - void setFocusable(bool focusable); + [[nodiscard]] bool focusable() const { return this->bFocusable; } + void setFocusable(bool focusable) { this->bFocusable = focusable; } signals: QSDOC_HIDE void anchorsChanged(); @@ -84,24 +92,37 @@ private slots: void onScreenVirtualGeometryChanged(); private: - void connectScreen(); - void getExclusion(int& side, quint32& exclusiveZone); + void updateScreen(); void updateStrut(bool propagate = true); + void updateStrutCb() { this->updateStrut(); } void updateAboveWindows(); void updateFocusable(); void updateDimensions(bool propagate = true); + void updateDimensionsCb() { this->updateDimensions(); } QPointer mTrackedScreen = nullptr; - bool mAboveWindows = true; - bool mFocusable = false; - Anchors mAnchors; - Margins mMargins; - qint32 mExclusiveZone = 0; - ExclusionMode::Enum mExclusionMode = ExclusionMode::Auto; + EngineGeneration* knownGeneration = nullptr; QRect lastScreenVirtualGeometry; XPanelEventFilter eventFilter; + // clang-format off + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(XPanelWindow, bool, bAboveWindows, true, &XPanelWindow::aboveWindowsChanged); + Q_OBJECT_BINDABLE_PROPERTY(XPanelWindow, bool, bFocusable, &XPanelWindow::focusableChanged); + Q_OBJECT_BINDABLE_PROPERTY(XPanelWindow, Anchors, bAnchors, &XPanelWindow::anchorsChanged); + Q_OBJECT_BINDABLE_PROPERTY(XPanelWindow, Margins, bMargins, &XPanelWindow::marginsChanged); + Q_OBJECT_BINDABLE_PROPERTY(XPanelWindow, qint32, bExclusiveZone, &XPanelWindow::exclusiveZoneChanged); + Q_OBJECT_BINDABLE_PROPERTY_WITH_ARGS(XPanelWindow, ExclusionMode::Enum, bExclusionMode, ExclusionMode::Auto, &XPanelWindow::exclusionModeChanged); + Q_OBJECT_BINDABLE_PROPERTY(XPanelWindow, qint32, bcExclusiveZone); + Q_OBJECT_BINDABLE_PROPERTY(XPanelWindow, Qt::Edge, bcExclusionEdge); + + QS_BINDING_SUBSCRIBE_METHOD(XPanelWindow, bAboveWindows, updateAboveWindows, onValueChanged); + QS_BINDING_SUBSCRIBE_METHOD(XPanelWindow, bAnchors, updateDimensionsCb, onValueChanged); + QS_BINDING_SUBSCRIBE_METHOD(XPanelWindow, bMargins, updateDimensionsCb, onValueChanged); + QS_BINDING_SUBSCRIBE_METHOD(XPanelWindow, bcExclusiveZone, updateStrutCb, onValueChanged); + QS_BINDING_SUBSCRIBE_METHOD(XPanelWindow, bFocusable, updateFocusable, onValueChanged); + // clang-format on + friend class XPanelStack; }; @@ -114,43 +135,8 @@ public: void onReload(QObject* oldInstance) override; [[nodiscard]] ProxyWindowBase* proxyWindow() const override; - [[nodiscard]] QQuickItem* contentItem() const override; // NOLINTBEGIN - [[nodiscard]] bool isVisible() const override; - [[nodiscard]] bool isBackingWindowVisible() const override; - void setVisible(bool visible) override; - - [[nodiscard]] qint32 implicitWidth() const override; - void setImplicitWidth(qint32 implicitWidth) override; - - [[nodiscard]] qint32 implicitHeight() const override; - void setImplicitHeight(qint32 implicitHeight) override; - - [[nodiscard]] qint32 width() const override; - void setWidth(qint32 width) override; - - [[nodiscard]] qint32 height() const override; - void setHeight(qint32 height) override; - - [[nodiscard]] virtual qreal devicePixelRatio() const override; - - [[nodiscard]] QuickshellScreenInfo* screen() const override; - void setScreen(QuickshellScreenInfo* screen) override; - - [[nodiscard]] QColor color() const override; - void setColor(QColor color) override; - - [[nodiscard]] PendingRegion* mask() const override; - void setMask(PendingRegion* mask) override; - - [[nodiscard]] QsSurfaceFormat surfaceFormat() const override; - void setSurfaceFormat(QsSurfaceFormat mask) override; - - [[nodiscard]] QQmlListProperty data() override; - - // panel specific - [[nodiscard]] Anchors anchors() const override; void setAnchors(Anchors anchors) override;