diff --git a/.clang-tidy b/.clang-tidy
index 6642fa76..002c444d 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -5,6 +5,9 @@ Checks: >
-*,
bugprone-*,
-bugprone-easily-swappable-parameters,
+ -bugprone-forward-declararion-namespace,
+ -bugprone-forward-declararion-namespace,
+ -bugprone-return-const-ref-from-parameter,
concurrency-*,
cppcoreguidelines-*,
-cppcoreguidelines-owning-memory,
@@ -12,8 +15,11 @@ Checks: >
-cppcoreguidelines-pro-bounds-constant-array-index,
-cppcoreguidelines-avoid-const-or-ref-data-members,
-cppcoreguidelines-non-private-member-variables-in-classes,
- google-build-using-namespace.
- google-explicit-constructor,
+ -cppcoreguidelines-avoid-goto,
+ -cppcoreguidelines-pro-bounds-array-to-pointer-decay,
+ -cppcoreguidelines-avoid-do-while,
+ -cppcoreguidelines-pro-type-reinterpret-cast,
+ -cppcoreguidelines-pro-type-vararg,
google-global-names-in-headers,
google-readability-casting,
google-runtime-int,
@@ -25,6 +31,7 @@ Checks: >
-modernize-return-braced-init-list,
-modernize-use-trailing-return-type,
performance-*,
+ -performance-avoid-endl,
portability-std-allocator-const,
readability-*,
-readability-function-cognitive-complexity,
@@ -35,6 +42,10 @@ Checks: >
-readability-braces-around-statements,
-readability-redundant-access-specifiers,
-readability-else-after-return,
+ -readability-container-data-pointer,
+ -readability-implicit-bool-conversion,
+ -readability-avoid-nested-conditional-operator,
+ -readability-math-missing-parentheses,
tidyfox-*,
CheckOptions:
performance-for-range-copy.WarnOnAllAutoCopies: true
diff --git a/.editorconfig b/.editorconfig
index 6b1b58df..9de26e09 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -9,3 +9,10 @@ indent_style = tab
[*.nix]
indent_style = space
indent_size = 2
+
+[*.{yml,yaml}]
+indent_style = space
+indent_size = 2
+
+[*.scm]
+indent_style = space
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..0086358d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: true
diff --git a/.github/ISSUE_TEMPLATE/crash.yml b/.github/ISSUE_TEMPLATE/crash.yml
new file mode 100644
index 00000000..c8b4804e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/crash.yml
@@ -0,0 +1,82 @@
+name: Crash Report
+description: Quickshell has crashed
+labels: ["bug", "crash"]
+body:
+ - type: textarea
+ id: crashinfo
+ 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
+ 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.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000..93b84585
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,56 @@
+name: Build
+on: [push, pull_request, workflow_dispatch]
+
+jobs:
+ nix:
+ 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]
+ compiler: [clang, gcc]
+ runs-on: ubuntu-latest
+ 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
+
+ - name: Download Dependencies
+ run: nix-build --no-out-link --expr '((import ./ci/matrix.nix) { qtver = "${{ matrix.qtver }}"; compiler = "${{ matrix.compiler }}"; }).inputDerivation'
+
+ - name: Build
+ run: nix-build --no-out-link --expr '(import ./ci/matrix.nix) { qtver = "${{ matrix.qtver }}"; compiler = "${{ matrix.compiler }}"; }'
+
+ archlinux:
+ name: Archlinux
+ runs-on: ubuntu-latest
+ container: archlinux
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Download Dependencies
+ run: |
+ pacman --noconfirm --noprogressbar -Syyu
+ pacman --noconfirm --noprogressbar -Sy \
+ base-devel \
+ cmake \
+ ninja \
+ pkgconf \
+ qt6-base \
+ qt6-declarative \
+ qt6-svg \
+ qt6-wayland \
+ qt6-shadertools \
+ wayland-protocols \
+ wayland \
+ libdrm \
+ libxcb \
+ libpipewire \
+ cli11 \
+ jemalloc
+
+ - 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 --build build
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..da329cc2
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,25 @@
+name: Lint
+on: [push, pull_request, workflow_dispatch]
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ 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: nicknovitski/nix-develop@v1
+
+ - name: Check formatting
+ run: clang-format -Werror --dry-run src/**/*.{cpp,hpp}
+
+ # required for lint
+ - name: Build
+ run: |
+ just configure debug -DNO_PCH=ON -DBUILD_TESTING=ON
+ just build
+
+ - name: Run lints
+ run: LC_ALL=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 just lint-ci
diff --git a/.gitignore b/.gitignore
index 1933837e..dcdefe39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
+# related repos
+/docs
+/examples
+
# build files
/result
/build/
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 74013769..00000000
--- a/.gitmodules
+++ /dev/null
@@ -1,6 +0,0 @@
-[submodule "docs"]
- path = docs
- url = https://git.outfoxxed.me/outfoxxed/quickshell-docs
-[submodule "examples"]
- path = examples
- url = https://git.outfoxxed.me/outfoxxed/quickshell-examples
diff --git a/BUILD.md b/BUILD.md
new file mode 100644
index 00000000..aa7c98ae
--- /dev/null
+++ b/BUILD.md
@@ -0,0 +1,251 @@
+# Build instructions
+Instructions for building from source and distro packagers. We highly recommend
+distro packagers read through this page fully.
+
+## Packaging
+If you are packaging quickshell for official or unofficial distribution channels,
+such as a distro package repository, user repository, or other shared build location,
+please set the following CMake flags.
+
+`-DDISTRIBUTOR="your distribution platform"`
+
+Please make this descriptive enough to identify your specific package, for example:
+- `Official Nix Flake`
+- `AUR (quickshell-git)`
+- `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).
+
+### QML Module dir
+Currently all QML modules are statically linked to quickshell, but this is where
+tooling information will go.
+
+`-DINSTALL_QML_PREFIX="path/to/qml"`
+
+`-DINSTALL_QMLDIR="/full/path/to/qml"`
+
+`INSTALL_QML_PREFIX` works the same as `INSTALL_QMLDIR`, except it prepends `CMAKE_INSTALL_PREFIX`. You usually want this.
+
+## Dependencies
+Quickshell has a set of base dependencies you will always need, names vary by distro:
+
+- `cmake`
+- `qt6base`
+- `qt6declarative`
+- `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`
+
+We recommend an implicit dependency on `qt6svg`. If it is not installed, svg images and
+svg icons will not work, including system ones.
+
+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,
+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`
+
+Dependencies: `google-breakpad` (static library)
+
+### Jemalloc
+We recommend leaving Jemalloc enabled as it will mask memory fragmentation caused
+by the QML engine, which results in much lower memory usage. Without this you
+will get a perceived memory leak.
+
+To disable: `-DUSE_JEMALLOC=OFF`
+
+Dependencies: `jemalloc`
+
+### Unix Sockets
+This feature allows interaction with unix sockets and creating socket servers
+which is useful for IPC and has no additional dependencies.
+
+WARNING: Disabling unix sockets will NOT make it safe to run arbitrary code using quickshell.
+There are many vectors which mallicious code can use to escape into your system.
+
+To disable: `-DSOCKETS=OFF`
+
+### Wayland
+This feature enables wayland support. Subfeatures exist for each particular wayland integration.
+
+WARNING: Wayland integration relies on features that are not part of the public Qt API and which
+may break in minor releases. Updating quickshell's dependencies without ensuring without ensuring
+that the current Qt version is supported WILL result in quickshell failing to build or misbehaving
+at runtime.
+
+Currently supported Qt versions: `6.6`, `6.7`.
+
+To disable: `-DWAYLAND=OFF`
+
+Dependencies:
+ - `qt6wayland`
+ - `wayland` (libwayland-client)
+ - `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,
+enabling use cases such as bars overlays and backgrounds.
+This feature has no extra dependencies.
+
+To disable: `-DWAYLAND_WLR_LAYERSHELL=OFF`
+
+[zwlr-layer-shell-v1]: https://wayland.app/protocols/wlr-layer-shell-unstable-v1
+
+#### Session Lock
+Enables session lock support through the [ext-session-lock-v1] protocol,
+which allows quickshell to be used as a session lock under compatible wayland compositors.
+
+To disable: `-DWAYLAND_SESSION_LOCK=OFF`
+
+[ext-session-lock-v1]: https://wayland.app/protocols/ext-session-lock-v1
+
+
+#### Foreign Toplevel Management
+Enables management of windows of other clients through the [zwlr-foreign-toplevel-management-v1] protocol,
+which allows quickshell to be used as a session lock under compatible wayland compositors.
+
+[zwlr-foreign-toplevel-management-v1]: https://wayland.app/protocols/wlr-foreign-toplevel-management-unstable-v1
+
+To disable: `-DWAYLAND_TOPLEVEL_MANAGEMENT=OFF`
+
+#### Screencopy
+Enables streaming video from monitors and toplevel windows through various protocols.
+
+To disable: `-DSCREENCOPY=OFF`
+
+Dependencies:
+- `libdrm`
+- `libgbm`
+
+Specific protocols can also be disabled:
+- `DSCREENCOPY_ICC=OFF` - Disable screencopy via [ext-image-copy-capture-v1]
+- `DSCREENCOPY_WLR=OFF` - Disable screencopy via [zwlr-screencopy-v1]
+- `DSCREENCOPY_HYPRLAND_TOPLEVEL=OFF` - Disable screencopy via [hyprland-toplevel-export-v1]
+
+[ext-image-copy-capture-v1]:https://wayland.app/protocols/ext-image-copy-capture-v1
+[zwlr-screencopy-v1]: https://wayland.app/protocols/wlr-screencopy-unstable-v1
+[hyprland-toplevel-export-v1]: https://wayland.app/protocols/hyprland-toplevel-export-v1
+
+### X11
+This feature enables x11 support. Currently this implements panel windows for X11 similarly
+to the wlroots layershell above.
+
+To disable: `-DX11=OFF`
+
+Dependencies: `libxcb`
+
+### Pipewire
+This features enables viewing and management of pipewire nodes.
+
+To disable: `-DSERVICE_PIPEWIRE=OFF`
+
+Dependencies: `libpipewire`
+
+### StatusNotifier / System Tray
+This feature enables system tray support using the status notifier dbus protocol.
+
+To disable: `-DSERVICE_STATUS_NOTIFIER=OFF`
+
+Dependencies: `qt6dbus` (usually part of qt6base)
+
+### MPRIS
+This feature enables access to MPRIS compatible media players using its dbus protocol.
+
+To disable: `-DSERVICE_MPRIS=OFF`
+
+Dependencies: `qt6dbus` (usually part of qt6base)
+
+### PAM
+This feature enables PAM integration for user authentication.
+
+To disable: `-DSERVICE_PAM=OFF`
+
+Dependencies: `pam`
+
+### Hyprland
+This feature enables hyprland specific integrations. It requires wayland support
+but has no extra dependencies.
+
+To disable: `-DHYPRLAND=OFF`
+
+#### Hyprland Global Shortcuts
+Enables creation of global shortcuts under hyprland through the [hyprland-global-shortcuts-v1]
+protocol. Generally a much nicer alternative to using unix sockets to implement the same thing.
+This feature has no extra dependencies.
+
+To disable: `-DHYPRLAND_GLOBAL_SHORTCUTS=OFF`
+
+[hyprland-global-shortcuts-v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-global-shortcuts-v1.xml
+
+#### Hyprland Focus Grab
+Enables windows to grab focus similarly to a context menu under hyprland through the
+[hyprland-focus-grab-v1] protocol. This feature has no extra dependencies.
+
+To disable: `-DHYPRLAND_FOCUS_GRAB=OFF`
+
+[hyprland-focus-grab-v1]: https://github.com/hyprwm/hyprland-protocols/blob/main/protocols/hyprland-focus-grab-v1.xml
+
+### i3/Sway
+Enables i3 and Sway specific features, does not have any dependency on Wayland or x11.
+
+To disable: `-DI3=OFF`
+
+#### i3/Sway IPC
+Enables interfacing with i3 and Sway's IPC.
+
+To disable: `-DI3_IPC=OFF`
+
+## Building
+*For developers and prospective contributors: See [CONTRIBUTING.md](CONTRIBUTING.md).*
+
+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]
+```
+
+Note that features you do not supply dependencies for MUST be disabled with their associated flags
+or quickshell will fail to build.
+
+Additionally, note that clang builds much faster than gcc if you care.
+
+#### Building
+```sh
+$ cmake --build build
+```
+
+#### Installing
+```sh
+$ cmake --install build
+```
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 67f8a1fe..55b5e5d5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,35 +1,93 @@
cmake_minimum_required(VERSION 3.20)
-project(quickshell VERSION "0.1.0")
+project(quickshell VERSION "0.2.0" LANGUAGES CXX C)
set(QT_MIN_VERSION "6.6.0")
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
-option(TESTS "Build tests" OFF)
+set(QS_BUILD_OPTIONS "")
-option(SOCKETS "Enable unix socket support" ON)
-option(WAYLAND "Enable wayland support" ON)
-option(WAYLAND_WLR_LAYERSHELL "Support the zwlr_layer_shell_v1 wayland protocol" ON)
-option(WAYLAND_SESSION_LOCK "Support the ext_session_lock_v1 wayland protocol" ON)
+function(boption VAR NAME DEFAULT)
+ cmake_parse_arguments(PARSE_ARGV 3 arg "" "REQUIRES" "")
+
+ option(${VAR} ${NAME} ${DEFAULT})
+
+ set(STATUS "${VAR}_status")
+ set(EFFECTIVE "${VAR}_effective")
+ set(${STATUS} ${${VAR}})
+ set(${EFFECTIVE} ${${VAR}})
+
+ if (${${VAR}} AND DEFINED arg_REQUIRES)
+ set(REQUIRED_EFFECTIVE "${arg_REQUIRES}_effective")
+ if (NOT ${${REQUIRED_EFFECTIVE}})
+ set(${STATUS} "OFF (Requires ${arg_REQUIRES})")
+ set(${EFFECTIVE} OFF)
+ endif()
+ endif()
+
+ set(${EFFECTIVE} "${${EFFECTIVE}}" PARENT_SCOPE)
+
+ message(STATUS " ${NAME}: ${${STATUS}}")
+
+ string(APPEND QS_BUILD_OPTIONS "\\n ${NAME}: ${${STATUS}}")
+ set(QS_BUILD_OPTIONS "${QS_BUILD_OPTIONS}" PARENT_SCOPE)
+endfunction()
+
+set(DISTRIBUTOR "Unset" CACHE STRING "Distributor")
+string(APPEND QS_BUILD_OPTIONS " Distributor: ${DISTRIBUTOR}")
message(STATUS "Quickshell configuration")
-message(STATUS " Build tests: ${BUILD_TESTING}")
-message(STATUS " Sockets: ${SOCKETS}")
-message(STATUS " Wayland: ${WAYLAND}")
-if (WAYLAND)
- message(STATUS " Wlroots Layershell: ${WAYLAND_WLR_LAYERSHELL}")
- message(STATUS " Session Lock: ${WAYLAND_SESSION_LOCK}")
-endif ()
+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})
-if (NOT DEFINED GIT_REVISION)
- execute_process(
- COMMAND git rev-parse HEAD
- WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
- OUTPUT_VARIABLE GIT_REVISION
- )
+boption(CRASH_REPORTER "Crash Handling" ON)
+boption(USE_JEMALLOC "Use jemalloc" ON)
+boption(SOCKETS "Unix Sockets" ON)
+boption(WAYLAND "Wayland" ON)
+boption(WAYLAND_WLR_LAYERSHELL " Wlroots Layer-Shell" ON REQUIRES WAYLAND)
+boption(WAYLAND_SESSION_LOCK " Session Lock" ON REQUIRES WAYLAND)
+boption(WAYLAND_TOPLEVEL_MANAGEMENT " Foreign Toplevel Management" ON REQUIRES WAYLAND)
+boption(HYPRLAND " Hyprland" ON REQUIRES WAYLAND)
+boption(HYPRLAND_IPC " Hyprland IPC" ON REQUIRES HYPRLAND)
+boption(HYPRLAND_GLOBAL_SHORTCUTS " Hyprland Global Shortcuts" ON REQUIRES HYPRLAND)
+boption(HYPRLAND_FOCUS_GRAB " Hyprland Focus Grabbing" ON REQUIRES HYPRLAND)
+boption(HYPRLAND_SURFACE_EXTENSIONS " Hyprland Surface Extensions" ON REQUIRES HYPRLAND)
+boption(SCREENCOPY " Screencopy" ON REQUIRES WAYLAND)
+boption(SCREENCOPY_ICC " Image Copy Capture" ON REQUIRES WAYLAND)
+boption(SCREENCOPY_WLR " Wlroots Screencopy" ON REQUIRES WAYLAND)
+boption(SCREENCOPY_HYPRLAND_TOPLEVEL " Hyprland Toplevel Export" ON REQUIRES WAYLAND)
+boption(X11 "X11" ON)
+boption(I3 "I3/Sway" ON)
+boption(I3_IPC " I3/Sway IPC" ON REQUIRES I3)
+boption(SERVICE_STATUS_NOTIFIER "System Tray" ON)
+boption(SERVICE_PIPEWIRE "PipeWire" ON)
+boption(SERVICE_MPRIS "Mpris" ON)
+boption(SERVICE_PAM "Pam" ON)
+boption(SERVICE_GREETD "Greetd" ON)
+boption(SERVICE_UPOWER "UPower" ON)
+boption(SERVICE_NOTIFICATIONS "Notifications" ON)
+boption(BLUETOOTH "Bluetooth" 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
+add_compile_definitions(_REENTRANT)
+
+if (FRAME_POINTERS)
+ add_compile_options(-fno-omit-frame-pointer)
endif()
-add_compile_options(-Wall -Wextra)
+if (ASAN)
+ add_compile_options(-fsanitize=address)
+ add_link_options(-fsanitize=address)
+endif()
# nix workaround
if (CMAKE_EXPORT_COMPILE_COMMANDS)
@@ -41,34 +99,61 @@ if (NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Debug)
endif()
-set(QT_DEPS Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2)
-set(QT_FPDEPS Gui Qml Quick QuickControls2)
+set(QT_FPDEPS Gui Qml Quick QuickControls2 Widgets ShaderTools)
+
+include(cmake/pch.cmake)
if (BUILD_TESTING)
enable_testing()
+ add_definitions(-DQS_TEST)
list(APPEND QT_FPDEPS Test)
endif()
if (SOCKETS)
- list(APPEND QT_DEPS Qt6::Network)
list(APPEND QT_FPDEPS Network)
endif()
if (WAYLAND)
- list(APPEND QT_DEPS Qt6::WaylandClient Qt6::WaylandClientPrivate)
list(APPEND QT_FPDEPS WaylandClient)
endif()
+if (SERVICE_STATUS_NOTIFIER OR SERVICE_MPRIS OR SERVICE_UPOWER OR SERVICE_NOTIFICATIONS OR BLUETOOTH)
+ set(DBUS ON)
+endif()
+
+if (DBUS)
+ list(APPEND QT_FPDEPS DBus)
+endif()
+
find_package(Qt6 REQUIRED COMPONENTS ${QT_FPDEPS})
+set(CMAKE_AUTOUIC OFF)
qt_standard_project_setup(REQUIRES 6.6)
set(QT_QML_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/qml_modules)
-add_subdirectory(src/core)
-add_subdirectory(src/io)
+add_subdirectory(src)
-if (WAYLAND)
- add_subdirectory(src/wayland)
-endif ()
+if (USE_JEMALLOC)
+ find_package(PkgConfig REQUIRED)
+ # IMPORTED_TARGET not working for some reason
+ pkg_check_modules(JEMALLOC REQUIRED jemalloc)
+ target_link_libraries(quickshell PRIVATE ${JEMALLOC_LIBRARIES})
+endif()
-install(TARGETS quickshell RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
+install(CODE "
+ execute_process(
+ COMMAND ${CMAKE_COMMAND} -E create_symlink \
+ ${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
new file mode 100644
index 00000000..39fab13e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,235 @@
+# Contributing / Development
+Instructions for development setup and upstreaming patches.
+
+If you just want to build or package quickshell see [BUILD.md](BUILD.md).
+
+## 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.
+
+Quickshell also uses `just` for common development command aliases.
+
+The dependencies are also available as a nix shell or nix flake which we recommend
+using with nix-direnv.
+
+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);
+};
+```
+
+### 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.
diff --git a/Justfile b/Justfile
index 314bcdd5..f60771aa 100644
--- a/Justfile
+++ b/Justfile
@@ -4,7 +4,13 @@ fmt:
find src -type f \( -name "*.cpp" -o -name "*.hpp" \) -print0 | xargs -0 clang-format -i
lint:
- find src -type f -name "*.cpp" -print0 | parallel -q0 --eta clang-tidy --load={{ env_var("TIDYFOX") }}
+ find src -type f -name "*.cpp" -print0 | parallel -j$(nproc) -q0 --no-notice --will-cite --tty --bar clang-tidy --load={{ env_var("TIDYFOX") }}
+
+lint-ci:
+ find src -type f -name "*.cpp" -print0 | parallel -j$(nproc) -q0 --no-notice --will-cite --tty clang-tidy --load={{ env_var("TIDYFOX") }}
+
+lint-changed:
+ git diff --name-only 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}} \
@@ -26,7 +32,7 @@ clean:
rm -rf {{builddir}}
run *ARGS='': build
- {{builddir}}/src/core/quickshell {{ARGS}}
+ {{builddir}}/src/quickshell {{ARGS}}
test *ARGS='': build
ctest --test-dir {{builddir}} --output-on-failure {{ARGS}}
diff --git a/README.md b/README.md
index e075a322..4491d24b 100644
--- a/README.md
+++ b/README.md
@@ -1,107 +1,13 @@
-# quickshell
+# Quickshell
+See the [website](https://quickshell.outfoxxed.me) for more information
+and installation instructions.
-Simple and flexbile QtQuick based desktop shell toolkit.
+This repo is hosted at:
+- https://git.outfoxxed.me/quickshell/quickshell
+- https://github.com/quickshell-mirror/quickshell
-Hosts: [outfoxxed's gitea], [github]
-
-[outfoxxed's gitea]: https://git.outfoxxed.me/outfoxxed/quickshell
-[github]: https://github.com/outfoxxed/quickshell
-
-Documentation can be built from the [quickshell-docs](https://git.outfoxxed.me/outfoxxed/quickshell-docs) repo,
-though is currently pretty lacking.
-
-Some fully working examples can be found in the [quickshell-examples](https://git.outfoxxed.me/outfoxxed/quickshell-examples)
-repo.
-
-Both the documentation and examples are included as submodules with revisions that work with the current
-version of quickshell.
-
-You can clone everything with
-```
-$ git clone --recursive https://git.outfoxxed.me/outfoxxed/quickshell.git
-```
-
-Or clone missing submodules later with
-```
-$ git submodule update --init --recursive
-```
-
-# Installation
-
-## Nix
-This repo has a nix flake you can use to install the package directly:
-
-```nix
-{
- inputs = {
- nixpkgs.url = "nixpkgs/nixos-unstable";
-
- quickshell = {
- url = "git+https://git.outfoxxed.me/outfoxxed/quickshell";
- inputs.nixpkgs.follows = "nixpkgs";
- };
- };
-}
-```
-
-Quickshell's binary is available at `quickshell.packages..default` to be added to
-lists such as `environment.systemPackages` or `home.packages`.
-
-## Manual
-
-If not using nix, you'll have to build from source.
-
-### Dependencies
-To build quickshell at all, you will need the following packages (names may vary by distro)
-
-- just
-- cmake
-- pkg-config
-- ninja
-- Qt6 [ QtBase, QtDeclarative ]
-
-To build with wayland support you will additionally need:
-- wayland
-- wayland-scanner (may be part of wayland on some distros)
-- wayland-protocols
-- Qt6 [ QtWayland ]
-
-### Building
-
-To make a release build of quickshell run:
-```sh
-$ just release
-```
-
-If you have all the dependencies installed and they are in expected
-locations this will build correctly.
-
-To install to /usr/local/bin run as root (usually `sudo`) in the same folder:
-```
-$ just install
-```
-
-### Building (Nix)
-
-You can build directly using the provided nix flake or nix package.
-```
-nix build
-nix build -f package.nix # calls default.nix with a basic callPackage expression
-```
-
-# Development
-
-For nix there is a devshell available from `shell.nix` and as a devShell
-output from the flake.
-
-The Justfile contains various useful aliases:
-- `just configure [ [extra cmake args]]`
-- `just build` (runs configure for debug mode)
-- `just run [args]`
-- `just clean`
-- `just test [args]` (configure with `-DBUILD_TESTING=ON` first)
-- `just fmt`
-- `just lint`
+# Contributing / Development
+See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
#### License
diff --git a/assets/org.quickshell.desktop b/assets/org.quickshell.desktop
new file mode 100644
index 00000000..63f65fd9
--- /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 00000000..7d0f9481
--- /dev/null
+++ b/assets/quickshell.svg
@@ -0,0 +1 @@
+
diff --git a/changelog/v0.1.0.md b/changelog/v0.1.0.md
new file mode 100644
index 00000000..f8a032f2
--- /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 00000000..2fbf74d7
--- /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/ci/matrix.nix b/ci/matrix.nix
new file mode 100644
index 00000000..be2da616
--- /dev/null
+++ b/ci/matrix.nix
@@ -0,0 +1,8 @@
+{
+ qtver,
+ compiler,
+}: let
+ nixpkgs = (import ./nix-checkouts.nix).${builtins.replaceStrings ["."] ["_"] qtver};
+ compilerOverride = (nixpkgs.callPackage ./variations.nix {}).${compiler};
+ pkg = (nixpkgs.callPackage ../default.nix {}).override compilerOverride;
+in pkg
diff --git a/ci/nix-checkouts.nix b/ci/nix-checkouts.nix
new file mode 100644
index 00000000..73c24156
--- /dev/null
+++ b/ci/nix-checkouts.nix
@@ -0,0 +1,78 @@
+let
+ byCommit = {
+ commit,
+ sha256,
+ }: import (builtins.fetchTarball {
+ name = "nixpkgs-${commit}";
+ 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.
+
+ qt6_9_0 = byCommit {
+ commit = "546c545bd0594809a28ab7e869b5f80dd7243ef6";
+ sha256 = "0562lbi67a9brfwzpqs4n3l0i8zvgla368aakcy5mghr7ps80567";
+ };
+
+ qt6_8_3 = byCommit {
+ commit = "374e6bcc403e02a35e07b650463c01a52b13a7c8";
+ sha256 = "1ck2d7q1f6k58qg47bc07036h9gmc2mqmqlgrv67k3frgplfhfga";
+ };
+
+ qt6_8_2 = byCommit {
+ commit = "97be9fbfc7a8a794bb51bd5dfcbfad5fad860512";
+ sha256 = "1sqh6kb8yg9yw6brkkb3n4y3vpbx8fnx45skyikqdqj2xs76v559";
+ };
+
+ qt6_8_1 = byCommit {
+ commit = "4a66c00fcb3f85ddad658b8cfa2e870063ce60b5";
+ sha256 = "1fcvr67s7366bk8czzwhr12zsq60izl5iq4znqbm44pzyq9pf8rq";
+ };
+
+ qt6_8_0 = byCommit {
+ commit = "352f462ad9d2aa2cde75fdd8f1734e86402a3ff6";
+ sha256 = "02zfgkr9fpd6iwfh6dcr3m6fnx61jppm3v081f3brvkqwmmz7zq1";
+ };
+
+ qt6_7_3 = byCommit {
+ commit = "273673e839189c26130d48993d849a84199523e6";
+ sha256 = "0aca369hdxb8j0vx9791anyzy4m65zckx0lriicqhp95kv9q6m7z";
+ };
+
+ qt6_7_2 = byCommit {
+ commit = "841f166ff96fc2f3ecd1c0cc08072633033d41bf";
+ sha256 = "0d7p0cp7zjiadhpa6sdafxvrpw4lnmb1h673w17q615vm1yaasvy";
+ };
+
+ qt6_7_1 = byCommit {
+ commit = "69bee9866a4e2708b3153fdb61c1425e7857d6b8";
+ sha256 = "1an4sha4jsa29dvc4n9mqxbq8jjwg7frl0rhy085g73m7l1yx0lj";
+ };
+
+ qt6_7_0 = byCommit {
+ commit = "4fbbc17ccf11bc80002b19b31387c9c80276f076";
+ sha256 = "09lhgdqlx8j9a7vpdcf8sddlhbzjq0s208spfmxfjdn14fvx8k0j";
+ };
+
+ qt6_6_3 = byCommit {
+ commit = "8f1a3fbaa92f1d59b09f2d24af6a607b5a280071";
+ sha256 = "0322zwxvmg8v2wkm03xpk6mqmmbfjgrhc9prcx0zd36vjl6jmi18";
+ };
+
+ qt6_6_2 = byCommit {
+ commit = "0bb9cfbd69459488576a0ef3c0e0477bedc3a29e";
+ sha256 = "172ww486jm1mczk9id78s32p7ps9m9qgisml286flc8jffb6yad8";
+ };
+
+ qt6_6_1 = byCommit {
+ commit = "8eecc3342103c38eea666309a7c0d90d403a039a";
+ sha256 = "1lakc0immsgrpz3basaysdvd0sx01r0mcbyymx6id12fk0404z5r";
+ };
+
+ qt6_6_0 = byCommit {
+ commit = "1ded005f95a43953112ffc54b39593ea2f16409f";
+ sha256 = "1xvyd3lj81hak9j53mrhdsqx78x5v2ppv8m2s54qa2099anqgm0f";
+ };
+}
diff --git a/ci/variations.nix b/ci/variations.nix
new file mode 100644
index 00000000..b0889be6
--- /dev/null
+++ b/ci/variations.nix
@@ -0,0 +1,7 @@
+{
+ clangStdenv,
+ gccStdenv,
+}: {
+ clang = { buildStdenv = clangStdenv; };
+ gcc = { buildStdenv = gccStdenv; };
+}
diff --git a/cmake/install-qml-module.cmake b/cmake/install-qml-module.cmake
new file mode 100644
index 00000000..5c95531c
--- /dev/null
+++ b/cmake/install-qml-module.cmake
@@ -0,0 +1,89 @@
+set(INSTALL_QMLDIR "" CACHE STRING "QML install dir")
+set(INSTALL_QML_PREFIX "" CACHE STRING "QML install prefix")
+
+# There doesn't seem to be a standard cross-distro qml install path.
+if ("${INSTALL_QMLDIR}" STREQUAL "" AND "${INSTALL_QML_PREFIX}" STREQUAL "")
+ message(WARNING "Neither INSTALL_QMLDIR nor INSTALL_QML_PREFIX is set. QML modules will not be installed.")
+else()
+ if ("${INSTALL_QMLDIR}" STREQUAL "")
+ set(QML_FULL_INSTALLDIR "${CMAKE_INSTALL_PREFIX}/${INSTALL_QML_PREFIX}")
+ else()
+ set(QML_FULL_INSTALLDIR "${INSTALL_QMLDIR}")
+ endif()
+
+ message(STATUS "QML install dir: ${QML_FULL_INSTALLDIR}")
+endif()
+
+# Install a given target as a QML module. This is mostly pulled from ECM, as there does not seem
+# to be an official way to do it.
+# see https://github.com/KDE/extra-cmake-modules/blob/fe0f606bf7f222e36f7560fd7a2c33ef993e23bb/modules/ECMQmlModule6.cmake#L160
+function(install_qml_module arg_TARGET)
+ if (NOT DEFINED QML_FULL_INSTALLDIR)
+ return()
+ endif()
+
+ qt_query_qml_module(${arg_TARGET}
+ URI module_uri
+ VERSION module_version
+ PLUGIN_TARGET module_plugin_target
+ TARGET_PATH module_target_path
+ QMLDIR module_qmldir
+ TYPEINFO module_typeinfo
+ QML_FILES module_qml_files
+ RESOURCES module_resources
+ )
+
+ set(module_dir "${QML_FULL_INSTALLDIR}/${module_target_path}")
+
+ if (NOT TARGET "${module_plugin_target}")
+ message(FATAL_ERROR "install_qml_modules called for a target without a plugin")
+ endif()
+
+ get_target_property(target_type "${arg_TARGET}" TYPE)
+ if (NOT "${target_type}" STREQUAL "STATIC_LIBRARY")
+ install(
+ TARGETS "${arg_TARGET}"
+ LIBRARY DESTINATION "${module_dir}"
+ RUNTIME DESTINATION "${module_dir}"
+ )
+
+ install(
+ TARGETS "${module_plugin_target}"
+ LIBRARY DESTINATION "${module_dir}"
+ RUNTIME DESTINATION "${module_dir}"
+ )
+ endif()
+
+ install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
+ install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
+
+ # Install QML files
+ list(LENGTH module_qml_files num_files)
+ if (NOT "${module_qml_files}" MATCHES "NOTFOUND" AND ${num_files} GREATER 0)
+ qt_query_qml_module(${arg_TARGET} QML_FILES_DEPLOY_PATHS qml_files_deploy_paths)
+
+ math(EXPR last_index "${num_files} - 1")
+ foreach(i RANGE 0 ${last_index})
+ list(GET module_qml_files ${i} src_file)
+ list(GET qml_files_deploy_paths ${i} deploy_path)
+ get_filename_component(dst_name "${deploy_path}" NAME)
+ get_filename_component(dest_dir "${deploy_path}" DIRECTORY)
+ install(FILES "${src_file}" DESTINATION "${module_dir}/${dest_dir}" RENAME "${dst_name}")
+ endforeach()
+ endif()
+
+ # Install resources
+ list(LENGTH module_resources num_files)
+ if (NOT "${module_resources}" MATCHES "NOTFOUND" AND ${num_files} GREATER 0)
+ qt_query_qml_module(${arg_TARGET} RESOURCES_DEPLOY_PATHS resources_deploy_paths)
+
+ math(EXPR last_index "${num_files} - 1")
+ foreach(i RANGE 0 ${last_index})
+ list(GET module_resources ${i} src_file)
+ list(GET resources_deploy_paths ${i} deploy_path)
+ get_filename_component(dst_name "${deploy_path}" NAME)
+ get_filename_component(dest_dir "${deploy_path}" DIRECTORY)
+ install(FILES "${src_file}" DESTINATION "${module_dir}/${dest_dir}" RENAME "${dst_name}")
+ endforeach()
+ endif()
+endfunction()
diff --git a/cmake/pch.cmake b/cmake/pch.cmake
new file mode 100644
index 00000000..e136015e
--- /dev/null
+++ b/cmake/pch.cmake
@@ -0,0 +1,85 @@
+# pch breaks clang-tidy..... somehow
+if (NOT NO_PCH)
+ file(GENERATE
+ OUTPUT ${CMAKE_BINARY_DIR}/pchstub.cpp
+ CONTENT "// intentionally empty"
+ )
+endif()
+
+function (qs_pch target)
+ if (NO_PCH)
+ return()
+ endif()
+
+ cmake_parse_arguments(PARSE_ARGV 1 arg "" "SET" "")
+
+ if ("${arg_SET}" STREQUAL "")
+ set(arg_SET "common")
+ endif()
+
+ target_precompile_headers(${target} REUSE_FROM "qs-pchset-${arg_SET}")
+endfunction()
+
+function (qs_module_pch target)
+ qs_pch(${target} ${ARGN})
+ qs_pch("${target}plugin" SET plugin)
+ qs_pch("${target}plugin_init" SET plugin)
+endfunction()
+
+function (qs_add_pchset SETNAME)
+ if (NO_PCH)
+ return()
+ endif()
+
+ cmake_parse_arguments(PARSE_ARGV 1 arg "" "" "HEADERS;DEPENDENCIES")
+
+ set(LIBNAME "qs-pchset-${SETNAME}")
+
+ add_library(${LIBNAME} ${CMAKE_BINARY_DIR}/pchstub.cpp)
+ target_link_libraries(${LIBNAME} ${arg_DEPENDENCIES})
+ target_precompile_headers(${LIBNAME} PUBLIC ${arg_HEADERS})
+endfunction()
+
+set(COMMON_PCH_SET
+
+
+
+
+
+
+
+
+
+
+)
+
+qs_add_pchset(common
+ DEPENDENCIES Qt::Quick
+ HEADERS ${COMMON_PCH_SET}
+)
+
+qs_add_pchset(large
+ DEPENDENCIES Qt::Quick
+ HEADERS
+ ${COMMON_PCH_SET}
+
+
+
+
+
+
+
+
+
+
+)
+
+
+# including qplugin.h directly will cause required symbols to disappear
+qs_add_pchset(plugin
+ DEPENDENCIES Qt::Qml
+ HEADERS
+
+
+
+)
diff --git a/cmake/util.cmake b/cmake/util.cmake
new file mode 100644
index 00000000..14fa7c2d
--- /dev/null
+++ b/cmake/util.cmake
@@ -0,0 +1,29 @@
+# Adds a dependency hint to the link order, but does not block build on the dependency.
+function (qs_add_link_dependencies target)
+ set_property(
+ TARGET ${target}
+ APPEND PROPERTY INTERFACE_LINK_LIBRARIES
+ ${ARGN}
+ )
+endfunction()
+
+function (qs_append_qmldir target text)
+ get_property(qmldir_content TARGET ${target} PROPERTY _qt_internal_qmldir_content)
+
+ if ("${qmldir_content}" STREQUAL "")
+ message(WARNING "qs_append_qmldir depends on private Qt cmake code, which has broken.")
+ return()
+ endif()
+
+ set_property(TARGET ${target} APPEND_STRING PROPERTY _qt_internal_qmldir_content ${text})
+endfunction()
+
+# DEPENDENCIES introduces a cmake dependency which we don't need with static modules.
+# This greatly improves comp speed by not introducing those dependencies.
+function (qs_add_module_deps_light target)
+ foreach (dep IN LISTS ARGN)
+ string(APPEND qmldir_extra "depends ${dep}\n")
+ endforeach()
+
+ qs_append_qmldir(${target} "${qmldir_extra}")
+endfunction()
diff --git a/default.nix b/default.nix
index 4fa9326f..71c949e3 100644
--- a/default.nix
+++ b/default.nix
@@ -3,13 +3,24 @@
nix-gitignore,
pkgs,
keepDebugInfo,
- stdenv ? (keepDebugInfo pkgs.stdenv),
+ buildStdenv ? pkgs.clangStdenv,
+ pkg-config,
cmake,
ninja,
+ spirv-tools,
qt6,
+ breakpad,
+ jemalloc,
+ cli11,
wayland,
wayland-protocols,
+ wayland-scanner,
+ xorg,
+ libdrm,
+ libgbm ? null,
+ pipewire,
+ pam,
gitRev ? (let
headExists = builtins.pathExists ./.git/HEAD;
@@ -21,51 +32,101 @@
then builtins.readFile ./.git/refs/heads/${builtins.elemAt matches 0}
else headContent)
else "unknown"),
+
debug ? false,
- enableWayland ? true,
-}: stdenv.mkDerivation {
- pname = "quickshell${lib.optionalString debug "-debug"}";
- version = "0.1.0";
- src = nix-gitignore.gitignoreSource [] ./.;
+ withCrashReporter ? true,
+ withJemalloc ? true, # masks heap fragmentation
+ withQtSvg ? true,
+ withWayland ? true,
+ withX11 ? true,
+ withPipewire ? true,
+ withPam ? true,
+ withHyprland ? true,
+ withI3 ? true,
+}: let
+ unwrapped = buildStdenv.mkDerivation {
+ pname = "quickshell${lib.optionalString debug "-debug"}";
+ version = "0.2.0";
+ src = nix-gitignore.gitignoreSource "/default.nix\n" ./.;
- nativeBuildInputs = with pkgs; [
- cmake
- ninja
- qt6.wrapQtAppsHook
- ] ++ (lib.optionals enableWayland [
- pkg-config
- wayland-protocols
- wayland-scanner
- ]);
+ dontWrapQtApps = true; # see wrappers
- buildInputs = with pkgs; [
- qt6.qtbase
- qt6.qtdeclarative
- ] ++ (lib.optionals enableWayland [ qt6.qtwayland wayland ]);
+ nativeBuildInputs = [
+ cmake
+ ninja
+ qt6.qtshadertools
+ spirv-tools
+ pkg-config
+ ]
+ ++ lib.optional withWayland wayland-scanner;
- QTWAYLANDSCANNER = lib.optionalString enableWayland "${qt6.qtwayland}/libexec/qtwaylandscanner";
+ buildInputs = [
+ qt6.qtbase
+ qt6.qtdeclarative
+ cli11
+ ]
+ ++ lib.optional withQtSvg qt6.qtsvg
+ ++ lib.optional withCrashReporter breakpad
+ ++ lib.optional withJemalloc jemalloc
+ ++ lib.optionals withWayland [ qt6.qtwayland wayland wayland-protocols ]
+ ++ lib.optionals (withWayland && libgbm != null) [ libdrm libgbm ]
+ ++ lib.optional withX11 xorg.libxcb
+ ++ lib.optional withPam pam
+ ++ lib.optional withPipewire pipewire;
- configurePhase = let
- cmakeBuildType = if debug
- then "Debug"
- else "RelWithDebInfo";
- in ''
- cmakeBuildType=${cmakeBuildType} # qt6 setup hook resets this for some godforsaken reason
- cmakeConfigurePhase
- '';
+ cmakeBuildType = if debug then "Debug" else "RelWithDebInfo";
- cmakeFlags = [
- "-DGIT_REVISION=${gitRev}"
- ] ++ lib.optional (!enableWayland) "-DWAYLAND=OFF";
+ 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)
+ ];
- buildPhase = "ninjaBuildPhase";
- enableParallelBuilding = true;
- dontStrip = true;
+ # 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://git.outfoxxed.me/outfoxxed/quickshell";
- description = "Simple and flexbile QtQuick based desktop shell toolkit";
- license = licenses.lgpl3Only;
- platforms = platforms.linux;
+ 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/docs b/docs
deleted file mode 160000
index 70989dc6..00000000
--- a/docs
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 70989dc619bcdc29dc4880b4ff5257d6ad188a18
diff --git a/examples b/examples
deleted file mode 160000
index 9c83cc24..00000000
--- a/examples
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 9c83cc248c968b18a827b4fa4c616a8d362176e1
diff --git a/flake.lock b/flake.lock
index 1527f635..7c25aa23 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1709237383,
- "narHash": "sha256-cy6ArO4k5qTx+l5o+0mL9f5fa86tYUX3ozE1S+Txlds=",
+ "lastModified": 1749285348,
+ "narHash": "sha256-frdhQvPbmDYaScPFiCnfdh3B/Vh81Uuoo0w5TkWmmjU=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "1536926ef5621b09bba54035ae2bb6d806d72ac8",
+ "rev": "3e3afe5174c561dee0df6f2c2b2236990146329f",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index b9ba8d1f..5de9c96a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -4,12 +4,16 @@
};
outputs = { self, nixpkgs }: let
- forEachSystem = fn: nixpkgs.lib.genAttrs
- [ "x86_64-linux" "aarch64-linux" ]
- (system: fn system nixpkgs.legacyPackages.${system});
+ forEachSystem = fn:
+ nixpkgs.lib.genAttrs
+ nixpkgs.lib.platforms.linux
+ (system: fn system nixpkgs.legacyPackages.${system});
in {
packages = forEachSystem (system: pkgs: rec {
- quickshell = import ./package.nix { inherit pkgs; };
+ quickshell = pkgs.callPackage ./default.nix {
+ gitRev = self.rev or self.dirtyRev;
+ };
+
default = quickshell;
});
diff --git a/package.nix b/package.nix
deleted file mode 100644
index a3e6249e..00000000
--- a/package.nix
+++ /dev/null
@@ -1 +0,0 @@
-{ pkgs ? import {}, ... }: pkgs.callPackage ./default.nix {}
diff --git a/quickshell.scm b/quickshell.scm
new file mode 100644
index 00000000..26abdc0b
--- /dev/null
+++ b/quickshell.scm
@@ -0,0 +1,77 @@
+(define-module (quickshell)
+ #:use-module ((guix licenses) #:prefix license:)
+ #:use-module (gnu packages cpp)
+ #:use-module (gnu packages freedesktop)
+ #:use-module (gnu packages gcc)
+ #:use-module (gnu packages gl)
+ #:use-module (gnu packages jemalloc)
+ #:use-module (gnu packages linux)
+ #:use-module (gnu packages ninja)
+ #:use-module (gnu packages pkg-config)
+ #:use-module (gnu packages qt)
+ #:use-module (gnu packages vulkan)
+ #:use-module (gnu packages xdisorg)
+ #:use-module (gnu packages xorg)
+ #:use-module (guix build-system cmake)
+ #:use-module (guix download)
+ #:use-module (guix gexp)
+ #:use-module (guix git-download)
+ #:use-module (guix packages)
+ #:use-module (guix packages)
+ #:use-module (guix utils))
+
+(define-public quickshell-git
+ (package
+ (name "quickshell")
+ (version "git")
+ (source (local-file "." "quickshell-checkout"
+ #:recursive? #t
+ #:select? (or (git-predicate (current-source-directory))
+ (const #t))))
+ (build-system cmake-build-system)
+ (propagated-inputs (list qtbase qtdeclarative qtsvg))
+ (native-inputs (list ninja
+ gcc-14
+ pkg-config
+ qtshadertools
+ spirv-tools
+ wayland-protocols
+ cli11))
+ (inputs (list jemalloc
+ libdrm
+ libxcb
+ libxkbcommon
+ linux-pam
+ mesa
+ pipewire
+ qtbase
+ qtdeclarative
+ qtwayland
+ vulkan-headers
+ wayland))
+ (arguments
+ (list #:tests? #f
+ #:configure-flags
+ #~(list "-GNinja"
+ "-DDISTRIBUTOR=\"In-tree Guix channel\""
+ "-DDISTRIBUTOR_DEBUGINFO_AVAILABLE=NO"
+ ;; Breakpad is not currently packaged for Guix.
+ "-DCRASH_REPORTER=OFF")
+ #:phases
+ #~(modify-phases %standard-phases
+ (replace 'build (lambda _ (invoke "cmake" "--build" ".")))
+ (replace 'install (lambda _ (invoke "cmake" "--install" ".")))
+ (add-after 'install 'wrap-program
+ (lambda* (#:key inputs #:allow-other-keys)
+ (wrap-program (string-append #$output "/bin/quickshell")
+ `("QML_IMPORT_PATH" ":"
+ = (,(getenv "QML_IMPORT_PATH")))))))))
+ (home-page "https://quickshell.outfoxxed.me")
+ (synopsis "QtQuick-based desktop shell toolkit")
+ (description
+ "Quickshell is a flexible QtQuick-based toolkit for creating and
+customizing toolbars, notification centers, and other desktop
+environment tools in a live programming environment.")
+ (license license:lgpl3)))
+
+quickshell-git
diff --git a/shell.nix b/shell.nix
index 484bf70a..82382f90 100644
--- a/shell.nix
+++ b/shell.nix
@@ -10,18 +10,17 @@
rev = "1f062cc198d1112d13e5128fa1f2ee3dbffe613b";
sha256 = "kbt0Zc1qHE5fhqBkKz8iue+B+ZANjF1AR/RdgmX1r0I=";
}) { inherit pkgs; };
-in pkgs.mkShell {
+in pkgs.mkShell.override { stdenv = quickshell.stdenv; } {
inputsFrom = [ quickshell ];
nativeBuildInputs = with pkgs; [
just
- clang-tools_17
+ clang-tools
parallel
makeWrapper
];
TIDYFOX = "${tidyfox}/lib/libtidyfox.so";
- QTWAYLANDSCANNER = quickshell.QTWAYLANDSCANNER;
shellHook = ''
export CMAKE_BUILD_PARALLEL_LEVEL=$(nproc)
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
new file mode 100644
index 00000000..52db00a5
--- /dev/null
+++ b/src/CMakeLists.txt
@@ -0,0 +1,35 @@
+qt_add_executable(quickshell main.cpp)
+
+install(TARGETS quickshell RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
+
+add_subdirectory(build)
+add_subdirectory(launch)
+add_subdirectory(core)
+add_subdirectory(debug)
+add_subdirectory(ipc)
+add_subdirectory(window)
+add_subdirectory(io)
+add_subdirectory(widgets)
+add_subdirectory(ui)
+
+if (CRASH_REPORTER)
+ add_subdirectory(crash)
+endif()
+
+if (DBUS)
+ add_subdirectory(dbus)
+endif()
+
+if (WAYLAND)
+ add_subdirectory(wayland)
+endif()
+
+if (X11)
+ add_subdirectory(x11)
+endif()
+
+add_subdirectory(services)
+
+if (BLUETOOTH)
+ add_subdirectory(bluetooth)
+endif()
diff --git a/src/bluetooth/CMakeLists.txt b/src/bluetooth/CMakeLists.txt
new file mode 100644
index 00000000..806ff04d
--- /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 00000000..0d8a3192
--- /dev/null
+++ b/src/bluetooth/adapter.cpp
@@ -0,0 +1,224 @@
+#include "adapter.hpp"
+
+#include
+#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 00000000..d7f21d7e
--- /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 00000000..f2c4300d
--- /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 00000000..9d7c93ca
--- /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 00000000..7265b241
--- /dev/null
+++ b/src/bluetooth/device.cpp
@@ -0,0 +1,319 @@
+#include "device.hpp"
+
+#include
+#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 00000000..23f230f5
--- /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 00000000..eb797d93
--- /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 00000000..286991e1
--- /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 00000000..274e9fde
--- /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 00000000..21c53b1d
--- /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
new file mode 100644
index 00000000..bb35da99
--- /dev/null
+++ b/src/build/CMakeLists.txt
@@ -0,0 +1,26 @@
+add_library(quickshell-build INTERFACE)
+
+if (NOT DEFINED GIT_REVISION)
+ execute_process(
+ COMMAND git rev-parse HEAD
+ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
+ OUTPUT_VARIABLE GIT_REVISION
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+ )
+endif()
+
+if (CRASH_REPORTER)
+ set(CRASH_REPORTER_DEF 1)
+else()
+ set(CRASH_REPORTER_DEF 0)
+endif()
+
+if (DISTRIBUTOR_DEBUGINFO_AVAILABLE)
+ set(DEBUGINFO_AVAILABLE 1)
+else()
+ set(DEBUGINFO_AVAILABLE 0)
+endif()
+
+configure_file(build.hpp.in build.hpp @ONLY ESCAPE_QUOTES)
+
+target_include_directories(quickshell-build INTERFACE ${CMAKE_CURRENT_BINARY_DIR})
diff --git a/src/build/build.hpp.in b/src/build/build.hpp.in
new file mode 100644
index 00000000..075abd17
--- /dev/null
+++ b/src/build/build.hpp.in
@@ -0,0 +1,12 @@
+#pragma once
+
+// NOLINTBEGIN
+#define GIT_REVISION "@GIT_REVISION@"
+#define DISTRIBUTOR "@DISTRIBUTOR@"
+#define DISTRIBUTOR_DEBUGINFO_AVAILABLE @DEBUGINFO_AVAILABLE@
+#define CRASH_REPORTER @CRASH_REPORTER_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@"
+// NOLINTEND
diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index c1fdee8e..7cef987a 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -1,23 +1,62 @@
-qt_add_executable(quickshell
- main.cpp
+qt_add_library(quickshell-core STATIC
plugin.cpp
shell.cpp
variants.cpp
rootwrapper.cpp
- proxywindow.cpp
reload.cpp
rootwrapper.cpp
qmlglobal.cpp
qmlscreen.cpp
- watcher.cpp
region.cpp
persistentprops.cpp
- windowinterface.cpp
- floatingwindow.cpp
- panelinterface.cpp
+ singleton.cpp
+ generation.cpp
+ scan.cpp
+ qsintercept.cpp
+ incubator.cpp
+ lazyloader.cpp
+ easingcurve.cpp
+ iconimageprovider.cpp
+ imageprovider.cpp
+ transformwatcher.cpp
+ boundcomponent.cpp
+ model.cpp
+ elapsedtimer.cpp
+ desktopentry.cpp
+ objectrepeater.cpp
+ platformmenu.cpp
+ qsmenu.cpp
+ retainable.cpp
+ popupanchor.cpp
+ types.cpp
+ qsmenuanchor.cpp
+ clock.cpp
+ logging.cpp
+ paths.cpp
+ instanceinfo.cpp
+ common.cpp
+ iconprovider.cpp
+ scriptmodel.cpp
+ colorquantizer.cpp
+ toolsupport.cpp
)
-set_source_files_properties(main.cpp PROPERTIES COMPILE_DEFINITIONS GIT_REVISION="${GIT_REVISION}")
-qt_add_qml_module(quickshell URI Quickshell VERSION 0.1)
+qt_add_qml_module(quickshell-core
+ URI Quickshell
+ VERSION 0.1
+ DEPENDENCIES QtQuick
+ OPTIONAL_IMPORTS Quickshell._Window
+ DEFAULT_IMPORTS Quickshell._Window
+)
-target_link_libraries(quickshell PRIVATE ${QT_DEPS})
+install_qml_module(quickshell-core)
+
+target_link_libraries(quickshell-core PRIVATE Qt::Quick Qt::Widgets)
+
+qs_module_pch(quickshell-core SET large)
+
+target_link_libraries(quickshell PRIVATE quickshell-coreplugin)
+
+if (BUILD_TESTING)
+ add_subdirectory(test)
+endif()
diff --git a/src/core/boundcomponent.cpp b/src/core/boundcomponent.cpp
new file mode 100644
index 00000000..8b1c8284
--- /dev/null
+++ b/src/core/boundcomponent.cpp
@@ -0,0 +1,258 @@
+#include "boundcomponent.hpp"
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "incubator.hpp"
+
+QObject* BoundComponent::item() const { return this->object; }
+QQmlComponent* BoundComponent::sourceComponent() const { return this->mComponent; }
+
+void BoundComponent::setSourceComponent(QQmlComponent* component) {
+ if (component == this->mComponent) return;
+
+ if (this->componentCompleted) {
+ qWarning() << "BoundComponent.component cannot be set after creation";
+ return;
+ }
+ this->disconnectComponent();
+
+ this->ownsComponent = false;
+ this->mComponent = component;
+ if (component != nullptr) {
+ QObject::connect(component, &QObject::destroyed, this, &BoundComponent::onComponentDestroyed);
+ }
+
+ emit this->sourceComponentChanged();
+}
+
+void BoundComponent::disconnectComponent() {
+ if (this->mComponent == nullptr) return;
+
+ if (this->ownsComponent) {
+ delete this->mComponent;
+ } else {
+ QObject::disconnect(this->mComponent, nullptr, this, nullptr);
+ }
+
+ this->mComponent = nullptr;
+}
+
+void BoundComponent::onComponentDestroyed() { this->mComponent = nullptr; }
+QString BoundComponent::source() const { return this->mSource; }
+
+void BoundComponent::setSource(QString source) {
+ if (source == this->mSource) return;
+
+ if (this->componentCompleted) {
+ qWarning() << "BoundComponent.url cannot be set after creation";
+ return;
+ }
+
+ auto* context = QQmlEngine::contextForObject(this);
+ auto* component = new QQmlComponent(context->engine(), context->resolvedUrl(source), this);
+
+ if (component->isError()) {
+ qWarning() << component->errorString().toStdString().c_str();
+ delete component;
+ } else {
+ this->disconnectComponent();
+ this->ownsComponent = true;
+ this->mSource = std::move(source);
+ this->mComponent = component;
+
+ emit this->sourceChanged();
+ emit this->sourceComponentChanged();
+ }
+}
+
+bool BoundComponent::bindValues() const { return this->mBindValues; }
+
+void BoundComponent::setBindValues(bool bindValues) {
+ if (this->componentCompleted) {
+ qWarning() << "BoundComponent.bindValues cannot be set after creation";
+ return;
+ }
+
+ this->mBindValues = bindValues;
+ emit this->bindValuesChanged();
+}
+
+void BoundComponent::componentComplete() {
+ this->QQuickItem::componentComplete();
+ this->componentCompleted = true;
+ this->tryCreate();
+}
+
+void BoundComponent::tryCreate() {
+ if (this->mComponent == nullptr) {
+ qWarning() << "BoundComponent has no component";
+ return;
+ }
+
+ auto initialProperties = QVariantMap();
+
+ const auto* metaObject = this->metaObject();
+ for (auto i = metaObject->propertyOffset(); i < metaObject->propertyCount(); i++) {
+ const auto prop = metaObject->property(i);
+
+ if (prop.isReadable()) {
+ initialProperties.insert(prop.name(), prop.read(this));
+ }
+ }
+
+ this->incubator = new QsQmlIncubator(QsQmlIncubator::AsynchronousIfNested, this);
+ this->incubator->setInitialProperties(initialProperties);
+
+ // clang-format off
+ QObject::connect(this->incubator, &QsQmlIncubator::completed, this, &BoundComponent::onIncubationCompleted);
+ QObject::connect(this->incubator, &QsQmlIncubator::failed, this, &BoundComponent::onIncubationFailed);
+ // clang-format on
+
+ this->mComponent->create(*this->incubator, QQmlEngine::contextForObject(this));
+}
+
+void BoundComponent::onIncubationCompleted() {
+ this->object = this->incubator->object();
+ delete this->incubator;
+ this->disconnectComponent();
+
+ this->object->setParent(this);
+ this->mItem = qobject_cast(this->object);
+
+ const auto* metaObject = this->metaObject();
+ const auto* objectMetaObject = this->object->metaObject();
+
+ if (this->mBindValues) {
+ for (auto i = metaObject->propertyOffset(); i < metaObject->propertyCount(); i++) {
+ const auto prop = metaObject->property(i);
+
+ if (prop.isReadable() && prop.hasNotifySignal()) {
+ const auto objectPropIndex = objectMetaObject->indexOfProperty(prop.name());
+
+ if (objectPropIndex == -1) {
+ qWarning() << "property" << prop.name()
+ << "defined on BoundComponent but not on its contained object.";
+ continue;
+ }
+
+ const auto objectProp = objectMetaObject->property(objectPropIndex);
+ if (objectProp.isWritable()) {
+ auto* proxy = new BoundComponentPropertyProxy(this, this->object, prop, objectProp);
+ proxy->onNotified(); // any changes that might've happened before connection
+ } else {
+ qWarning() << "property" << prop.name()
+ << "defined on BoundComponent is not writable for its contained object.";
+ }
+ }
+ }
+ }
+
+ for (auto i = metaObject->methodOffset(); i < metaObject->methodCount(); i++) {
+ const auto method = metaObject->method(i);
+
+ if (method.name().startsWith("on") && method.name().length() > 2) {
+ auto sig = QString(method.methodSignature()).sliced(2);
+ if (!sig[0].isUpper()) continue;
+ sig[0] = sig[0].toLower();
+ auto name = sig.sliced(0, sig.indexOf('('));
+
+ auto mostViableSignal = QMetaMethod();
+ for (auto i = 0; i < objectMetaObject->methodCount(); i++) {
+ const auto method = objectMetaObject->method(i);
+ if (method.methodSignature() == sig) {
+ mostViableSignal = method;
+ break;
+ }
+
+ if (method.name() == name) {
+ if (mostViableSignal.isValid()) {
+ qWarning() << "Multiple candidates, so none will be attached for signal" << name;
+ goto next;
+ }
+
+ mostViableSignal = method;
+ }
+ }
+
+ if (!mostViableSignal.isValid()) {
+ qWarning() << "Function" << method.name() << "appears to be a signal handler for" << name
+ << "but it does not match any signals on the target object";
+ goto next;
+ }
+
+ QMetaObject::connect(
+ this->object,
+ mostViableSignal.methodIndex(),
+ this,
+ method.methodIndex()
+ );
+ }
+
+ next:;
+ }
+
+ if (this->mItem != nullptr) {
+ this->mItem->setParentItem(this);
+
+ // clang-format off
+ QObject::connect(this, &QQuickItem::widthChanged, this, &BoundComponent::updateSize);
+ QObject::connect(this, &QQuickItem::heightChanged, this, &BoundComponent::updateSize);
+ QObject::connect(this->mItem, &QQuickItem::implicitWidthChanged, this, &BoundComponent::updateImplicitSize);
+ QObject::connect(this->mItem, &QQuickItem::implicitHeightChanged, this, &BoundComponent::updateImplicitSize);
+ // clang-format on
+
+ this->updateImplicitSize();
+ this->updateSize();
+ }
+
+ emit this->loaded();
+}
+
+void BoundComponent::onIncubationFailed() {
+ qWarning() << "Failed to create BoundComponent";
+
+ for (auto& error: this->incubator->errors()) {
+ qWarning() << error;
+ }
+
+ delete this->incubator;
+ this->disconnectComponent();
+}
+
+void BoundComponent::updateSize() { this->mItem->setSize(this->size()); }
+
+void BoundComponent::updateImplicitSize() {
+ this->setImplicitWidth(this->mItem->implicitWidth());
+ this->setImplicitHeight(this->mItem->implicitHeight());
+}
+
+BoundComponentPropertyProxy::BoundComponentPropertyProxy(
+ QObject* from,
+ QObject* to,
+ QMetaProperty fromProperty,
+ QMetaProperty toProperty
+)
+ : QObject(from)
+ , from(from)
+ , to(to)
+ , fromProperty(fromProperty)
+ , toProperty(toProperty) {
+ const auto* metaObject = this->metaObject();
+ auto method = metaObject->indexOfSlot("onNotified()");
+ QMetaObject::connect(from, fromProperty.notifySignal().methodIndex(), this, method);
+}
+
+void BoundComponentPropertyProxy::onNotified() {
+ this->toProperty.write(this->to, this->fromProperty.read(this->from));
+}
diff --git a/src/core/boundcomponent.hpp b/src/core/boundcomponent.hpp
new file mode 100644
index 00000000..d47121df
--- /dev/null
+++ b/src/core/boundcomponent.hpp
@@ -0,0 +1,125 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "incubator.hpp"
+
+///! Component loader that allows setting initial properties.
+/// Component loader that allows setting initial properties, primarily useful for
+/// escaping cyclic dependency errors.
+///
+/// Properties defined on the BoundComponent will be applied to its loaded component,
+/// including required properties, and will remain reactive. Functions created with
+/// the names of signal handlers will also be attached to signals of the loaded component.
+///
+/// ```qml {filename="MyComponent.qml"}
+/// MouseArea {
+/// required property color color;
+/// width: 100
+/// height: 100
+///
+/// Rectangle {
+/// anchors.fill: parent
+/// color: parent.color
+/// }
+/// }
+/// ```
+///
+/// ```qml
+/// BoundComponent {
+/// source: "MyComponent.qml"
+///
+/// // this is the same as assigning to `color` on MyComponent if loaded normally.
+/// property color color: "red";
+///
+/// // this will be triggered when the `clicked` signal from the MouseArea is sent.
+/// function onClicked() {
+/// color = "blue";
+/// }
+/// }
+/// ```
+class BoundComponent: public QQuickItem {
+ Q_OBJECT;
+ // clang-format off
+ /// The loaded component. Will be null until it has finished loading.
+ Q_PROPERTY(QObject* item READ item NOTIFY loaded);
+ /// The source to load, as a Component.
+ Q_PROPERTY(QQmlComponent* sourceComponent READ sourceComponent WRITE setSourceComponent NOTIFY sourceComponentChanged);
+ /// The source to load, as a Url.
+ Q_PROPERTY(QString source READ source WRITE setSource NOTIFY sourceChanged);
+ /// If property values should be bound after they are initially set. Defaults to `true`.
+ Q_PROPERTY(bool bindValues READ bindValues WRITE setBindValues NOTIFY bindValuesChanged);
+ Q_PROPERTY(qreal implicitWidth READ implicitWidth NOTIFY implicitWidthChanged);
+ Q_PROPERTY(qreal implicitHeight READ implicitHeight NOTIFY implicitHeightChanged);
+ // clang-format on
+ QML_ELEMENT;
+
+public:
+ explicit BoundComponent(QQuickItem* parent = nullptr): QQuickItem(parent) {}
+
+ void componentComplete() override;
+
+ [[nodiscard]] QObject* item() const;
+
+ [[nodiscard]] QQmlComponent* sourceComponent() const;
+ void setSourceComponent(QQmlComponent* sourceComponent);
+
+ [[nodiscard]] QString source() const;
+ void setSource(QString source);
+
+ [[nodiscard]] bool bindValues() const;
+ void setBindValues(bool bindValues);
+
+signals:
+ void loaded();
+ void sourceComponentChanged();
+ void sourceChanged();
+ void bindValuesChanged();
+
+private slots:
+ void onComponentDestroyed();
+ void onIncubationCompleted();
+ void onIncubationFailed();
+ void updateSize();
+ void updateImplicitSize();
+
+private:
+ void disconnectComponent();
+ void tryCreate();
+
+ QString mSource;
+ bool mBindValues = true;
+ QQmlComponent* mComponent = nullptr;
+ bool ownsComponent = false;
+ QsQmlIncubator* incubator = nullptr;
+ QObject* object = nullptr;
+ QQuickItem* mItem = nullptr;
+ bool componentCompleted = false;
+};
+
+class BoundComponentPropertyProxy: public QObject {
+ Q_OBJECT;
+
+public:
+ BoundComponentPropertyProxy(
+ QObject* from,
+ QObject* to,
+ QMetaProperty fromProperty,
+ QMetaProperty toProperty
+ );
+
+public slots:
+ void onNotified();
+
+private:
+ QObject* from;
+ QObject* to;
+ QMetaProperty fromProperty;
+ QMetaProperty toProperty;
+};
diff --git a/src/core/clock.cpp b/src/core/clock.cpp
new file mode 100644
index 00000000..90938d21
--- /dev/null
+++ b/src/core/clock.cpp
@@ -0,0 +1,88 @@
+#include "clock.hpp"
+
+#include
+#include
+#include
+#include
+#include
+
+SystemClock::SystemClock(QObject* parent): QObject(parent) {
+ QObject::connect(&this->timer, &QTimer::timeout, this, &SystemClock::onTimeout);
+ this->update();
+}
+
+bool SystemClock::enabled() const { return this->mEnabled; }
+
+void SystemClock::setEnabled(bool enabled) {
+ if (enabled == this->mEnabled) return;
+ this->mEnabled = enabled;
+ emit this->enabledChanged();
+ this->update();
+}
+
+SystemClock::Enum SystemClock::precision() const { return this->mPrecision; }
+
+void SystemClock::setPrecision(SystemClock::Enum precision) {
+ if (precision == this->mPrecision) return;
+ this->mPrecision = precision;
+ emit this->precisionChanged();
+ this->update();
+}
+
+void SystemClock::onTimeout() {
+ this->setTime(this->targetTime);
+ this->schedule(this->targetTime);
+}
+
+void SystemClock::update() {
+ if (this->mEnabled) {
+ this->setTime(QDateTime::fromMSecsSinceEpoch(0));
+ this->schedule(QDateTime::fromMSecsSinceEpoch(0));
+ } else {
+ this->timer.stop();
+ }
+}
+
+void SystemClock::setTime(const QDateTime& targetTime) {
+ auto currentTime = QDateTime::currentDateTime();
+ auto offset = currentTime.msecsTo(targetTime);
+ this->currentTime = offset > -500 && offset < 500 ? targetTime : currentTime;
+
+ auto time = this->currentTime.time();
+ this->currentTime.setTime(QTime(
+ this->mPrecision >= SystemClock::Hours ? time.hour() : 0,
+ this->mPrecision >= SystemClock::Minutes ? time.minute() : 0,
+ this->mPrecision >= SystemClock::Seconds ? time.second() : 0
+ ));
+
+ emit this->dateChanged();
+}
+
+void SystemClock::schedule(const QDateTime& targetTime) {
+ auto secondPrecision = this->mPrecision >= SystemClock::Seconds;
+ auto minutePrecision = this->mPrecision >= SystemClock::Minutes;
+ auto hourPrecision = this->mPrecision >= SystemClock::Hours;
+
+ auto currentTime = QDateTime::currentDateTime();
+
+ auto offset = currentTime.msecsTo(targetTime);
+
+ // timer skew
+ auto nextTime = offset > 0 && offset < 500 ? targetTime : currentTime;
+
+ auto baseTimeT = nextTime.time();
+ nextTime.setTime(QTime(
+ hourPrecision ? baseTimeT.hour() : 0,
+ minutePrecision ? baseTimeT.minute() : 0,
+ secondPrecision ? baseTimeT.second() : 0
+ ));
+
+ if (secondPrecision) nextTime = nextTime.addSecs(1);
+ else if (minutePrecision) nextTime = nextTime.addSecs(60);
+ else if (hourPrecision) nextTime = nextTime.addSecs(3600);
+
+ auto delay = currentTime.msecsTo(nextTime);
+
+ this->timer.start(static_cast(delay));
+ this->targetTime = nextTime;
+}
diff --git a/src/core/clock.hpp b/src/core/clock.hpp
new file mode 100644
index 00000000..67461911
--- /dev/null
+++ b/src/core/clock.hpp
@@ -0,0 +1,91 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+///! System clock accessor.
+/// SystemClock is a view into the system's clock.
+/// It updates at hour, minute, or second intervals depending on @@precision.
+///
+/// # Examples
+/// ```qml
+/// SystemClock {
+/// id: clock
+/// precision: SystemClock.Seconds
+/// }
+///
+/// @@QtQuick.Text {
+/// text: Qt.formatDateTime(clock.date, "hh:mm:ss - yyyy-MM-dd")
+/// }
+/// ```
+///
+/// > [!WARNING] Clock updates will trigger within 50ms of the system clock changing,
+/// > however this can be either before or after the clock changes (+-50ms). If you
+/// > need a date object, use @@date instead of constructing a new one, or the time
+/// > of the constructed object could be off by up to a second.
+class SystemClock: public QObject {
+ Q_OBJECT;
+ /// If the clock should update. Defaults to true.
+ ///
+ /// Setting enabled to false pauses the clock.
+ Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged);
+ /// The precision the clock should measure at. Defaults to `SystemClock.Seconds`.
+ Q_PROPERTY(SystemClock::Enum precision READ precision WRITE setPrecision NOTIFY precisionChanged);
+ /// The current date and time.
+ ///
+ /// > [!TIP] You can use @@QtQml.Qt.formatDateTime() to get the time as a string in
+ /// > your format of choice.
+ Q_PROPERTY(QDateTime date READ date NOTIFY dateChanged);
+ /// The current hour.
+ Q_PROPERTY(quint32 hours READ hours NOTIFY dateChanged);
+ /// The current minute, or 0 if @@precision is `SystemClock.Hours`.
+ Q_PROPERTY(quint32 minutes READ minutes NOTIFY dateChanged);
+ /// The current second, or 0 if @@precision is `SystemClock.Hours` or `SystemClock.Minutes`.
+ Q_PROPERTY(quint32 seconds READ seconds NOTIFY dateChanged);
+ QML_ELEMENT;
+
+public:
+ // must be named enum until docgen is ready to handle member enums better
+ enum Enum : quint8 {
+ Hours = 1,
+ Minutes = 2,
+ Seconds = 3,
+ };
+ Q_ENUM(Enum);
+
+ explicit SystemClock(QObject* parent = nullptr);
+
+ [[nodiscard]] bool enabled() const;
+ void setEnabled(bool enabled);
+
+ [[nodiscard]] SystemClock::Enum precision() const;
+ void setPrecision(SystemClock::Enum precision);
+
+ [[nodiscard]] QDateTime date() const { return this->currentTime; }
+ [[nodiscard]] quint32 hours() const { return this->currentTime.time().hour(); }
+ [[nodiscard]] quint32 minutes() const { return this->currentTime.time().minute(); }
+ [[nodiscard]] quint32 seconds() const { return this->currentTime.time().second(); }
+
+signals:
+ void enabledChanged();
+ void precisionChanged();
+ void dateChanged();
+
+private slots:
+ void onTimeout();
+
+private:
+ bool mEnabled = true;
+ SystemClock::Enum mPrecision = SystemClock::Seconds;
+ QTimer timer;
+ QDateTime currentTime;
+ QDateTime targetTime;
+
+ void update();
+ void setTime(const QDateTime& targetTime);
+ void schedule(const QDateTime& targetTime);
+};
diff --git a/src/core/colorquantizer.cpp b/src/core/colorquantizer.cpp
new file mode 100644
index 00000000..6cfb05db
--- /dev/null
+++ b/src/core/colorquantizer.cpp
@@ -0,0 +1,242 @@
+#include "colorquantizer.hpp"
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "logcat.hpp"
+
+namespace {
+QS_LOGGING_CATEGORY(logColorQuantizer, "quickshell.colorquantizer", QtWarningMsg);
+}
+
+ColorQuantizerOperation::ColorQuantizerOperation(QUrl* source, qreal depth, qreal rescaleSize)
+ : source(source)
+ , maxDepth(depth)
+ , rescaleSize(rescaleSize) {
+ setAutoDelete(false);
+}
+
+void ColorQuantizerOperation::quantizeImage(const QAtomicInteger& shouldCancel) {
+ if (shouldCancel.loadAcquire() || source->isEmpty()) return;
+
+ colors.clear();
+
+ auto image = QImage(source->toLocalFile());
+ if ((image.width() > rescaleSize || image.height() > rescaleSize) && rescaleSize > 0) {
+ image = image.scaled(
+ static_cast(rescaleSize),
+ static_cast(rescaleSize),
+ Qt::KeepAspectRatio,
+ Qt::SmoothTransformation
+ );
+ }
+
+ if (image.isNull()) {
+ qCWarning(logColorQuantizer) << "Failed to load image from" << source->toString();
+ return;
+ }
+
+ QList pixels;
+ for (int y = 0; y != image.height(); ++y) {
+ for (int x = 0; x != image.width(); ++x) {
+ auto pixel = image.pixel(x, y);
+ if (qAlpha(pixel) == 0) continue;
+
+ pixels.append(QColor::fromRgb(pixel));
+ }
+ }
+
+ auto startTime = QDateTime::currentDateTime();
+
+ colors = quantization(pixels, 0);
+
+ auto endTime = QDateTime::currentDateTime();
+ auto milliseconds = startTime.msecsTo(endTime);
+ qCDebug(logColorQuantizer) << "Color Quantization took: " << milliseconds << "ms";
+}
+
+QList ColorQuantizerOperation::quantization(
+ QList& rgbValues,
+ qreal depth,
+ const QAtomicInteger& shouldCancel
+) {
+ if (shouldCancel.loadAcquire()) return QList();
+
+ if (depth >= maxDepth || rgbValues.isEmpty()) {
+ if (rgbValues.isEmpty()) return QList();
+
+ auto totalR = 0;
+ auto totalG = 0;
+ auto totalB = 0;
+
+ for (const auto& color: rgbValues) {
+ if (shouldCancel.loadAcquire()) return QList();
+
+ totalR += color.red();
+ totalG += color.green();
+ totalB += color.blue();
+ }
+
+ auto avgColor = QColor(
+ qRound(totalR / static_cast(rgbValues.size())),
+ qRound(totalG / static_cast(rgbValues.size())),
+ qRound(totalB / static_cast(rgbValues.size()))
+ );
+
+ return QList() << avgColor;
+ }
+
+ auto dominantChannel = findBiggestColorRange(rgbValues);
+ std::ranges::sort(rgbValues, [dominantChannel](const auto& a, const auto& b) {
+ if (dominantChannel == 'r') return a.red() < b.red();
+ else if (dominantChannel == 'g') return a.green() < b.green();
+ return a.blue() < b.blue();
+ });
+
+ auto mid = rgbValues.size() / 2;
+
+ auto leftHalf = rgbValues.mid(0, mid);
+ auto rightHalf = rgbValues.mid(mid);
+
+ QList result;
+ result.append(quantization(leftHalf, depth + 1));
+ result.append(quantization(rightHalf, depth + 1));
+
+ return result;
+}
+
+char ColorQuantizerOperation::findBiggestColorRange(const QList& rgbValues) {
+ if (rgbValues.isEmpty()) return 'r';
+
+ auto rMin = 255;
+ auto gMin = 255;
+ auto bMin = 255;
+ auto rMax = 0;
+ auto gMax = 0;
+ auto bMax = 0;
+
+ for (const auto& color: rgbValues) {
+ rMin = qMin(rMin, color.red());
+ gMin = qMin(gMin, color.green());
+ bMin = qMin(bMin, color.blue());
+
+ rMax = qMax(rMax, color.red());
+ gMax = qMax(gMax, color.green());
+ bMax = qMax(bMax, color.blue());
+ }
+
+ auto rRange = rMax - rMin;
+ auto gRange = gMax - gMin;
+ auto bRange = bMax - bMin;
+
+ auto biggestRange = qMax(rRange, qMax(gRange, bRange));
+ if (biggestRange == rRange) {
+ return 'r';
+ } else if (biggestRange == gRange) {
+ return 'g';
+ } else {
+ return 'b';
+ }
+}
+
+void ColorQuantizerOperation::finishRun() {
+ QMetaObject::invokeMethod(this, &ColorQuantizerOperation::finished, Qt::QueuedConnection);
+}
+
+void ColorQuantizerOperation::finished() {
+ emit this->done(colors);
+ delete this;
+}
+
+void ColorQuantizerOperation::run() {
+ if (!this->shouldCancel) {
+ this->quantizeImage();
+
+ if (this->shouldCancel.loadAcquire()) {
+ qCDebug(logColorQuantizer) << "Color quantization" << this << "cancelled";
+ }
+ }
+
+ this->finishRun();
+}
+
+void ColorQuantizerOperation::tryCancel() { this->shouldCancel.storeRelease(true); }
+
+void ColorQuantizer::componentComplete() {
+ componentCompleted = true;
+ if (!mSource.isEmpty()) quantizeAsync();
+}
+
+void ColorQuantizer::setSource(const QUrl& source) {
+ if (mSource != source) {
+ mSource = source;
+ emit this->sourceChanged();
+
+ if (this->componentCompleted && !mSource.isEmpty()) quantizeAsync();
+ }
+}
+
+void ColorQuantizer::setDepth(qreal depth) {
+ if (mDepth != depth) {
+ mDepth = depth;
+ emit this->depthChanged();
+
+ if (this->componentCompleted) quantizeAsync();
+ }
+}
+
+void ColorQuantizer::setRescaleSize(int rescaleSize) {
+ if (mRescaleSize != rescaleSize) {
+ mRescaleSize = rescaleSize;
+ emit this->rescaleSizeChanged();
+
+ if (this->componentCompleted) quantizeAsync();
+ }
+}
+
+void ColorQuantizer::operationFinished(const QList& result) {
+ bColors = result;
+ this->liveOperation = nullptr;
+ emit this->colorsChanged();
+}
+
+void ColorQuantizer::quantizeAsync() {
+ if (this->liveOperation) this->cancelAsync();
+
+ qCDebug(logColorQuantizer) << "Starting color quantization asynchronously";
+ this->liveOperation = new ColorQuantizerOperation(&mSource, mDepth, mRescaleSize);
+
+ QObject::connect(
+ this->liveOperation,
+ &ColorQuantizerOperation::done,
+ this,
+ &ColorQuantizer::operationFinished
+ );
+
+ QThreadPool::globalInstance()->start(this->liveOperation);
+}
+
+void ColorQuantizer::cancelAsync() {
+ if (!this->liveOperation) return;
+
+ this->liveOperation->tryCancel();
+ QThreadPool::globalInstance()->waitForDone();
+
+ QObject::disconnect(this->liveOperation, nullptr, this, nullptr);
+ this->liveOperation = nullptr;
+}
diff --git a/src/core/colorquantizer.hpp b/src/core/colorquantizer.hpp
new file mode 100644
index 00000000..d35a15ac
--- /dev/null
+++ b/src/core/colorquantizer.hpp
@@ -0,0 +1,128 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+class ColorQuantizerOperation
+ : public QObject
+ , public QRunnable {
+ Q_OBJECT;
+
+public:
+ explicit ColorQuantizerOperation(QUrl* source, qreal depth, qreal rescaleSize);
+
+ void run() override;
+ void tryCancel();
+
+signals:
+ void done(QList colors);
+
+private slots:
+ void finished();
+
+private:
+ static char findBiggestColorRange(const QList& rgbValues);
+
+ void quantizeImage(const QAtomicInteger& shouldCancel = false);
+
+ QList quantization(
+ QList& rgbValues,
+ qreal depth,
+ const QAtomicInteger& shouldCancel = false
+ );
+
+ void finishRun();
+
+ QAtomicInteger shouldCancel = false;
+ QList colors;
+ QUrl* source;
+ qreal maxDepth;
+ qreal rescaleSize;
+};
+
+///! Color Quantization Utility
+/// A color quantization utility used for getting prevalent colors in an image, by
+/// averaging out the image's color data recursively.
+///
+/// #### Example
+/// ```qml
+/// ColorQuantizer {
+/// id: colorQuantizer
+/// source: Qt.resolvedUrl("./yourImage.png")
+/// depth: 3 // Will produce 8 colors (2³)
+/// rescaleSize: 64 // Rescale to 64x64 for faster processing
+/// }
+/// ```
+class ColorQuantizer
+ : public QObject
+ , public QQmlParserStatus {
+ Q_OBJECT;
+ QML_ELEMENT;
+ Q_INTERFACES(QQmlParserStatus);
+ /// Access the colors resulting from the color quantization performed.
+ /// > [!NOTE] The amount of colors returned from the quantization is determined by
+ /// > the property depth, specifically 2ⁿ where n is the depth.
+ Q_PROPERTY(QList colors READ default NOTIFY colorsChanged BINDABLE bindableColors);
+
+ /// Path to the image you'd like to run the color quantization on.
+ Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged);
+
+ /// Max depth for the color quantization. Each level of depth represents another
+ /// binary split of the color space
+ Q_PROPERTY(qreal depth READ depth WRITE setDepth NOTIFY depthChanged);
+
+ /// 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.
+ Q_PROPERTY(qreal rescaleSize READ rescaleSize WRITE setRescaleSize NOTIFY rescaleSizeChanged);
+
+public:
+ explicit ColorQuantizer(QObject* parent = nullptr): QObject(parent) {}
+
+ void componentComplete() override;
+ void classBegin() override {}
+
+ [[nodiscard]] QBindable> bindableColors() { return &this->bColors; }
+
+ [[nodiscard]] QUrl source() const { return mSource; }
+ void setSource(const QUrl& source);
+
+ [[nodiscard]] qreal depth() const { return mDepth; }
+ void setDepth(qreal depth);
+
+ [[nodiscard]] qreal rescaleSize() const { return mRescaleSize; }
+ void setRescaleSize(int rescaleSize);
+
+signals:
+ void colorsChanged();
+ void sourceChanged();
+ void depthChanged();
+ void rescaleSizeChanged();
+
+public slots:
+ void operationFinished(const QList& result);
+
+private:
+ void quantizeAsync();
+ void cancelAsync();
+
+ bool componentCompleted = false;
+ ColorQuantizerOperation* liveOperation = nullptr;
+ QUrl mSource;
+ qreal mDepth = 0;
+ qreal mRescaleSize = 0;
+
+ Q_OBJECT_BINDABLE_PROPERTY(
+ ColorQuantizer,
+ QList,
+ bColors,
+ &ColorQuantizer::colorsChanged
+ );
+};
diff --git a/src/core/common.cpp b/src/core/common.cpp
new file mode 100644
index 00000000..080019ab
--- /dev/null
+++ b/src/core/common.cpp
@@ -0,0 +1,9 @@
+#include "common.hpp"
+
+#include
+
+namespace qs {
+
+const QDateTime Common::LAUNCH_TIME = QDateTime::currentDateTime();
+
+} // namespace qs
diff --git a/src/core/common.hpp b/src/core/common.hpp
new file mode 100644
index 00000000..ab8edb80
--- /dev/null
+++ b/src/core/common.hpp
@@ -0,0 +1,13 @@
+#pragma once
+
+#include
+#include
+
+namespace qs {
+
+struct Common {
+ static const QDateTime LAUNCH_TIME;
+ static inline QProcessEnvironment INITIAL_ENVIRONMENT = {}; // NOLINT
+};
+
+} // namespace qs
diff --git a/src/core/desktopentry.cpp b/src/core/desktopentry.cpp
new file mode 100644
index 00000000..95fcb89e
--- /dev/null
+++ b/src/core/desktopentry.cpp
@@ -0,0 +1,422 @@
+#include "desktopentry.hpp"
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../io/processcore.hpp"
+#include "logcat.hpp"
+#include "model.hpp"
+#include "qmlglobal.hpp"
+
+namespace {
+QS_LOGGING_CATEGORY(logDesktopEntry, "quickshell.desktopentry", QtWarningMsg);
+}
+
+struct Locale {
+ explicit Locale() = default;
+
+ explicit Locale(const QString& string) {
+ auto territoryIdx = string.indexOf('_');
+ auto codesetIdx = string.indexOf('.');
+ auto modifierIdx = string.indexOf('@');
+
+ auto parseEnd = string.length();
+
+ if (modifierIdx != -1) {
+ this->modifier = string.sliced(modifierIdx + 1, parseEnd - modifierIdx - 1);
+ parseEnd = modifierIdx;
+ }
+
+ if (codesetIdx != -1) {
+ parseEnd = codesetIdx;
+ }
+
+ if (territoryIdx != -1) {
+ this->territory = string.sliced(territoryIdx + 1, parseEnd - territoryIdx - 1);
+ parseEnd = territoryIdx;
+ }
+
+ this->language = string.sliced(0, parseEnd);
+ }
+
+ [[nodiscard]] bool isValid() const { return !this->language.isEmpty(); }
+
+ [[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;
+
+ auto score = 1;
+ if (territoryMatches) score += 2;
+ if (modifierMatches) score += 1;
+
+ return score;
+ }
+
+ static const Locale& system() {
+ static Locale* locale = nullptr; // NOLINT
+
+ if (locale == nullptr) {
+ auto lstr = qEnvironmentVariable("LC_MESSAGES");
+ if (lstr.isEmpty()) lstr = qEnvironmentVariable("LANG");
+ locale = new Locale(lstr);
+ }
+
+ return *locale;
+ }
+
+ QString language;
+ QString territory;
+ QString modifier;
+};
+
+// NOLINTNEXTLINE(misc-use-internal-linkage)
+QDebug operator<<(QDebug debug, const Locale& locale) {
+ auto saver = QDebugStateSaver(debug);
+ debug.nospace() << "Locale(language=" << locale.language << ", territory=" << locale.territory
+ << ", modifier" << locale.modifier << ')';
+
+ return debug;
+}
+
+void DesktopEntry::parseEntry(const QString& text) {
+ const auto& system = Locale::system();
+
+ auto groupName = QString();
+ auto entries = QHash>();
+
+ auto finishCategory = [this, &groupName, &entries]() {
+ if (groupName == "Desktop Entry") {
+ if (entries["Type"].second != "Application") return;
+ if (entries.contains("Hidden") && entries["Hidden"].second == "true") return;
+
+ for (const auto& [key, pair]: entries.asKeyValueRange()) {
+ auto& [_, value] = pair;
+ this->mEntries.insert(key, value);
+
+ if (key == "Name") this->mName = value;
+ else if (key == "GenericName") this->mGenericName = value;
+ else if (key == "StartupWMClass") this->mStartupClass = 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;
+ this->mCommand = DesktopEntry::parseExecString(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);
+ }
+ } else if (groupName.startsWith("Desktop Action ")) {
+ auto actionName = groupName.sliced(16);
+ auto* action = new DesktopAction(actionName, this);
+
+ for (const auto& [key, pair]: entries.asKeyValueRange()) {
+ const auto& [_, value] = pair;
+ action->mEntries.insert(key, value);
+
+ if (key == "Name") action->mName = value;
+ else if (key == "Icon") action->mIcon = value;
+ else if (key == "Exec") {
+ action->mExecString = value;
+ action->mCommand = DesktopEntry::parseExecString(value);
+ }
+ }
+
+ this->mActions.insert(actionName, action);
+ }
+
+ entries.clear();
+ };
+
+ for (auto& line: text.split(u'\n', Qt::SkipEmptyParts)) {
+ if (line.startsWith(u'#')) continue;
+
+ if (line.startsWith(u'[') && line.endsWith(u']')) {
+ finishCategory();
+ groupName = line.sliced(1, line.length() - 2);
+ continue;
+ }
+
+ auto splitIdx = line.indexOf(u'=');
+ if (splitIdx == -1) {
+ qCWarning(logDesktopEntry) << "Encountered invalid line in desktop entry (no =)" << line;
+ continue;
+ }
+
+ auto key = line.sliced(0, splitIdx);
+ const auto& value = line.sliced(splitIdx + 1);
+
+ auto localeIdx = key.indexOf('[');
+ Locale locale;
+ if (localeIdx != -1 && localeIdx != key.length() - 1) {
+ locale = Locale(key.sliced(localeIdx + 1, key.length() - localeIdx - 2));
+ key = key.sliced(0, localeIdx);
+ }
+
+ if (entries.contains(key)) {
+ const auto& old = entries.value(key);
+
+ auto oldScore = system.matchScore(old.first);
+ auto newScore = system.matchScore(locale);
+
+ if (newScore > oldScore || (oldScore == 0 && !locale.isValid())) {
+ entries.insert(key, qMakePair(locale, value));
+ }
+ } else {
+ entries.insert(key, qMakePair(locale, value));
+ }
+ }
+
+ finishCategory();
+}
+
+void DesktopEntry::execute() const {
+ DesktopEntry::doExec(this->mCommand, this->mWorkingDirectory);
+}
+
+bool DesktopEntry::isValid() const { return !this->mName.isEmpty(); }
+bool DesktopEntry::noDisplay() const { return this->mNoDisplay; }
+
+QVector DesktopEntry::actions() const { return this->mActions.values(); }
+
+QVector DesktopEntry::parseExecString(const QString& execString) {
+ QVector arguments;
+ QString currentArgument;
+ auto parsingString = false;
+ auto escape = 0;
+ auto percent = false;
+
+ for (auto c: execString) {
+ if (escape == 0 && c == u'\\') {
+ escape = 1;
+ } else if (parsingString) {
+ if (c == '\\') {
+ escape++;
+ if (escape == 4) {
+ currentArgument += '\\';
+ 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.
+ qCWarning(logDesktopEntry).noquote()
+ << "Illegal escape sequence in desktop entry exec string:" << execString;
+ }
+
+ currentArgument += c;
+ escape = 0;
+ } else if (c == u'"' || c == u'\'') {
+ parsingString = false;
+ } else {
+ currentArgument += c;
+ }
+ } else if (escape != 0) {
+ currentArgument += c;
+ escape = 0;
+ } else if (percent) {
+ if (c == '%') {
+ currentArgument += '%';
+ } // else discard
+
+ percent = false;
+ } else if (c == '%') {
+ percent = true;
+ } else if (c == u'"' || c == u'\'') {
+ parsingString = true;
+ } else if (c == u' ') {
+ if (!currentArgument.isEmpty()) {
+ arguments.push_back(currentArgument);
+ currentArgument.clear();
+ }
+ } else {
+ currentArgument += c;
+ }
+ }
+
+ if (!currentArgument.isEmpty()) {
+ arguments.push_back(currentArgument);
+ currentArgument.clear();
+ }
+
+ return arguments;
+}
+
+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->mCommand, this->entry->mWorkingDirectory);
+}
+
+DesktopEntryManager::DesktopEntryManager() {
+ this->scanDesktopEntries();
+ this->populateApplications();
+}
+
+void DesktopEntryManager::scanDesktopEntries() {
+ QList dataPaths;
+
+ if (qEnvironmentVariableIsSet("XDG_DATA_HOME")) {
+ dataPaths.push_back(qEnvironmentVariable("XDG_DATA_HOME"));
+ } else if (qEnvironmentVariableIsSet("HOME")) {
+ dataPaths.push_back(qEnvironmentVariable("HOME") + "/.local/share");
+ }
+
+ 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);
+ }
+}
+
+void DesktopEntryManager::populateApplications() {
+ for (auto& entry: this->desktopEntries.values()) {
+ if (!entry->noDisplay()) this->mApplications.insertObject(entry);
+ }
+}
+
+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()) {
+ auto path = entry.filePath();
+ if (!path.endsWith(".desktop")) {
+ qCDebug(logDesktopEntry) << "Skipping file" << path << "as it has no .desktop extension";
+ continue;
+ }
+
+ auto file = QFile(path);
+ if (!file.open(QFile::ReadOnly)) {
+ qCDebug(logDesktopEntry) << "Could not open file" << path;
+ continue;
+ }
+
+ auto id = prefix + entry.fileName().sliced(0, entry.fileName().length() - 8);
+ auto lowerId = id.toLower();
+
+ 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);
+ }
+ }
+}
+
+DesktopEntryManager* DesktopEntryManager::instance() {
+ static auto* instance = new DesktopEntryManager(); // NOLINT
+ return instance;
+}
+
+DesktopEntry* DesktopEntryManager::byId(const QString& id) {
+ if (auto* entry = this->desktopEntries.value(id)) {
+ return entry;
+ } else if (auto* entry = this->lowercaseDesktopEntries.value(id.toLower())) {
+ return entry;
+ } else {
+ return nullptr;
+ }
+}
+
+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, [&](const DesktopEntry* entry) {
+ return name == entry->mStartupClass;
+ });
+
+ if (iter != list.end()) return *iter;
+
+ iter = std::ranges::find_if(list, [&](const DesktopEntry* entry) {
+ return name.toLower() == entry->mStartupClass.toLower();
+ });
+
+ if (iter != list.end()) return *iter;
+ return nullptr;
+}
+
+ObjectModel* DesktopEntryManager::applications() { return &this->mApplications; }
+
+DesktopEntries::DesktopEntries() { DesktopEntryManager::instance(); }
+
+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
new file mode 100644
index 00000000..827a6187
--- /dev/null
+++ b/src/core/desktopentry.hpp
@@ -0,0 +1,204 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "doc.hpp"
+#include "model.hpp"
+
+class DesktopAction;
+
+/// 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);
+ /// Short description of the application, such as "Web Browser". May be empty.
+ Q_PROPERTY(QString genericName MEMBER mGenericName CONSTANT);
+ /// Initial class or app id the app intends to use. May be useful for matching running apps
+ /// to desktop entries.
+ Q_PROPERTY(QString startupClass MEMBER mStartupClass CONSTANT);
+ /// If true, this application should not be displayed in menus and launchers.
+ Q_PROPERTY(bool noDisplay MEMBER mNoDisplay CONSTANT);
+ /// Long description of the application, such as "View websites on the internet". May be empty.
+ Q_PROPERTY(QString comment MEMBER mComment CONSTANT);
+ /// 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.
+ ///
+ /// > [!WARNING] This cannot be reliably run as a command. See @@command for one you can run.
+ Q_PROPERTY(QString execString MEMBER mExecString CONSTANT);
+ /// 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 MEMBER mCommand CONSTANT);
+ /// The working directory to execute from.
+ Q_PROPERTY(QString workingDirectory MEMBER mWorkingDirectory CONSTANT);
+ /// 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(QVector actions READ actions CONSTANT);
+ QML_ELEMENT;
+ QML_UNCREATABLE("DesktopEntry instances must be retrieved from DesktopEntries");
+
+public:
+ explicit DesktopEntry(QString id, QObject* parent): QObject(parent), mId(std::move(id)) {}
+
+ void parseEntry(const QString& text);
+
+ /// 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