From e9b10838bad7c3511d03d76c09efc02999bf872f Mon Sep 17 00:00:00 2001 From: Wayne Heaney <42350981+wheaney@users.noreply.github.com> Date: Thu, 16 May 2024 12:26:59 -0700 Subject: [PATCH] Breezy GNOME support (#22) * Adds a gnome subdirectory with the Breezy GNOME extension source * Adds a 'ui' subdirectory with the Gtk UI app source, currently with the Breezy GNOME view implemented, eventually will support Breezy Vulkan * Adds all the necessary package and setup scripts for the full GNOME build * Pulls in the latest driver with the BREEZY_DESKTOP build support --- .gitignore | 8 +- .gitmodules | 11 +- bin/breezy_gnome_setup | 56 +++ bin/package_gnome | 74 +++ bin/package_vulkan | 8 +- gnome/bin/breezy_gnome_uninstall | 55 +++ gnome/bin/breezy_gnome_verify | 49 ++ gnome/bin/dev/use_local_extension.sh | 20 + gnome/bin/package_extension | 79 +++ gnome/bin/setup | 64 +++ gnome/src/IMUAdjust.frag | 1 + gnome/src/cursor.js | 67 +++ gnome/src/cursormanager.js | 211 ++++++++ .../org.gnome.Mutter.DisplayConfig.xml | 453 ++++++++++++++++++ gnome/src/extension.js | 243 ++++++++++ gnome/src/globals.js | 5 + gnome/src/ipc.js | 49 ++ gnome/src/math.js | 3 + gnome/src/metadata.json | 10 + gnome/src/monitormanager.js | 167 +++++++ .../com.xronlinux.BreezyDesktop.gschema.xml | 1 + gnome/src/shader.js | 14 + gnome/src/textures/calibrating.png | 1 + gnome/src/textures/custom_banner.png | 1 + gnome/src/time.js | 7 + gnome/src/xrEffect.js | 320 +++++++++++++ modules/sombrero | 1 + modules/xrealAirLinuxDriver | 2 +- ui/bin/package | 28 ++ ui/build-aux/dconf-override.patch | 23 + ui/build-aux/start-breezy-desktop.sh | 37 ++ ui/com.xronlinux.BreezyDesktop.json | 86 ++++ .../com.xronlinux.BreezyDesktop.desktop.in | 8 + .../com.xronlinux.BreezyDesktop.gschema.xml | 68 +++ ...om.xronlinux.BreezyDesktop.metainfo.xml.in | 9 + .../apps/com.xronlinux.BreezyDesktop.svg | 1 + ui/data/icons/meson.build | 7 + ui/data/meson.build | 36 ++ ui/meson.build | 20 + ui/modules/PyXRLinuxDriverIPC | 1 + ui/po/LINGUAS | 0 ui/po/POTFILES | 6 + ui/po/meson.build | 1 + ui/python3-pydbus.json | 14 + ui/src/__init__.py | 0 ui/src/breezydesktop.gresource.xml | 10 + ui/src/breezydesktop.in | 46 ++ ui/src/connecteddevice.py | 116 +++++ ui/src/extensionsmanager.py | 70 +++ ui/src/gtk/connected-device.ui | 201 ++++++++ ui/src/gtk/no-device.ui | 19 + ui/src/gtk/no-extension.ui | 19 + ui/src/gtk/shortcut-dialog.ui | 43 ++ ui/src/gtk/window.ui | 38 ++ ui/src/main.py | 91 ++++ ui/src/meson.build | 43 ++ ui/src/nodevice.py | 5 + ui/src/noextension.py | 5 + ui/src/settingsmanager.py | 15 + ui/src/shortcutdialog.py | 109 +++++ ui/src/statemanager.py | 74 +++ ui/src/window.py | 55 +++ vulkan/custom_banner.png | Bin 153232 -> 153462 bytes vulkan/modules/sombrero | 1 - 64 files changed, 3274 insertions(+), 11 deletions(-) create mode 100755 bin/breezy_gnome_setup create mode 100755 bin/package_gnome create mode 100755 gnome/bin/breezy_gnome_uninstall create mode 100755 gnome/bin/breezy_gnome_verify create mode 100755 gnome/bin/dev/use_local_extension.sh create mode 100755 gnome/bin/package_extension create mode 100755 gnome/bin/setup create mode 120000 gnome/src/IMUAdjust.frag create mode 100644 gnome/src/cursor.js create mode 100644 gnome/src/cursormanager.js create mode 100644 gnome/src/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml create mode 100644 gnome/src/extension.js create mode 100644 gnome/src/globals.js create mode 100644 gnome/src/ipc.js create mode 100644 gnome/src/math.js create mode 100644 gnome/src/metadata.json create mode 100644 gnome/src/monitormanager.js create mode 120000 gnome/src/schemas/com.xronlinux.BreezyDesktop.gschema.xml create mode 100644 gnome/src/shader.js create mode 120000 gnome/src/textures/calibrating.png create mode 120000 gnome/src/textures/custom_banner.png create mode 100644 gnome/src/time.js create mode 100644 gnome/src/xrEffect.js create mode 160000 modules/sombrero create mode 100755 ui/bin/package create mode 100644 ui/build-aux/dconf-override.patch create mode 100644 ui/build-aux/start-breezy-desktop.sh create mode 100644 ui/com.xronlinux.BreezyDesktop.json create mode 100644 ui/data/com.xronlinux.BreezyDesktop.desktop.in create mode 100644 ui/data/com.xronlinux.BreezyDesktop.gschema.xml create mode 100644 ui/data/com.xronlinux.BreezyDesktop.metainfo.xml.in create mode 100644 ui/data/icons/hicolor/scalable/apps/com.xronlinux.BreezyDesktop.svg create mode 100644 ui/data/icons/meson.build create mode 100644 ui/data/meson.build create mode 100644 ui/meson.build create mode 160000 ui/modules/PyXRLinuxDriverIPC create mode 100644 ui/po/LINGUAS create mode 100644 ui/po/POTFILES create mode 100644 ui/po/meson.build create mode 100644 ui/python3-pydbus.json create mode 100644 ui/src/__init__.py create mode 100644 ui/src/breezydesktop.gresource.xml create mode 100755 ui/src/breezydesktop.in create mode 100644 ui/src/connecteddevice.py create mode 100644 ui/src/extensionsmanager.py create mode 100644 ui/src/gtk/connected-device.ui create mode 100644 ui/src/gtk/no-device.ui create mode 100644 ui/src/gtk/no-extension.ui create mode 100644 ui/src/gtk/shortcut-dialog.ui create mode 100644 ui/src/gtk/window.ui create mode 100644 ui/src/main.py create mode 100644 ui/src/meson.build create mode 100644 ui/src/nodevice.py create mode 100644 ui/src/noextension.py create mode 100644 ui/src/settingsmanager.py create mode 100644 ui/src/shortcutdialog.py create mode 100644 ui/src/statemanager.py create mode 100644 ui/src/window.py delete mode 160000 vulkan/modules/sombrero diff --git a/.gitignore b/.gitignore index 6f4d703..cc9412a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ -/vulkan/build/ -/build/ \ No newline at end of file +build/ +__pycache__ +*.zip +gschemas.compiled +.flatpak-builder/ +out/ diff --git a/.gitmodules b/.gitmodules index a6a6cb3..935efb3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,9 +2,12 @@ path = vulkan/modules/vkBasalt url = https://github.com/wheaney/vkBasalt.git branch = runtime_uniforms -[submodule "vulkan/modules/sombrero"] - path = vulkan/modules/sombrero - url = https://github.com/wheaney/sombrero.git [submodule "modules/xrealAirLinuxDriver"] path = modules/xrealAirLinuxDriver - url = https://github.com/wheaney/XRLinuxDriver.git \ No newline at end of file + url = https://github.com/wheaney/XRLinuxDriver.git +[submodule "modules/sombrero"] + path = modules/sombrero + url = https://github.com/wheaney/sombrero.git +[submodule "ui/modules/PyXRLinuxDriverIPC"] + path = ui/modules/PyXRLinuxDriverIPC + url = https://github.com/wheaney/PyXRLinuxDriverIPC.git diff --git a/bin/breezy_gnome_setup b/bin/breezy_gnome_setup new file mode 100755 index 0000000..c5d9ce6 --- /dev/null +++ b/bin/breezy_gnome_setup @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# This setup script should do the minimum work required to download the release package, unzip it, and kick off the +# setup script contained within. + +# exit when any command fails +set -e + +if [ "$(id -u)" == "0" ]; then + echo "This script must not be run as root" 1>&2 + exit 1 +fi + +start_dir=$(pwd) + +# create temp directory +tmp_dir=$(mktemp -d -t breezy-gnome-XXXXXXXXXX) +pushd $tmp_dir > /dev/null +echo "Created temp directory: ${tmp_dir}" + +# if the first argument is "-v" then the second argument is metrics version, and the third argument is binary path +# otherwise, if the first argument is present, it's the binary path +if [ "$1" = "-v" ] +then + metrics_version="$2" + binary_path_arg="$3" +else + binary_path_arg="$1" +fi + +if [ -z "$binary_path_arg" ] +then + # download and unzip the latest driver + echo "Downloading latest release to: ${tmp_dir}/breezyGNOME.tar.gz" + curl -L -O https://github.com/wheaney/breezy-desktop/releases/latest/download/breezyGNOME.tar.gz +else + if [[ "$binary_path_arg" = /* ]]; then + abs_path="$binary_path_arg" + else + # Convert relative path to absolute path + abs_path=$(realpath "$start_dir/$binary_path_arg") + fi + cp $abs_path $tmp_dir +fi + +echo "Extracting to: ${tmp_dir}/breezy_gnome" +tar -xf breezyGNOME.tar.gz + +pushd breezy_gnome > /dev/null + +# run the setup script that comes with this release +bin/setup $metrics_version + +echo "Deleting temp directory: ${tmp_dir}" +rm -rf $tmp_dir +cd "$(dirs -l -0)" && dirs -c diff --git a/bin/package_gnome b/bin/package_gnome new file mode 100755 index 0000000..65c8bfe --- /dev/null +++ b/bin/package_gnome @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +# exit when any command fails +set -e + +# https://stackoverflow.com/a/246128 +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +ROOT_DIR=$(realpath $SCRIPT_DIR/..) +VULKAN_DIR=$ROOT_DIR/vulkan +GNOME_DIR=$ROOT_DIR/gnome +GNOME_BUILD_DIR=$GNOME_DIR/build + +rm -rf $GNOME_BUILD_DIR +mkdir -p $GNOME_BUILD_DIR + +PACKAGE_DIR=$GNOME_BUILD_DIR/breezy_gnome +rm -rf $PACKAGE_DIR +mkdir -p $PACKAGE_DIR + +XREAL_DRIVER_DIR=$ROOT_DIR/modules/xrealAirLinuxDriver +source $XREAL_DRIVER_DIR/bin/inject_ua + +# check out submodules, recursively for nested ones +git submodule update --init --recursive + +# if a custom_banner image exists, copy it over the sombrero one +if [ -e "$VULKAN_DIR/custom_banner.png" ]; then + cp $VULKAN_DIR/custom_banner.png $PACKAGE_DIR +fi + +# copy vulkan setup scripts and configs +mkdir -p $PACKAGE_DIR/bin +copy_and_inject_ua "$XREAL_DRIVER_DIR/bin/ua.sh" "$PACKAGE_DIR/bin" "$GNOME_DIR/bin/setup" "$GNOME_DIR/bin/breezy_gnome_verify" "$GNOME_DIR/bin/breezy_gnome_uninstall" + +XREAL_BINARY=$XREAL_DRIVER_DIR/build/xrealAirLinuxDriver.tar.gz +pushd $XREAL_DRIVER_DIR + +if [ ! -e "$XREAL_BINARY" ] || [ "$1" != "--skip-module-builds" ]; then + # if a file exists at custom_banner_config.yml, copy it to the xrealAirLinuxDriver directory + if [ -e "$VULKAN_DIR/custom_banner_config.yml" ]; then + cp $VULKAN_DIR/custom_banner_config.yml $XREAL_DRIVER_DIR + fi + + BREEZY_DESKTOP=1 bin/package +fi + +XREAL_MANIFEST_LINE=$(sha256sum build/driver_air_glasses/manifest) +popd + +cp $XREAL_BINARY $PACKAGE_DIR +cp $XREAL_DRIVER_DIR/bin/xreal_driver_setup $PACKAGE_DIR/bin + +gnome/bin/package_extension +cp gnome/out/* $PACKAGE_DIR + +# create a checksum that combines the checksums of all files in the directory +pushd gnome/src +GNOME_MANIFEST_LINE=$(find -L . -type f ! -name "*.compiled" -exec sha256sum {} \; | sort | sha256sum | sed 's/ .*//') +popd + +ui/bin/package +cp ui/out/* $PACKAGE_DIR + +# create manifest file for verifying installed file checksums against the originally packaged versions +# include any file that doesn't get modified during setup (e.g. vkBasalt.json files) +pushd $PACKAGE_DIR +echo $XREAL_MANIFEST_LINE > manifest +echo -e "$GNOME_MANIFEST_LINE breezydesktop@xronlinux.com" >> manifest +popd + +# bundle everything up +pushd $GNOME_BUILD_DIR +tar -zcvf breezyGNOME.tar.gz breezy_gnome +popd \ No newline at end of file diff --git a/bin/package_vulkan b/bin/package_vulkan index 59cb32f..0b12c32 100755 --- a/bin/package_vulkan +++ b/bin/package_vulkan @@ -41,8 +41,8 @@ cp $VKBASALT_BUILD_DIR/builddir/config/vkBasalt.json $PACKAGE_DIR/vkBasalt.64/ cp $VKBASALT_BUILD_DIR/builddir.32/src/libvkbasalt.so $PACKAGE_DIR/vkBasalt.32/ # copy Sombrero FX, get ReShade headers -cp $VULKAN_MODULES/sombrero/*.fx $PACKAGE_DIR -cp $VULKAN_MODULES/sombrero/*.png $PACKAGE_DIR +cp modules/sombrero/*.fx $PACKAGE_DIR +cp modules/sombrero/*.png $PACKAGE_DIR wget -P $PACKAGE_DIR https://raw.githubusercontent.com/crosire/reshade-shaders/384465d0287999caa6190b5ebea506200b4f4a0a/Shaders/ReShade.fxh wget -P $PACKAGE_DIR https://raw.githubusercontent.com/crosire/reshade-shaders/384465d0287999caa6190b5ebea506200b4f4a0a/Shaders/ReShadeUI.fxh @@ -58,7 +58,6 @@ cp -r $VULKAN_DIR/config $PACKAGE_DIR # build xreal driver XREAL_BINARY=$XREAL_DRIVER_DIR/build/xrealAirLinuxDriver.tar.gz -pushd $XREAL_DRIVER_DIR if [ ! -e "$XREAL_BINARY" ] || [ "$1" != "--skip-module-builds" ]; then # if a file exists at custom_banner_config.yml, copy it to the xrealAirLinuxDriver directory @@ -66,7 +65,10 @@ if [ ! -e "$XREAL_BINARY" ] || [ "$1" != "--skip-module-builds" ]; then cp $VULKAN_DIR/custom_banner_config.yml $XREAL_DRIVER_DIR fi + pushd $XREAL_DRIVER_DIR bin/package +else + pushd $XREAL_DRIVER_DIR fi XREAL_MANIFEST_LINE=$(sha256sum build/driver_air_glasses/manifest) diff --git a/gnome/bin/breezy_gnome_uninstall b/gnome/bin/breezy_gnome_uninstall new file mode 100755 index 0000000..fbffccc --- /dev/null +++ b/gnome/bin/breezy_gnome_uninstall @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +for_install=0 +if [[ -n "$1" ]] && [[ "$1" == "--for-install" ]]; then + for_install=1 +fi + +# we don't want the uninstall script to be able to cause a failure if being triggered by the setup script +[ "$for_install" -eq 0 ] && set -e + +if [ "$(id -u)" == "0" ]; then + echo "This script must not be run as root" 1>&2 + exit 1 +fi + +USER_HOME=$(realpath ~) +if [ "$for_install" -eq 0 ]; then + UA_EVENT_NAME="breezy_gnome_uninstall" + UA_CLIENT_ID="BreezyGNOME" + #INJECT_UA_CALL +fi + +if [ -z "$XDG_BIN_HOME" ]; then + XDG_BIN_HOME="$USER_HOME/.local/bin" +fi + +if [ -z "$XDG_DATA_HOME" ]; then + XDG_DATA_HOME="$USER_HOME/.local/share" +fi +DATA_DIR="$XDG_DATA_HOME/breezy_gnome" + + +[ "$for_install" -eq 0 ] && echo "Removing ${DATA_DIR} and its contents" +rm -rf $DATA_DIR + +[ "$for_install" -eq 0 ] && echo "Uninstalling the breezydesktop@xronlinux.com GNOME extension" +gnome-extensions uninstall breezydesktop@xronlinux.com + +[ "$for_install" -eq 0 ] && echo "Uninstalling the Breezy Desktop UI Flatpak" +flatpak uninstall --user --noninteractive --force-remove com.xronlinux.BreezyDesktop + +[ "$for_install" -eq 0 ] && echo "Uninstalling XRLinuxDriver" +# if for-install +if [ "$for_install" -eq 1 ]; then + sudo ~/bin/xreal_driver_uninstall --for-install +else + sudo ~/bin/xreal_driver_uninstall +fi + + +[ "$for_install" -eq 0 ] && echo "Removing the breezy_gnome scripts from ${XDG_BIN_HOME}" +rm -f $XDG_BIN_HOME/breezy_gnome_verify + +# this script is self-deleting, leave this as the last command +rm -f $XDG_BIN_HOME/breezy_gnome_uninstall \ No newline at end of file diff --git a/gnome/bin/breezy_gnome_verify b/gnome/bin/breezy_gnome_verify new file mode 100755 index 0000000..c91b47b --- /dev/null +++ b/gnome/bin/breezy_gnome_verify @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +set -e + +USER_HOME=$(realpath ~) + +if [ -z "$XDG_DATA_HOME" ]; then + XDG_DATA_HOME="$USER_HOME/.local/share" +fi +DATA_DIR="$XDG_DATA_HOME/breezy_gnome" + +# create a string to string mapping, file name to expected file location +declare -A file_paths +file_paths=( + ["build/driver_air_glasses/manifest"]="$USER_HOME/.local/bin/xr_driver/manifest" + ["breezydesktop@xronlinux.com"]="$XDG_DATA_HOME/gnome-shell/extensions/breezydesktop@xronlinux.com" +) + +# verify the file hashes in ./manifest +while IFS= read -r line +do + # split the line into hash and filename + manifest_hash=$(echo $line | awk '{print $1}') + file=$(echo $line | awk '{print $2}') + + actual_file_path=${file_paths[$file]} + + # check if the file path is a directory + if [ -d "$actual_file_path" ]; then + # compute the SHA256 hash of the directory contents + pushd $actual_file_path > /dev/null + actual_hash=$(find -L . -type f ! -name "*.compiled" -exec sha256sum {} \; | sort | sha256sum | sed 's/ .*//') + popd > /dev/null + else + # compute the SHA256 hash of the actual file + actual_hash=$(sha256sum $actual_file_path | awk '{print $1}') + fi + + # compare the hashes + if ! [ "$manifest_hash" = "$actual_hash" ]; then + echo "Verification failed" >&2 + exit 1 + fi +done < "$XDG_DATA_HOME/breezy_gnome/manifest" + +# if our checks succeeded, run the xr_driver verify script +$USER_HOME/.local/bin/xr_driver/verify_installation > /dev/null + +echo "Verification succeeded" \ No newline at end of file diff --git a/gnome/bin/dev/use_local_extension.sh b/gnome/bin/dev/use_local_extension.sh new file mode 100755 index 0000000..54e9577 --- /dev/null +++ b/gnome/bin/dev/use_local_extension.sh @@ -0,0 +1,20 @@ +USER_HOME=$(realpath ~) + +# https://stackoverflow.com/a/246128 +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) + +if [ -z "$XDG_DATA_HOME" ]; then + XDG_DATA_HOME="$USER_HOME/.local/share" +fi + +# if $XDG_DATA_HOME/gnome-shell/extensions/breezydesktop@xronlinux.com exists +extension_path="$XDG_DATA_HOME/gnome-shell/extensions/breezydesktop@xronlinux.com" +if [ -d $extension_path ]; then + # remove it + rm -rf $extension_path +fi + +# recursively copy the $SCRIPT_DIR/../../src to extension_path, don't preserve symlinks +cp -rL $SCRIPT_DIR/../../src $extension_path + +glib-compile-schemas $extension_path/schemas \ No newline at end of file diff --git a/gnome/bin/package_extension b/gnome/bin/package_extension new file mode 100755 index 0000000..69af779 --- /dev/null +++ b/gnome/bin/package_extension @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +UUID="breezydesktop@xronlinux.com" + +# fail on error +set -e + +if [[ $EUID -eq 0 ]]; then + echo "This script must NOT be run as root" 1>&2 + exit 1 +fi + +# https://stackoverflow.com/a/246128 +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) + +SRC_DIR="$SCRIPT_DIR/../src" +TMP_DIR=$(mktemp -d -t breezy-gnome-XXXXXXXXXX) +DEST_DIR="$TMP_DIR/$UUID" +OUT_DIR="$SCRIPT_DIR/../out" +rm -rf "$OUT_DIR" + +cd "$SCRIPT_DIR" || exit 1 + +check_command() { + if ! command -v "$1" &>/dev/null; then + echo "Please install \"$1\" and make sure it's available in your \$PATH" + exit 1 + fi +} + +compile_schemas() { + check_command "glib-compile-schemas" + mkdir -p "$DEST_DIR/schemas/" + + # the pack command also compiles the schemas but only into the zip file + glib-compile-schemas --targetdir="$DEST_DIR/schemas" "$SRC_DIR/schemas/" +} + +copy_static_files() { + # Copy non generated files to destdir + cp $SRC_DIR/*.js $DEST_DIR/ + cp $SRC_DIR/*.frag $DEST_DIR/ + mkdir -p "$DEST_DIR/schemas/" + cp $SRC_DIR/schemas/*.xml $DEST_DIR/schemas/ + mkdir -p $DEST_DIR/dbus-interfaces/ + cp $SRC_DIR/dbus-interfaces/*.xml $DEST_DIR/dbus-interfaces/ + mkdir -p $DEST_DIR/textures/ + cp -rL $SRC_DIR/textures/* $DEST_DIR/textures/ + cp $SRC_DIR/metadata.json $DEST_DIR/ +} + +pack() { + check_command "gnome-extensions" + + # pack everything into a sharable zip file + extra_source=() + for file in "$DEST_DIR"/*; do + extra_source+=("--extra-source=$file") + done + + mkdir -p "$OUT_DIR" + gnome-extensions pack --force "${extra_source[@]}" "$DEST_DIR" -o "$OUT_DIR" +} + +if [ $# -eq 0 ]; then + # No arguments, do everything + compile_schemas + copy_static_files + pack +elif [ "$1" == "build_local" ]; then + compile_schemas + copy_static_files +elif [ "$1" == "pack" ]; then + pack +elif [ "$1" == "copy_static" ]; then + copy_static_files +fi + +rm -rf "$TMP_DIR" \ No newline at end of file diff --git a/gnome/bin/setup b/gnome/bin/setup new file mode 100755 index 0000000..18f6ee6 --- /dev/null +++ b/gnome/bin/setup @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -e + +# This script gets packaged with the release and should do the bulk of the setup work. This allows this setup to be tied +# to a specific release of the code, and guarantees it will never run along-side newer or older binaries. + +USER_HOME=$(realpath ~) + +if [ -z "$XDG_BIN_HOME" ]; then + XDG_BIN_HOME="$USER_HOME/.local/bin" +fi + +if [ -z "$XDG_DATA_HOME" ]; then + XDG_DATA_HOME="$USER_HOME/.local/share" +fi +DATA_DIR="$XDG_DATA_HOME/breezy_gnome" + +UA_EVENT_NAME="breezy_gnome_install" +if [ -e "$XDG_BIN_HOME/breezy_gnome_uninstall" ]; then + echo "Cleaning up the previous installation" + + # ` || true` will ensure that this can't cause a failure, even with `set -e` + $XDG_BIN_HOME/breezy_gnome_uninstall --for-install || true + + UA_EVENT_NAME="breezy_gnome_update" +fi + +UA_CLIENT_ID="BreezyGNOME" +UA_EVENT_VERSION="$1" +#INJECT_UA_CALL + +echo "Copying the breezy_gnome scripts to ${XDG_BIN_HOME}" +mkdir -p $XDG_BIN_HOME +cp bin/breezy_gnome_uninstall $XDG_BIN_HOME +cp bin/breezy_gnome_verify $XDG_BIN_HOME + +echo "Copying the manifest file to ${DATA_DIR}" +mkdir -p $DATA_DIR +cp manifest $DATA_DIR + +echo "Installing the breezydesktop@xronlinux.com GNOME extension" +gnome-extensions install --force breezydesktop@xronlinux.com.shell-extension.zip + +echo "Installing the Breezy Desktop UI Flatpak (this may take a couple minutes the first time)" +flatpak install --user --noninteractive --reinstall com.xronlinux.BreezyDesktop.flatpak + +# set up the XREAL driver using the local binary +echo "Installing xrealAirLinuxDriver" +echo "BEGIN - xreal_driver_setup" +if [ -z "$1" ] +then + sudo bin/xreal_driver_setup $(pwd)/xrealAirLinuxDriver.tar.gz +else + sudo bin/xreal_driver_setup -v $1 $(pwd)/xrealAirLinuxDriver.tar.gz +fi + +echo "END - xreal_driver_setup" + +echo "Enabling the driver and setting it to Breezy Desktop mode" +$USER_HOME/bin/xreal_driver_config -e +$USER_HOME/bin/xreal_driver_config -vd + +sed -i 's/virtual_display/breezy_desktop/g' $USER_HOME/.xreal_driver_config \ No newline at end of file diff --git a/gnome/src/IMUAdjust.frag b/gnome/src/IMUAdjust.frag new file mode 120000 index 0000000..ffacb50 --- /dev/null +++ b/gnome/src/IMUAdjust.frag @@ -0,0 +1 @@ +../../modules/sombrero/IMUAdjust.frag \ No newline at end of file diff --git a/gnome/src/cursor.js b/gnome/src/cursor.js new file mode 100644 index 0000000..36ad7ee --- /dev/null +++ b/gnome/src/cursor.js @@ -0,0 +1,67 @@ +// Taken from https://github.com/jkitching/soft-brightness-plus +// +// Copyright (C) 2023 Joel Kitching (jkitching on Github) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; + +// Copied almost verbatim from ui/magnifier.js. +export const MouseSpriteContent = GObject.registerClass({ + Implements: [Clutter.Content], +}, class MouseSpriteContent extends GObject.Object { + _init() { + super._init(); + this._texture = null; + } + + vfunc_get_preferred_size() { + if (!this._texture) + return [false, 0, 0]; + + return [true, this._texture.get_width(), this._texture.get_height()]; + } + + vfunc_paint_content(actor, node, _paintContext) { + if (!this._texture) + return; + + let [minFilter, magFilter] = actor.get_content_scaling_filters(); + let textureNode = new Clutter.TextureNode(this._texture, + null, minFilter, magFilter); + textureNode.set_name('BreezyDesktopSpriteContent'); + node.add_child(textureNode); + + textureNode.add_rectangle(actor.get_content_box()); + } + + get texture() { + return this._texture; + } + + set texture(coglTexture) { + if (this._texture === coglTexture) + return; + + let oldTexture = this._texture; + this._texture = coglTexture; + this.invalidate(); + + if (!oldTexture || !coglTexture || + oldTexture.get_width() !== coglTexture.get_width() || + oldTexture.get_height() !== coglTexture.get_height()) + this.invalidate_size(); + } +}); \ No newline at end of file diff --git a/gnome/src/cursormanager.js b/gnome/src/cursormanager.js new file mode 100644 index 0000000..9f80ad0 --- /dev/null +++ b/gnome/src/cursormanager.js @@ -0,0 +1,211 @@ +import Clutter from 'gi://Clutter'; +import Meta from 'gi://Meta'; +import * as PointerWatcher from 'resource:///org/gnome/shell/ui/pointerWatcher.js'; +import { MouseSpriteContent } from './cursor.js'; + +// Taken from https://github.com/jkitching/soft-brightness-plus +export class CursorManager { + constructor(mainActor) { + this._mainActor = mainActor; + + this._changeHookFn = null; + + // Set/destroyed by _enableCloningMouse/_disableCloningMouse + this._cursorWantedVisible = null; + this._cursorTracker = null; + this._cursorTrackerSetPointerVisible = null; + this._cursorTrackerSetPointerVisibleBound = null; + this._cursorSprite = null; + this._cursorActor = null; + this._cursorWatcher = null; + this._cursorSeat = null; + this._cursorUnfocusInhibited = false; + + // Set/destroyed by _startCloningMouse / _stopCloningMouse + this._cursorWatch = null; + this._cursorChangedConnection = null; + this._cursorVisibilityChangedConnection = null; + this._cursorPositionInvalidatedConnection = null; + } + + enable() { + this._enableCloningMouse(); + this.startCloning(); + } + + disable() { + this._disableCloningMouse(); + } + + startCloning() { + this._startCloningMouse(); + } + + stopCloning() { + this._stopCloningMouse(); + } + + // After this: + // * real cursor is disabled + // * cloning is "on" + // * cloned cursor not visible, but ready for _startCloningMouse to make it visible + // + // okay if _startCloningMouse is not immediately called since set_pointer_visible is bound to our replacement function + // and will trigger _startCloningMouse when the cursor should be shown + _enableCloningMouse() { + this._cursorTracker = Meta.CursorTracker.get_for_display(global.display); + this._cursorWantedVisible = this._cursorTracker.get_pointer_visible(); + this._cursorTrackerSetPointerVisible = Meta.CursorTracker.prototype.set_pointer_visible; + this._cursorTrackerSetPointerVisibleBound = this._cursorTrackerSetPointerVisible.bind(this._cursorTracker); + Meta.CursorTracker.prototype.set_pointer_visible = this._cursorTrackerSetPointerVisibleReplacement.bind(this); + + this._cursorTrackerSetPointerVisibleBound(false); + + this._cursorSprite = new Clutter.Actor({ request_mode: Clutter.RequestMode.CONTENT_SIZE }); + this._cursorSprite.content = new MouseSpriteContent(); + + this._cursorActor = new Clutter.Actor(); + if (Clutter.Container === undefined) { + this._cursorActor.add_child(this._cursorSprite); + } else { + this._cursorActor.add_actor(this._cursorSprite); + } + this._cursorWatcher = PointerWatcher.getPointerWatcher(); + this._cursorSeat = Clutter.get_default_backend().get_default_seat(); + } + + // After this: + // * real cursor enabled, manages its own visibility + // * cloning is "off" + // * no cloned cursor + // + // completely reverts _enableCloningMouse + _disableCloningMouse() { + this._stopCloningMouse(); + Meta.CursorTracker.prototype.set_pointer_visible = this._cursorTrackerSetPointerVisible; + this._cursorTracker.set_pointer_visible(this._cursorWantedVisible); + + this._cursorWantedVisible = null; + this._cursorTracker = null; + this._cursorTrackerSetPointerVisible = null; + this._cursorTrackerSetPointerVisibleBound = null; + this._cursorSprite = null; + this._cursorActor = null; + this._cursorWatcher = null; + this._cursorSeat = null; + } + + // bound to Meta.CursorTracker.prototype.set_pointer_visible when cloning is "on" + // original function available in this._cursorTrackerSetPointerVisibleBound + _cursorTrackerSetPointerVisibleReplacement(visible) { + this._cursorWantedVisible = visible; + if (visible) { + this._startCloningMouse(); + } else { + this._stopCloningMouse(); + } + } + + // After this: + // * real cursor is hidden + // * cloning is "on" + // * clone cursor is visible + // + // add the clone cursor actor, watch for pointer movement and cursor changes, reflect them in the cloned cursor + // prereqs: setup in _enableCloningMouse, _cursorWantedVisible is true + _startCloningMouse() { + if (this._cursorWatch == null) { + if (Clutter.Container === undefined) { + this._mainActor.add_child(this._cursorActor); + } else { + this._mainActor.add_actor(this._cursorActor); + } + this._cursorChangedConnection = this._cursorTracker.connect('cursor-changed', this._updateMouseSprite.bind(this)); + this._cursorVisibilityChangedConnection = this._cursorTracker.connect('visibility-changed', this._updateMouseSprite.bind(this)); + this._cursorPositionInvalidatedConnection = this._cursorTracker.connect('position-invalidated', this._updateMouseSprite.bind(this)); + + const interval = 1000 / 250; + this._cursorWatch = this._cursorWatcher.addWatch(interval, this._updateMousePosition.bind(this)); + + const [x, y] = global.get_pointer(); + this._updateMousePosition(x, y); + this._updateMouseSprite(); + } + + if (this._cursorTracker.set_keep_focus_while_hidden) { + this._cursorTracker.set_keep_focus_while_hidden(true); + } + + if (!this._cursorUnfocusInhibited) { + console.log('Breezy debug - inhibit_unfocus\n'); + this._cursorSeat.inhibit_unfocus(); + this._cursorUnfocusInhibited = true; + } + } + + // After this: + // * real cursor is hidden + // * cloning is "on" + // * cloned cursor not visible, but ready for _startCloningMouse to make it visible + // + // completely reverts _startCloningMouse + _stopCloningMouse() { + if (this._cursorWatch != null) { + this._cursorWatch.remove(); + this._cursorWatch = null; + + this._cursorTracker.disconnect(this._cursorChangedConnection); + this._cursorChangedConnection = null; + + this._cursorTracker.disconnect(this._cursorVisibilityChangedConnection); + this._cursorVisibilityChangedConnection = null; + + this._cursorTracker.disconnect(this._cursorPositionInvalidatedConnection); + this._cursorPositionInvalidatedConnection = null; + + if (Clutter.Container === undefined) { + this._mainActor.remove_child(this._cursorActor); + } else { + this._mainActor.remove_actor(this._cursorActor); + } + } + + if (this._cursorTracker.set_keep_focus_while_hidden) { + this._cursorTracker.set_keep_focus_while_hidden(false); + } + + if (this._cursorUnfocusInhibited) { + console.log('Breezy debug - uninhibit_unfocus\n'); + this._cursorSeat.uninhibit_unfocus(); + this._cursorUnfocusInhibited = false; + } + } + + _updateMousePosition(x, y) { + this._cursorActor.set_position(x, y); + } + + _updateMouseSprite() { + const sprite = this._cursorTracker.get_sprite(); + if (sprite) { + this._cursorSprite.content.texture = sprite; + this._cursorSprite.show(); + } else { + this._cursorSprite.hide(); + } + + const [xHot, yHot] = this._cursorTracker.get_hot(); + this._cursorSprite.set({ + translation_x: -xHot, + translation_y: -yHot, + }); + this._mainActor.set_child_above_sibling(this._cursorActor, null); + this._cursorTrackerSetPointerVisibleBound(false); + + // some other processes are uninhibiting when they shouldn't, so we need to re-inhibit here + if (!this._cursorSeat.is_unfocus_inhibited() && this._cursorUnfocusInhibited) { + console.log('Breezy debug - reinhibiting\n'); + this._cursorSeat.inhibit_unfocus(); + } + } +} \ No newline at end of file diff --git a/gnome/src/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml b/gnome/src/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml new file mode 100644 index 0000000..50b07fa --- /dev/null +++ b/gnome/src/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml @@ -0,0 +1,453 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gnome/src/extension.js b/gnome/src/extension.js new file mode 100644 index 0000000..d5d5384 --- /dev/null +++ b/gnome/src/extension.js @@ -0,0 +1,243 @@ +import Clutter from 'gi://Clutter' +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; +import St from 'gi://St'; + +import { CursorManager } from './cursormanager.js'; +import Globals from './globals.js'; +import MonitorManager from './monitormanager.js'; +import { IPC_FILE_PATH, XREffect } from './xrEffect.js'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +const SUPPORTED_MONITOR_PRODUCTS = [ + 'VITURE', + 'nreal air', + 'Air', + 'MetaMonitor' // nested mode dummy monitor +]; + +export default class BreezyDesktopExtension extends Extension { + constructor(metadata, uuid) { + super(metadata, uuid); + + this.settings = this.getSettings(); + + // Set/destroyed by enable/disable + this._cursor_manager = null; + this._monitor_manager = null; + this._xr_effect = null; + this._overlay = null; + this._target_monitor = null; + this._is_effect_running = false; + } + + enable() { + Globals.extension_dir = this.path; + this._monitor_manager = new MonitorManager(this.path); + this._monitor_manager.setChangeHook(this._setup.bind(this)); + this._monitor_manager.enable(); + + this._setup(); + } + + _poll_for_ready() { + var target_monitor = this._target_monitor; + var is_effect_running = this._is_effect_running; + this._running_poller_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, (() => { + if (is_effect_running) return GLib.SOURCE_REMOVE; + + const is_driver_running = this._check_driver_running(); + if (is_driver_running && target_monitor) { + console.log('Driver is running, supported monitor connected. Enabling XR effect.'); + this._effect_enable(); + return GLib.SOURCE_REMOVE; + } else { + return GLib.SOURCE_CONTINUE; + } + }).bind(this)); + } + + _find_supported_monitor() { + const target_monitor = this._monitor_manager.getMonitorPropertiesList()?.find( + monitor => SUPPORTED_MONITOR_PRODUCTS.includes(monitor.product)); + if (target_monitor !== undefined) { + return { + monitor: this._monitor_manager.getMonitors()[target_monitor.index], + refreshRate: target_monitor.refreshRate, + }; + } + + if (this.settings.get_boolean('developer-mode')) { + // allow testing XR devices with just USB, no video needed + return { + monitor: this._monitor_manager.getMonitors()[0], + refreshRate: 60, + }; + } + + return null; + } + + _setup() { + if (this._is_effect_running) { + console.log('Monitors changed, disabling XR effect'); + this._effect_disable(); + } + const target_monitor = this._find_supported_monitor(); + + // if target_monitor isn't set, do nothing and wait for MonitorManager to call this again + if (target_monitor && this._running_poller_id === undefined) { + this._target_monitor = target_monitor.monitor; + this._refresh_rate = target_monitor.refreshRate; + + if (this._check_driver_running()) { + console.log('Ready, enabling XR effect'); + this._effect_enable(); + } else { + this._poll_for_ready(); + } + } + } + + _check_driver_running() { + if (!Globals.ipc_file) Globals.ipc_file = Gio.file_new_for_path(IPC_FILE_PATH); + return Globals.ipc_file.query_exists(null); + } + + _effect_enable() { + this._running_poller_id = undefined; + if (!this._is_effect_running) { + this._is_effect_running = true; + + try { + this._cursor_manager = new CursorManager(Main.layoutManager.uiGroup); + this._cursor_manager.enable(); + + this._overlay = new St.Bin({ style: 'background-color: rgba(0, 0, 0, 1);'}); + this._overlay.opacity = 255; + this._overlay.set_position(this._target_monitor.x, this._target_monitor.y); + this._overlay.set_size(this._target_monitor.width, this._target_monitor.height); + + const overlayContent = new Clutter.Actor({clip_to_allocation: true}); + const uiClone = new Clutter.Clone({ source: Main.layoutManager.uiGroup, clip_to_allocation: true }); + uiClone.x = -this._target_monitor.x; + uiClone.y = -this._target_monitor.y; + if (Clutter.Container === undefined) { + overlayContent.add_child(uiClone); + } else { + overlayContent.add_actor(uiClone); + } + + this._overlay.set_child(overlayContent); + + global.stage.insert_child_above(this._overlay, null); + Shell.util_set_hidden_from_pick(this._overlay, true); + + this._xr_effect = new XREffect({ + target_monitor: this._target_monitor, + target_framerate: this._refresh_rate ?? 60, + display_distance: this.settings.get_double('display-distance'), + toggle_display_distance_start: this.settings.get_double('toggle-display-distance-start'), + toggle_display_distance_end: this.settings.get_double('toggle-display-distance-end'), + }); + + this.settings.bind('display-distance', this._xr_effect, 'display-distance', Gio.SettingsBindFlags.DEFAULT) + this.settings.bind('toggle-display-distance-start', this._xr_effect, 'toggle-display-distance-start', Gio.SettingsBindFlags.DEFAULT) + this.settings.bind('toggle-display-distance-end', this._xr_effect, 'toggle-display-distance-end', Gio.SettingsBindFlags.DEFAULT) + + this._overlay.add_effect_with_name('xr-desktop', this._xr_effect); + Meta.disable_unredirect_for_display(global.display); + + this._add_settings_keybinding('recenter-display-shortcut', this._recenter_display.bind(this)); + this._add_settings_keybinding('toggle-display-distance-shortcut', this._xr_effect._change_distance.bind(this._xr_effect)); + this._add_settings_keybinding('toggle-follow-shortcut', this._toggle_follow_mode.bind(this)); + } catch (e) { + console.error(`Error enabling XR effect ${e.message}`, e.stack); + this._effect_disable(); + } + } + } + + _add_settings_keybinding(settings_key, bind_to_function) { + Main.wm.addKeybinding( + settings_key, + this.settings, + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP, + bind_to_function + ); + + // Connect to the 'changed' signal for the keybinding property + this.settings.connect(`changed::${settings_key}`, () => { + // Remove the old keybinding + Main.wm.removeKeybinding(settings_key); + + // Add the updated keybinding + Main.wm.addKeybinding( + settings_key, + this.settings, + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP, + bind_to_function + ); + }); + } + + _write_control(key, value) { + const file = Gio.file_new_for_path('/dev/shm/xr_driver_control'); + const stream = file.replace(null, false, Gio.FileCreateFlags.NONE, null); + stream.write(`${key}=${value}`, null); + stream.close(null); + } + + _recenter_display() { + this._write_control('recenter_screen', 'true'); + } + + _toggle_follow_mode() { + this._write_control('toggle_breezy_desktop_smooth_follow', 'true'); + } + + _effect_disable() { + this._is_effect_running = false; + + if (this._running_poller_id) GLib.source_remove(this._running_poller_id); + + Main.wm.removeKeybinding('recenter-display-shortcut'); + Main.wm.removeKeybinding('toggle-display-distance-shortcut'); + Meta.enable_unredirect_for_display(global.display); + + if (this._overlay) { + global.stage.remove_child(this._overlay); + this._overlay.remove_effect_by_name('xr-desktop'); + this._overlay.destroy(); + this._overlay = null; + } + + if (this._xr_effect) { + this._xr_effect = null; + } + + if (this._cursor_manager) { + this._cursor_manager.disable(); + this._cursor_manager = null; + } + } + + disable() { + this._effect_disable(); + this._target_monitor = null; + if (this._monitor_manager) { + this._monitor_manager.disable(); + this._monitor_manager = null; + } + } +} + +function init() { + return new Extension(); +} diff --git a/gnome/src/globals.js b/gnome/src/globals.js new file mode 100644 index 0000000..b30b510 --- /dev/null +++ b/gnome/src/globals.js @@ -0,0 +1,5 @@ +const Globals = { + ipc_file: null, // Gio.File instance, file exists + extension_dir: null // string path +} +export default Globals; \ No newline at end of file diff --git a/gnome/src/ipc.js b/gnome/src/ipc.js new file mode 100644 index 0000000..77d8fa1 --- /dev/null +++ b/gnome/src/ipc.js @@ -0,0 +1,49 @@ +export const UINT8_SIZE = 1; +export const BOOL_SIZE = UINT8_SIZE; +export const UINT_SIZE = 4; +export const FLOAT_SIZE = 4; + +export const DATA_VIEW_INFO_OFFSET_INDEX = 0; +export const DATA_VIEW_INFO_SIZE_INDEX = 1; +export const DATA_VIEW_INFO_COUNT_INDEX = 2; + +// computes the end offset, exclusive +export function dataViewEnd(dataViewInfo) { + return dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX] + dataViewInfo[DATA_VIEW_INFO_SIZE_INDEX] * dataViewInfo[DATA_VIEW_INFO_COUNT_INDEX]; +} + +export function dataViewUint8(dataView, dataViewInfo) { + return dataView.getUint8(dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX]); +} + +export function dataViewUint(dataView, dataViewInfo) { + return dataView.getUint32(dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX], true); +} + +export function dataViewBigUint(dataView, dataViewInfo) { + return Number(dataView.getBigUint64(dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX], true)); +} + +export function dataViewUintArray(dataView, dataViewInfo) { + const uintArray = [] + let offset = dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX]; + for (let i = 0; i < dataViewInfo[DATA_VIEW_INFO_COUNT_INDEX]; i++) { + uintArray.push(dataView.getUint32(offset, true)); + offset += UINT_SIZE; + } + return uintArray; +} + +export function dataViewFloat(dataView, dataViewInfo) { + return dataView.getFloat32(dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX], true); +} + +export function dataViewFloatArray(dataView, dataViewInfo) { + const floatArray = [] + let offset = dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX]; + for (let i = 0; i < dataViewInfo[DATA_VIEW_INFO_COUNT_INDEX]; i++) { + floatArray.push(dataView.getFloat32(offset, true)); + offset += FLOAT_SIZE; + } + return floatArray; +} \ No newline at end of file diff --git a/gnome/src/math.js b/gnome/src/math.js new file mode 100644 index 0000000..497274e --- /dev/null +++ b/gnome/src/math.js @@ -0,0 +1,3 @@ +export function degreeToRadian(degree) { + return degree * Math.PI / 180; +} \ No newline at end of file diff --git a/gnome/src/metadata.json b/gnome/src/metadata.json new file mode 100644 index 0000000..894c670 --- /dev/null +++ b/gnome/src/metadata.json @@ -0,0 +1,10 @@ +{ + "uuid": "breezydesktop@xronlinux.com", + "name": "Breezy GNOME XR Desktop", + "description": "XR virtual desktop for GNOME.", + "settings-schema": "com.xronlinux.BreezyDesktop", + "shell-version": [ + "45", "46" + ], + "url": "https://github.com/wheaney/breezy-desktop" +} \ No newline at end of file diff --git a/gnome/src/monitormanager.js b/gnome/src/monitormanager.js new file mode 100644 index 0000000..09e4e2b --- /dev/null +++ b/gnome/src/monitormanager.js @@ -0,0 +1,167 @@ +// Taken from https://github.com/jkitching/soft-brightness-plus +// +// Copyright (C) 2019, 2021 Philippe Troin (F-i-f on Github) +// Copyright (C) 2023 Joel Kitching (jkitching on Github) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import Gio from 'gi://Gio'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +let cachedDisplayConfigProxy = null; + +function getDisplayConfigProxy(extPath) { + if (cachedDisplayConfigProxy == null) { + let xml = null; + const file = Gio.File.new_for_path(extPath + '/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml'); + try { + const [ok, bytes] = file.load_contents(null); + if (ok) { + xml = new TextDecoder().decode(bytes); + } + } catch (e) { + console.error('failed to load DisplayConfig interface XML'); + throw e; + } + cachedDisplayConfigProxy = Gio.DBusProxy.makeProxyWrapper(xml); + } + return cachedDisplayConfigProxy; +} + +export function newDisplayConfig(extPath, callback) { + const DisplayConfigProxy = getDisplayConfigProxy(extPath); + new DisplayConfigProxy( + Gio.DBus.session, + 'org.gnome.Mutter.DisplayConfig', + '/org/gnome/Mutter/DisplayConfig', + callback + ); +} + +export function getMonitorConfig(displayConfigProxy, callback) { + displayConfigProxy.GetResourcesRemote((result) => { + if (result.length <= 2) { + callback(null, 'Cannot get DisplayConfig: No outputs in GetResources()'); + } else { + const monitors = []; + for (let i = 0; i < result[2].length; i++) { + const output = result[2][i]; + if (output.length <= 7) { + callback(null, 'Cannot get DisplayConfig: No properties on output #' + i); + return; + } + const props = output[7]; + const displayName = props['display-name'].get_string()[0]; + const connectorName = output[4]; + if (!displayName || displayName == '') { + const displayName = 'Monitor on output ' + connectorName; + } + const vendor = props['vendor'].get_string()[0]; + const product = props['product'].get_string()[0]; + const serial = props['serial'].get_string()[0]; + + // grab refresh rate from the modes array + const refreshRate = result[3][i][4]; + + monitors.push([displayName, connectorName, vendor, product, serial, refreshRate]); + } + callback(monitors, null); + } + }); +} + +// Monitor change handling +export default class MonitorManager { + constructor(extPath) { + this._extPath = extPath; + + this._monitorsChangedConnection = null; + this._displayConfigProxy = null; + this._backendManager = null; + this._monitorProperties = null; + this._changeHookFn = null; + } + + enable() { + this._backendManager = global.backend.get_monitor_manager(); + newDisplayConfig(this._extPath, (proxy, error) => { + if (error) { + return; + } + this._displayConfigProxy = proxy; + this._on_monitors_change(); + }); + + this._monitorsChangedConnection = Main.layoutManager.connect('monitors-changed', this._on_monitors_change.bind(this)); + } + + disable() { + Main.layoutManager.disconnect(this._monitorsChangedConnection); + + this._monitorsChangedConnection = null; + this._displayConfigProxy = null; + this._backendManager = null; + this._monitorProperties = null; + this._changeHookFn = null; + } + + setChangeHook(fn) { + this._changeHookFn = fn; + } + + setPostCallback(callback) { + this._postCallback = callback; + } + + getMonitors() { + return Main.layoutManager.monitors; + } + + getMonitorPropertiesList() { + return this._monitorProperties; + } + + _on_monitors_change() { + if (this._displayConfigProxy == null) { + return; + } + getMonitorConfig(this._displayConfigProxy, (result, error) => { + if (error) { + return; + } + const monitorProperties = []; + for (let i = 0; i < result.length; i++) { + const [monitorName, connectorName, vendor, product, serial, refreshRate] = result[i]; + const monitorIndex = this._backendManager.get_monitor_for_connector(connectorName); + console.log(`\n\nFound monitor ${monitorName}, vendor ${vendor}, product ${product}, serial ${serial}, connector ${connectorName}, index ${monitorIndex}\n\n`); + if (monitorIndex >= 0) { + monitorProperties[monitorIndex] = { + index: monitorIndex, + name: monitorName, + vendor: vendor, + product: product, + serial: serial, + connector: connectorName, + refreshRate: refreshRate + }; + } + } + this._monitorProperties = monitorProperties; + if (this._changeHookFn !== null) { + this._changeHookFn(); + } + }); + } +} \ No newline at end of file diff --git a/gnome/src/schemas/com.xronlinux.BreezyDesktop.gschema.xml b/gnome/src/schemas/com.xronlinux.BreezyDesktop.gschema.xml new file mode 120000 index 0000000..399c9da --- /dev/null +++ b/gnome/src/schemas/com.xronlinux.BreezyDesktop.gschema.xml @@ -0,0 +1 @@ +../../../ui/data/com.xronlinux.BreezyDesktop.gschema.xml \ No newline at end of file diff --git a/gnome/src/shader.js b/gnome/src/shader.js new file mode 100644 index 0000000..b906762 --- /dev/null +++ b/gnome/src/shader.js @@ -0,0 +1,14 @@ +import Gio from 'gi://Gio'; + +export function getShaderSource(path) { + const file = Gio.file_new_for_path(path); + const data = file.load_contents(null); + + // version string helps with linting, but GNOME extension doesn't like it, so remove it if it's there + // + // TODO - Gjs on GNOME 45.5 WARNING: Some code called array.toString() on a Uint8Array instance. Previously this + // would have interpreted the bytes of the array as a string, but that is nonstandard. In the future this + // will return the bytes as comma-separated digits. For the time being, the old behavior has been preserved, + // but please fix your code anyway to use TextDecoder. + return data[1].toString().replace(/^#version .*$/gm, '') + '\n'; +} \ No newline at end of file diff --git a/gnome/src/textures/calibrating.png b/gnome/src/textures/calibrating.png new file mode 120000 index 0000000..9611d2d --- /dev/null +++ b/gnome/src/textures/calibrating.png @@ -0,0 +1 @@ +../../../modules/sombrero/calibrating.png \ No newline at end of file diff --git a/gnome/src/textures/custom_banner.png b/gnome/src/textures/custom_banner.png new file mode 120000 index 0000000..e48019a --- /dev/null +++ b/gnome/src/textures/custom_banner.png @@ -0,0 +1 @@ +../../../vulkan/custom_banner.png \ No newline at end of file diff --git a/gnome/src/time.js b/gnome/src/time.js new file mode 100644 index 0000000..6c3259c --- /dev/null +++ b/gnome/src/time.js @@ -0,0 +1,7 @@ +export function getEpochSec() { + return toSec(Date.now()); +} + +export function toSec(milliseconds) { + return Math.floor(milliseconds / 1000); +} \ No newline at end of file diff --git a/gnome/src/xrEffect.js b/gnome/src/xrEffect.js new file mode 100644 index 0000000..ad3a21f --- /dev/null +++ b/gnome/src/xrEffect.js @@ -0,0 +1,320 @@ +import Clutter from 'gi://Clutter'; +import Cogl from 'gi://Cogl'; +import GdkPixbuf from 'gi://GdkPixbuf'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Shell from 'gi://Shell'; + +import Globals from './globals.js'; + +import { + dataViewEnd, + dataViewUint8, + dataViewBigUint, + dataViewUintArray, + dataViewFloat, + dataViewFloatArray, + BOOL_SIZE, + DATA_VIEW_INFO_COUNT_INDEX, + DATA_VIEW_INFO_OFFSET_INDEX, + FLOAT_SIZE, + UINT_SIZE, + UINT8_SIZE +} from "./ipc.js"; +import { degreeToRadian } from "./math.js"; +import { getShaderSource } from "./shader.js"; +import { toSec } from "./time.js"; + +export const IPC_FILE_PATH = "/dev/shm/breezy_desktop_imu"; + +// the driver should be using the same data layout version +const DATA_LAYOUT_VERSION = 2; + +// DataView info: [offset, size, count] +const VERSION = [0, UINT8_SIZE, 1]; +const ENABLED = [dataViewEnd(VERSION), BOOL_SIZE, 1]; +const LOOK_AHEAD_CFG = [dataViewEnd(ENABLED), FLOAT_SIZE, 4]; +const DISPLAY_RES = [dataViewEnd(LOOK_AHEAD_CFG), UINT_SIZE, 2]; +const DISPLAY_FOV = [dataViewEnd(DISPLAY_RES), FLOAT_SIZE, 1]; +const LENS_DISTANCE_RATIO = [dataViewEnd(DISPLAY_FOV), FLOAT_SIZE, 1]; +const SBS_ENABLED = [dataViewEnd(LENS_DISTANCE_RATIO), BOOL_SIZE, 1]; +const CUSTOM_BANNER_ENABLED = [dataViewEnd(SBS_ENABLED), BOOL_SIZE, 1]; +const EPOCH_MS = [dataViewEnd(CUSTOM_BANNER_ENABLED), UINT_SIZE, 2]; +const IMU_QUAT_DATA = [dataViewEnd(EPOCH_MS), FLOAT_SIZE, 16]; +const DATA_VIEW_LENGTH = dataViewEnd(IMU_QUAT_DATA); + +// cached after first retrieval +const shaderUniformLocations = { + 'enabled': null, + 'show_banner': null, + 'imu_quat_data': null, + 'look_ahead_cfg': null, + 'look_ahead_ms': null, + 'stage_aspect_ratio': null, + 'display_aspect_ratio': null, + 'trim_width_percent': null, + 'trim_height_percent': null, + 'display_zoom': null, + 'display_north_offset': null, + 'lens_distance_ratio': null, + 'sbs_enabled': null, + 'sbs_content': null, + 'custom_banner_enabled': null, + 'half_fov_z_rads': null, + 'half_fov_y_rads': null, + 'screen_distance': null, + 'display_res': null +}; + +function setUniformFloat(effect, locationName, dataViewInfo, value) { + effect.set_uniform_float(shaderUniformLocations[locationName], dataViewInfo[DATA_VIEW_INFO_COUNT_INDEX], value); +} + +function transferUniformFloat(effect, locationName, dataView, dataViewInfo) { + setUniformFloat(effect, locationName, dataViewInfo, dataViewFloatArray(dataView, dataViewInfo)); +} + +function setSingleFloat(effect, locationName, value) { + effect.set_uniform_float(shaderUniformLocations[locationName], 1, [value]); +} + +function setUniformMatrix(effect, locationName, components, dataView, dataViewInfo) { + const numValues = dataViewInfo[DATA_VIEW_INFO_COUNT_INDEX]; + if (numValues / components !== components) { + throw new Error('Invalid matrix size'); + } + + const floatArray = [].fill(0, 0, numValues); + let offset = dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX]; + for (let i = 0; i < numValues; i++) { + // GLSL uses column-major order, so we need to transpose the matrix + const row = i % components; + const column = Math.floor(i / components); + + floatArray[row * components + column] = dataView.getFloat32(offset, true); + offset += FLOAT_SIZE; + } + effect.set_uniform_matrix(shaderUniformLocations[locationName], true, components, floatArray); +} + +function lookAheadMS(dataView) { + const lookAheadCfg = dataViewFloatArray(dataView, LOOK_AHEAD_CFG); + const imuDateMS = dataViewBigUint(dataView, EPOCH_MS); + + // how stale the imu data is + const dataAge = Date.now() - imuDateMS; + + return lookAheadCfg[0] + dataAge; +} + +// most uniforms don't change frequently, this function should be called periodically +function setIntermittentUniformVariables() { + const dataView = this._dataView; + + if (dataView.byteLength === DATA_VIEW_LENGTH) { + const version = dataViewUint8(dataView, VERSION); + const imuDateMS = dataViewBigUint(dataView, EPOCH_MS); + const currentDateMS = Date.now(); + const validKeepalive = Math.abs(toSec(currentDateMS) - toSec(imuDateMS)) < 5; + const imuData = dataViewFloatArray(dataView, IMU_QUAT_DATA); + const imuResetState = validKeepalive && imuData[0] === 0.0 && imuData[1] === 0.0 && imuData[2] === 0.0 && imuData[3] === 1.0; + const enabled = dataViewUint8(dataView, ENABLED) !== 0 && version === DATA_LAYOUT_VERSION && validKeepalive; + + if (enabled) { + const displayRes = dataViewUintArray(dataView, DISPLAY_RES); + const displayFov = dataViewFloat(dataView, DISPLAY_FOV); + const lensDistanceRatio = dataViewFloat(dataView, LENS_DISTANCE_RATIO); + + // compute these values once, they only change when the XR device changes + const displayAspectRatio = displayRes[0] / displayRes[1]; + const stageAspectRatio = this.target_monitor.width / this.target_monitor.height; + const diagToVertRatio = Math.sqrt(Math.pow(stageAspectRatio, 2) + 1); + const halfFovZRads = degreeToRadian(displayFov / diagToVertRatio) / 2; + const halfFovYRads = halfFovZRads * stageAspectRatio; + const screenDistance = 1.0 - lensDistanceRatio; + + // our overlay doesn't quite cover the full screen texture, which allows us to see some of the real desktop + // underneath, so we trim two pixels around the entire edge of the texture + const trimWidthPercent = 2.0 / this.target_monitor.width; + const trimHeightPercent = 2.0 / this.target_monitor.height; + + // all these values are transferred directly, unmodified from the driver + transferUniformFloat(this, 'look_ahead_cfg', dataView, LOOK_AHEAD_CFG); + transferUniformFloat(this, 'lens_distance_ratio', dataView, LENS_DISTANCE_RATIO); + + // computed values with no dataViewInfo, so we set these manually + setSingleFloat(this, 'stage_aspect_ratio', stageAspectRatio); + setSingleFloat(this, 'display_aspect_ratio', displayAspectRatio); + setSingleFloat(this, 'trim_width_percent', trimWidthPercent); + setSingleFloat(this, 'trim_height_percent', trimHeightPercent); + setSingleFloat(this, 'half_fov_z_rads', halfFovZRads); + setSingleFloat(this, 'half_fov_y_rads', halfFovYRads); + setSingleFloat(this, 'screen_distance', screenDistance); + + // TOOD - drive from settings + setSingleFloat(this, 'display_zoom', 1.0); + } + + // these variables are always in play, even if enabled is false + setSingleFloat(this, 'enabled', enabled ? 1.0 : 0.0); + setSingleFloat(this, 'show_banner', imuResetState ? 1.0 : 0.0); + setSingleFloat(this, 'sbs_content', 0.0); // TOOD - drive from settings + setSingleFloat(this, 'sbs_enabled', dataViewUint8(dataView, SBS_ENABLED) !== 0 ? 1.0 : 0.0); + setSingleFloat(this, 'custom_banner_enabled', dataViewUint8(dataView, CUSTOM_BANNER_ENABLED) !== 0 ? 1.0 : 0.0); + + this.set_uniform_float(shaderUniformLocations['display_res'], 2, [this.target_monitor.width, this.target_monitor.height]); + } else if (dataView.byteLength !== 0) { + console.error(`Invalid dataView.byteLength: ${dataView.byteLength} !== ${DATA_VIEW_LENGTH}`) + } +} + +export const XREffect = GObject.registerClass({ + Properties: { + 'target-monitor': GObject.ParamSpec.jsobject( + 'target-monitor', + 'Target Monitor', + 'Geometry of the target monitor for this effect', + GObject.ParamFlags.READWRITE + ), + 'target-framerate': GObject.ParamSpec.uint( + 'target-framerate', + 'Target Framerate', + 'Target framerate for this effect', + GObject.ParamFlags.READWRITE, 30, 240, 60 + ), + 'display-distance': GObject.ParamSpec.double( + 'display-distance', + 'Display Distance', + 'How far away the display appears', + GObject.ParamFlags.READWRITE, + 0.2, + 2.5, + 1.05 + ), + 'toggle-display-distance-start': GObject.ParamSpec.double( + 'toggle-display-distance-start', + 'Display distance start', + 'Start distance when using the "change distance" shortcut.', + GObject.ParamFlags.READWRITE, + 0.2, + 2.5, + 1.05 + ), + 'toggle-display-distance-end': GObject.ParamSpec.double( + 'toggle-display-distance-end', + 'Display distance end', + 'End distance when using the "change distance" shortcut.', + GObject.ParamFlags.READWRITE, + 0.2, + 2.5, + 1.05 + ) + } +}, class XREffect extends Shell.GLSLEffect { + constructor(params = {}) { + super(params); + + this._frametime = Math.floor(1000 / this.target_framerate); + + this._is_display_distance_at_end = false; + this._distance_ease_timeline = null; + + const calibrating = GdkPixbuf.Pixbuf.new_from_file(`${Globals.extension_dir}/textures/calibrating.png`); + this.calibratingImage = new Clutter.Image(); + this.calibratingImage.set_data(calibrating.get_pixels(), Cogl.PixelFormat.RGB_888, + calibrating.width, calibrating.height, calibrating.rowstride); + + const customBanner = GdkPixbuf.Pixbuf.new_from_file(`${Globals.extension_dir}/textures/custom_banner.png`); + this.customBannerImage = new Clutter.Image(); + this.customBannerImage.set_data(customBanner.get_pixels(), Cogl.PixelFormat.RGB_888, + customBanner.width, customBanner.height, customBanner.rowstride); + } + + _change_distance() { + if (this._distance_ease_timeline?.is_playing()) this._distance_ease_timeline.stop(); + + this._distance_ease_start = this.display_distance; + this._distance_ease_timeline = Clutter.Timeline.new_for_actor(this.get_actor(), 250); + + const toggle_display_distance_target = this._is_display_distance_at_end ? + this.toggle_display_distance_start : this.toggle_display_distance_end; + this._distance_ease_timeline.connect('new-frame', () => { + this.display_distance = this._distance_ease_start + + this._distance_ease_timeline.get_progress() * + (toggle_display_distance_target - this._distance_ease_start); + }); + this._is_display_distance_at_end = !this._is_display_distance_at_end; + + this._distance_ease_timeline.start(); + } + + vfunc_build_pipeline() { + const code = getShaderSource(`${Globals.extension_dir}/IMUAdjust.frag`); + const main = 'PS_IMU_Transform(vec4(0, 0, 0, 0), cogl_tex_coord_in[0].xy, cogl_color_out);'; + this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, code, main, false); + } + + vfunc_paint_target(node, paintContext) { + var now = Date.now(); + var lastPaint = this._last_paint || 0; + var frametime = this._frametime; + var calibratingImage = this.calibratingImage; + var customBannerImage = this.customBannerImage; + const data = Globals.ipc_file.load_contents(null); + if (data[0]) { + const buffer = new Uint8Array(data[1]).buffer; + this._dataView = new DataView(buffer); + if (!this._initialized) { + this.set_uniform_float(this.get_uniform_location('uDesktopTexture'), 1, [0]); + + this.get_pipeline().set_layer_texture(1, calibratingImage.get_texture()); + this.get_pipeline().set_layer_texture(2, customBannerImage.get_texture()); + this.get_pipeline().set_uniform_1i(this.get_uniform_location('uCalibratingTexture'), 1); + this.get_pipeline().set_uniform_1i(this.get_uniform_location('uCustomBannerTexture'), 2); + + for (let key in shaderUniformLocations) { + shaderUniformLocations[key] = this.get_uniform_location(key); + } + this.setIntermittentUniformVariables = setIntermittentUniformVariables.bind(this); + this.setIntermittentUniformVariables(); + + this._redraw_timeout_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._frametime, () => { + if ((now - lastPaint) > frametime) global.stage.queue_redraw(); + return GLib.SOURCE_CONTINUE; + }); + + this._uniforms_timeout_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, (() => { + this.setIntermittentUniformVariables(); + return GLib.SOURCE_CONTINUE; + }).bind(this)); + + this._initialized = true; + } + + if (this._dataView.byteLength === DATA_VIEW_LENGTH) { + setSingleFloat(this, 'display_north_offset', this.display_distance); + setSingleFloat(this, 'look_ahead_ms', lookAheadMS(this._dataView)); + setUniformMatrix(this, 'imu_quat_data', 4, this._dataView, IMU_QUAT_DATA); + } else if (this._dataView.byteLength !== 0) { + console.error(`Invalid dataView.byteLength: ${this._dataView.byteLength} !== ${DATA_VIEW_LENGTH}`) + } + + // improves sampling quality for smooth text and edges + this.get_pipeline().set_layer_filters ( + 0, + Cogl.PipelineFilter.LINEAR_MIPMAP_LINEAR, + Cogl.PipelineFilter.LINEAR + ); + + super.vfunc_paint_target(node, paintContext); + } else { + super.vfunc_paint_target(node, paintContext); + } + this._last_paint = now; + } + + vfunc_dispose() { + if (this._redraw_timeout_id) GLib.source_remove(this._redraw_timeout_id); + if (this._uniforms_timeout_id) GLib.source_remove(this._uniforms_timeout_id); + } +}); \ No newline at end of file diff --git a/modules/sombrero b/modules/sombrero new file mode 160000 index 0000000..84c2caa --- /dev/null +++ b/modules/sombrero @@ -0,0 +1 @@ +Subproject commit 84c2caa96271eb37b02f78c3be1fcb5d641dc247 diff --git a/modules/xrealAirLinuxDriver b/modules/xrealAirLinuxDriver index 9132174..f379c97 160000 --- a/modules/xrealAirLinuxDriver +++ b/modules/xrealAirLinuxDriver @@ -1 +1 @@ -Subproject commit 91321744c438914154279abb01265d0541ea0972 +Subproject commit f379c976e003823ed6a61fe2d054c802b3bca80d diff --git a/ui/bin/package b/ui/bin/package new file mode 100755 index 0000000..a3c6764 --- /dev/null +++ b/ui/bin/package @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# exit when any command fails +set -e + +check_command() { + if ! command -v "$1" &>/dev/null; then + echo "Please install \"$1\" and make sure it's available in your \$PATH" + exit 1 + fi +} + +check_command "flatpak" +check_command "flatpak-builder" + +# https://stackoverflow.com/a/246128 +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) + +TMP_DIR=$(mktemp -d -t breezy-ui-flatpak-XXXXXXXXXX) +OUT_DIR=$SCRIPT_DIR/../out +rm -rf $OUT_DIR +mkdir -p $OUT_DIR + +flatpak-builder --force-clean $TMP_DIR/build $SCRIPT_DIR/../com.xronlinux.BreezyDesktop.json +flatpak build-export $TMP_DIR/export $TMP_DIR/build +flatpak build-bundle $TMP_DIR/export $OUT_DIR/com.xronlinux.BreezyDesktop.flatpak com.xronlinux.BreezyDesktop --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo + +rm -rf "$TMP_DIR" \ No newline at end of file diff --git a/ui/build-aux/dconf-override.patch b/ui/build-aux/dconf-override.patch new file mode 100644 index 0000000..2f6e38e --- /dev/null +++ b/ui/build-aux/dconf-override.patch @@ -0,0 +1,23 @@ +diff --git a/engine/dconf-engine-source-user.c b/engine/dconf-engine-source-user.c +index 1657875..e4f8786 100644 +--- a/engine/dconf-engine-source-user.c ++++ b/engine/dconf-engine-source-user.c +@@ -39,11 +39,17 @@ dconf_engine_source_user_open_gvdb (const gchar *name) + { + GvdbTable *table; + gchar *filename; ++ const gchar *override; ++ ++ override = g_getenv ("DCONF_USER_CONFIG_DIR"); ++ if (override == NULL) ++ filename = g_build_filename (g_get_user_config_dir (), "dconf", name, NULL); ++ else ++ filename = g_build_filename (g_get_home_dir (), override, name, NULL); + + /* This can fail in the normal case of the user not having any + * settings. That's OK and it shouldn't be considered as an error. + */ +- filename = g_build_filename (g_get_user_config_dir (), "dconf", name, NULL); + table = gvdb_table_new (filename, FALSE, NULL); + g_free (filename); + \ No newline at end of file diff --git a/ui/build-aux/start-breezy-desktop.sh b/ui/build-aux/start-breezy-desktop.sh new file mode 100644 index 0000000..988ec3f --- /dev/null +++ b/ui/build-aux/start-breezy-desktop.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +# stolen from dconf-editor: +# https://gitlab.gnome.org/GNOME/dconf-editor/-/blob/master/build-aux/start-dconf-editor.sh + +IFS=: read -ra host_data_dirs < <(flatpak-spawn --host sh -c 'echo "$XDG_DATA_DIRS"') + +# To avoid potentially muddying up $XDG_DATA_DIRS too much, we link the schema paths +# into a temporary directory. +bridge_dir=$XDG_RUNTIME_DIR/dconf-bridge +mkdir -p "$bridge_dir" + +HOST_XDG_DATA_DIRS="" + +for dir in "${host_data_dirs[@]}"; do + if [[ "$dir" == /usr/* ]]; then + dir=/run/host/"$dir" + fi + + schemas="$dir/glib-2.0/schemas" + if [[ -d "$schemas" ]]; then + bridged=$(mktemp -d XXXXXXXXXX -p "$bridge_dir") + mkdir -p "$bridged"/glib-2.0 + ln -s "$schemas" "$bridged"/glib-2.0 + HOST_XDG_DATA_DIRS="${HOST_XDG_DATA_DIRS}:${bridged}" + fi +done + +# We MUST prepend the host's data dirs BEFORE the Flatpak environment's own dirs, +# otherwise data (such as default values) load in the wrong order and would then +# incorrectly prefer the Flatpak's internal defaults instead of the host's defaults! +if [[ ! -z "${HOST_XDG_DATA_DIRS}" ]]; then + XDG_DATA_DIRS="${HOST_XDG_DATA_DIRS:1}:${XDG_DATA_DIRS}" +fi + +export XDG_DATA_DIRS +exec breezydesktop "$@" \ No newline at end of file diff --git a/ui/com.xronlinux.BreezyDesktop.json b/ui/com.xronlinux.BreezyDesktop.json new file mode 100644 index 0000000..5c87a57 --- /dev/null +++ b/ui/com.xronlinux.BreezyDesktop.json @@ -0,0 +1,86 @@ +{ + "id" : "com.xronlinux.BreezyDesktop", + "runtime" : "org.gnome.Platform", + "runtime-version" : "46", + "sdk" : "org.gnome.Sdk", + "command" : "breezydesktop", + "finish-args" : [ + "--share=network", + "--share=ipc", + "--socket=fallback-x11", + "--device=dri", + "--socket=wayland", + "--device=shm", + "--talk-name=ca.desrt.dconf", + "--filesystem=xdg-run/dconf", + "--filesystem=host", + "--env=DCONF_USER_CONFIG_DIR=.config/dconf", + "--env=GIO_EXTRA_MODULES=/app/lib/gio/modules/", + "--talk-name=org.freedesktop.Flatpak", + "--talk-name=org.gnome.Shell.Extensions" + ], + "cleanup" : [ + "/include", + "/lib/pkgconfig", + "/man", + "/share/doc", + "/share/gtk-doc", + "/share/man", + "/share/pkgconfig", + "*.la", + "*.a" + ], + "modules" : [ + "python3-pydbus.json", + { + "name": "dconf", + "buildsystem": "meson", + "config-opts": [ + "-Dbash_completion=false", + "-Dman=false", + "-Dsystemduserunitdir=' '" + ], + "cleanup": [ + "/include", + "/lib/pkgconfig", + "/libexec", + "/share/dbus-1" + ], + "sources": [ + { + "type": "archive", + "url": "https://download.gnome.org/sources/dconf/0.40/dconf-0.40.0.tar.xz", + "sha256": "cf7f22a4c9200421d8d3325c5c1b8b93a36843650c9f95d6451e20f0bcb24533" + }, + { + "type": "patch", + "path": "build-aux/dconf-override.patch" + } + ] + }, + { + "name": "scripts", + "buildsystem": "simple", + "build-commands": [ + "install -Dm 755 start-breezy-desktop.sh /app/bin/start-breezy-desktop" + ], + "sources": [ + { + "type": "file", + "path": "build-aux/start-breezy-desktop.sh" + } + ] + }, + { + "name" : "breezydesktop", + "builddir" : true, + "buildsystem" : "meson", + "sources" : [ + { + "type" : "dir", + "path" : "." + } + ] + } + ] +} diff --git a/ui/data/com.xronlinux.BreezyDesktop.desktop.in b/ui/data/com.xronlinux.BreezyDesktop.desktop.in new file mode 100644 index 0000000..3955dac --- /dev/null +++ b/ui/data/com.xronlinux.BreezyDesktop.desktop.in @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Breezy Desktop +Exec=start-breezy-desktop +Icon=com.xronlinux.BreezyDesktop +Terminal=false +Type=Application +Categories=GTK; +StartupNotify=true diff --git a/ui/data/com.xronlinux.BreezyDesktop.gschema.xml b/ui/data/com.xronlinux.BreezyDesktop.gschema.xml new file mode 100644 index 0000000..0dc1db1 --- /dev/null +++ b/ui/data/com.xronlinux.BreezyDesktop.gschema.xml @@ -0,0 +1,68 @@ + + + + + + space']]]> + + Re-center display + + Shortcut to re-center the virtual display. + + + + + Return']]]> + + Trigger change to display distance + + Shortcut to change the display distance. + + + + + 0']]]> + + Toggle follow mode + + Shortcut to toggle follow mode. + + + + + 1.05 + + Display distance + + How far away the display appears. Farther will look smaller, closer will look larger. + + + + + 0.85 + + Display distance start + + Start distance when using the "change distance" shortcut. + + + + + 1.05 + + Display distance end + + End distance when using the "toggle display distance" shortcut. + + + + + false + + Developer mode + + Enable developer mode + + + + diff --git a/ui/data/com.xronlinux.BreezyDesktop.metainfo.xml.in b/ui/data/com.xronlinux.BreezyDesktop.metainfo.xml.in new file mode 100644 index 0000000..eab0852 --- /dev/null +++ b/ui/data/com.xronlinux.BreezyDesktop.metainfo.xml.in @@ -0,0 +1,9 @@ + + + com.xronlinux.BreezyDesktop.desktop + CC0-1.0 + GPL-3.0-or-later + +

No description

+
+
diff --git a/ui/data/icons/hicolor/scalable/apps/com.xronlinux.BreezyDesktop.svg b/ui/data/icons/hicolor/scalable/apps/com.xronlinux.BreezyDesktop.svg new file mode 100644 index 0000000..45aed09 --- /dev/null +++ b/ui/data/icons/hicolor/scalable/apps/com.xronlinux.BreezyDesktop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/data/icons/meson.build b/ui/data/icons/meson.build new file mode 100644 index 0000000..919bfad --- /dev/null +++ b/ui/data/icons/meson.build @@ -0,0 +1,7 @@ +application_id = 'com.xronlinux.BreezyDesktop' + +scalable_dir = 'hicolor' / 'scalable' / 'apps' +install_data( + scalable_dir / ('@0@.svg').format(application_id), + install_dir: get_option('datadir') / 'icons' / scalable_dir +) diff --git a/ui/data/meson.build b/ui/data/meson.build new file mode 100644 index 0000000..71c25bd --- /dev/null +++ b/ui/data/meson.build @@ -0,0 +1,36 @@ +desktop_file = i18n.merge_file( + input: 'com.xronlinux.BreezyDesktop.desktop.in', + output: 'com.xronlinux.BreezyDesktop.desktop', + type: 'desktop', + po_dir: '../po', + install: true, + install_dir: get_option('datadir') / 'applications' +) + +desktop_utils = find_program('desktop-file-validate', required: false) +if desktop_utils.found() + test('Validate desktop file', desktop_utils, args: [desktop_file]) +endif + +appstream_file = i18n.merge_file( + input: 'com.xronlinux.BreezyDesktop.metainfo.xml.in', + output: 'com.xronlinux.BreezyDesktop.metainfo.xml', + po_dir: '../po', + install: true, + install_dir: get_option('datadir') / 'metainfo' +) + +appstreamcli = find_program('appstreamcli', required: false, disabler: true) +test('Validate appstream file', appstreamcli, + args: ['validate', '--no-net', '--explain', appstream_file]) + +install_data('com.xronlinux.BreezyDesktop.gschema.xml', + install_dir: get_option('datadir') / 'glib-2.0' / 'schemas' +) + +compile_schemas = find_program('glib-compile-schemas', required: false, disabler: true) +test('Validate schema file', + compile_schemas, + args: ['--strict', '--dry-run', meson.current_source_dir()]) + +subdir('icons') diff --git a/ui/meson.build b/ui/meson.build new file mode 100644 index 0000000..9dffea0 --- /dev/null +++ b/ui/meson.build @@ -0,0 +1,20 @@ +project('breezydesktop', + version: '0.1.0', + meson_version: '>= 0.62.0', + default_options: [ 'warning_level=2', 'werror=false', ], +) + +i18n = import('i18n') +gnome = import('gnome') + + + +subdir('data') +subdir('src') +subdir('po') + +gnome.post_install( + glib_compile_schemas: true, + gtk_update_icon_cache: true, + update_desktop_database: true, +) diff --git a/ui/modules/PyXRLinuxDriverIPC b/ui/modules/PyXRLinuxDriverIPC new file mode 160000 index 0000000..b2cdc8c --- /dev/null +++ b/ui/modules/PyXRLinuxDriverIPC @@ -0,0 +1 @@ +Subproject commit b2cdc8cc5fc593ab04015cec08a8e97b103ffa63 diff --git a/ui/po/LINGUAS b/ui/po/LINGUAS new file mode 100644 index 0000000..e69de29 diff --git a/ui/po/POTFILES b/ui/po/POTFILES new file mode 100644 index 0000000..2fbee68 --- /dev/null +++ b/ui/po/POTFILES @@ -0,0 +1,6 @@ +data/com.xronlinux.BreezyDesktop.desktop.in +data/com.xronlinux.BreezyDesktop.metainfo.xml.in +data/com.xronlinux.BreezyDesktop.gschema.xml +src/main.py +src/window.py +src/window.ui diff --git a/ui/po/meson.build b/ui/po/meson.build new file mode 100644 index 0000000..25704f5 --- /dev/null +++ b/ui/po/meson.build @@ -0,0 +1 @@ +i18n.gettext('breezydesktop', preset: 'glib') diff --git a/ui/python3-pydbus.json b/ui/python3-pydbus.json new file mode 100644 index 0000000..9fd19cd --- /dev/null +++ b/ui/python3-pydbus.json @@ -0,0 +1,14 @@ +{ + "name": "python3-pydbus", + "buildsystem": "simple", + "build-commands": [ + "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pydbus\" --no-build-isolation" + ], + "sources": [ + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/92/56/27148014c2f85ce70332f18612f921f682395c7d4e91ec103783be4fce00/pydbus-0.6.0-py2.py3-none-any.whl", + "sha256": "66b80106352a718d80d6c681dc2a82588048e30b75aab933e4020eb0660bf85e" + } + ] +} \ No newline at end of file diff --git a/ui/src/__init__.py b/ui/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/breezydesktop.gresource.xml b/ui/src/breezydesktop.gresource.xml new file mode 100644 index 0000000..4df66f8 --- /dev/null +++ b/ui/src/breezydesktop.gresource.xml @@ -0,0 +1,10 @@ + + + + gtk/connected-device.ui + gtk/no-device.ui + gtk/no-extension.ui + gtk/shortcut-dialog.ui + gtk/window.ui + + diff --git a/ui/src/breezydesktop.in b/ui/src/breezydesktop.in new file mode 100755 index 0000000..f25bbe3 --- /dev/null +++ b/ui/src/breezydesktop.in @@ -0,0 +1,46 @@ +#!@PYTHON@ + +# breezydesktop.in +# +# Copyright 2024 Unknown +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +import sys +import signal +import locale +import gettext + +VERSION = '@VERSION@' +pkgdatadir = '@pkgdatadir@' +localedir = '@localedir@' + +sys.path.insert(1, pkgdatadir) +signal.signal(signal.SIGINT, signal.SIG_DFL) +locale.bindtextdomain('breezydesktop', localedir) +locale.textdomain('breezydesktop') +gettext.install('breezydesktop', localedir) + +if __name__ == '__main__': + import gi + + from gi.repository import Gio + resource = Gio.Resource.load(os.path.join(pkgdatadir, 'breezydesktop.gresource')) + resource._register() + + from breezydesktop import main + sys.exit(main.main(VERSION)) diff --git a/ui/src/connecteddevice.py b/ui/src/connecteddevice.py new file mode 100644 index 0000000..175354c --- /dev/null +++ b/ui/src/connecteddevice.py @@ -0,0 +1,116 @@ +from gi.repository import Gio, Gtk, GObject +from .extensionsmanager import ExtensionsManager +from .settingsmanager import SettingsManager +from .shortcutdialog import bind_shortcut_settings +from .statemanager import StateManager +from .xrdriveripc import XRDriverIPC + +@Gtk.Template(resource_path='/com/xronlinux/BreezyDesktop/gtk/connected-device.ui') +class ConnectedDevice(Gtk.Box): + __gtype_name__ = "ConnectedDevice" + + effect_enable_switch = Gtk.Template.Child() + display_distance_scale = Gtk.Template.Child() + display_distance_adjustment = Gtk.Template.Child() + follow_mode_switch = Gtk.Template.Child() + device_label = Gtk.Template.Child() + set_toggle_display_distance_start_button = Gtk.Template.Child() + set_toggle_display_distance_end_button = Gtk.Template.Child() + reassign_recenter_display_shortcut_button = Gtk.Template.Child() + recenter_display_shortcut_label = Gtk.Template.Child() + reassign_toggle_display_distance_shortcut_button = Gtk.Template.Child() + toggle_display_distance_shortcut_label = Gtk.Template.Child() + reassign_toggle_follow_shortcut_button = Gtk.Template.Child() + toggle_follow_shortcut_label = Gtk.Template.Child() + + def __init__(self): + super(Gtk.Box, self).__init__() + self.init_template() + self.all_enabled_state_inputs = [ + self.display_distance_scale, + self.follow_mode_switch, + self.set_toggle_display_distance_start_button, + self.set_toggle_display_distance_end_button, + self.reassign_recenter_display_shortcut_button, + self.reassign_toggle_display_distance_shortcut_button, + self.reassign_toggle_follow_shortcut_button + ] + + self.settings = SettingsManager.get_instance().settings + self.ipc = XRDriverIPC.get_instance() + self.extensions_manager = ExtensionsManager.get_instance() + + self.settings.bind('display-distance', self.display_distance_adjustment, 'value', Gio.SettingsBindFlags.DEFAULT) + + bind_shortcut_settings(self.get_parent(), [ + [self.reassign_recenter_display_shortcut_button, self.recenter_display_shortcut_label], + [self.reassign_toggle_display_distance_shortcut_button, self.toggle_display_distance_shortcut_label], + [self.reassign_toggle_follow_shortcut_button, self.toggle_follow_shortcut_label] + ]) + + self.bind_set_distance_toggle([ + self.set_toggle_display_distance_start_button, + self.set_toggle_display_distance_end_button + ]) + + self.state_manager = StateManager.get_instance() + self.state_manager.bind_property('follow-mode', self.follow_mode_switch, 'active', GObject.BindingFlags.DEFAULT) + + self.follow_mode_switch.set_active(self.state_manager.follow_mode) + self.follow_mode_switch.connect('notify::active', self._request_follow_mode) + + self.effect_enable_switch.set_active(self._is_config_enabled(self.ipc.retrieve_config()) and self.extensions_manager.is_enabled()) + self.effect_enable_switch.connect('notify::active', self._refresh_inputs_for_enabled_state) + + self._refresh_inputs_for_enabled_state(self.effect_enable_switch, None) + self.extensions_manager.bind_property('breezy-enabled', self.effect_enable_switch, 'active', GObject.BindingFlags.BIDIRECTIONAL) + + self.connect("destroy", self._on_widget_destroy) + + def _is_config_enabled(self, config): + return config.get('disabled') == False and 'breezy_desktop' in config.get('external_mode', []) + + def _refresh_inputs_for_enabled_state(self, switch, param): + requesting_enabled = switch.get_active() + self.extensions_manager.set_property('breezy-enabled', requesting_enabled) + if requesting_enabled: + config = self.ipc.retrieve_config(False) + if not self._is_config_enabled(config): + config['disabled'] = False + config['output_mode'] = 'external_only' + config['external_mode'] = ['breezy_desktop'] + self.ipc.write_config(config) + + for widget in self.all_enabled_state_inputs: + widget.set_sensitive(requesting_enabled) + + def _request_follow_mode(self, switch, param): + if (self.state_manager.follow_mode == switch.get_active()): + return + + self.ipc.write_control_flags({ + 'enable_breezy_desktop_smooth_follow': switch.get_active() + }) + + def set_device_name(self, name): + self.device_label.set_markup(f"{name}") + + def bind_set_distance_toggle(self, widgets): + for widget in widgets: + widget.connect('clicked', lambda *args, widget=widget: on_set_display_distance_toggle(widget)) + reload_display_distance_toggle_button(widget) + + def _on_widget_destroy(self, widget): + self.state_manager.unbind_property('follow-mode', self.follow_mode_switch, 'active') + self.settings.unbind('display-distance', self.display_distance_adjustment, 'value') + self.extensions_manager.unbind_property('breezy-enabled', self.effect_enable_switch, 'active') + +def reload_display_distance_toggle_button(widget): + distance = SettingsManager.get_instance().settings.get_double(widget.get_name()) + if distance: widget.set_label(str(distance)) + +def on_set_display_distance_toggle(widget): + settings = SettingsManager.get_instance().settings + distance = settings.get_double('display-distance') + settings.set_double(widget.get_name(), distance) + reload_display_distance_toggle_button(widget) \ No newline at end of file diff --git a/ui/src/extensionsmanager.py b/ui/src/extensionsmanager.py new file mode 100644 index 0000000..5a3e521 --- /dev/null +++ b/ui/src/extensionsmanager.py @@ -0,0 +1,70 @@ +import pydbus +from gi.repository import GObject + +BREEZY_DESKTOP_UUID = "breezydesktop@xronlinux.com" +EXTENSION_STATE_ENABLED = 1 + +class ExtensionsManager(GObject.GObject): + __gproperties__ = { + 'breezy-enabled': (bool, 'Breezy Enabled', 'Whether the Breezy Desktop GNOME extension is enabled', False, GObject.ParamFlags.READWRITE) + } + + _instance = None + + @staticmethod + def get_instance(): + if ExtensionsManager._instance is None: + ExtensionsManager._instance = ExtensionsManager() + return ExtensionsManager._instance + + def __init__(self): + GObject.GObject.__init__(self) + + self.bus = pydbus.SessionBus() + self.gnome_shell_extensions = self.bus.get("org.gnome.Shell.Extensions") + self.gnome_shell_extensions.ExtensionStateChanged.connect(self._handle_extension_state_change) + + self.remote_extension_state = None + + def _handle_extension_state_change(self, extension_uuid, state): + if extension_uuid == BREEZY_DESKTOP_UUID: + self.remote_extension_state = state.get('state') == EXTENSION_STATE_ENABLED + self.set_property('breezy-enabled', self.remote_extension_state) + + def is_installed(self): + # TODO check specific version + return self._is_installed(BREEZY_DESKTOP_UUID) + + def enable(self): + self._enable_extension(BREEZY_DESKTOP_UUID) + + def disable(self): + self._disable_extension(BREEZY_DESKTOP_UUID) + + def is_enabled(self): + return self._is_enabled(BREEZY_DESKTOP_UUID) + + def _is_installed(self, extension_uuid): + extensions_result = self.gnome_shell_extensions.ListExtensions() + for extension in extensions_result: + if extension == extension_uuid: + return True + + return False + + def _enable_extension(self, extension_uuid): + self.gnome_shell_extensions.EnableExtension(extension_uuid) + + def _disable_extension(self, extension_uuid): + self.gnome_shell_extensions.DisableExtension(extension_uuid) + + def _is_enabled(self, extension_uuid): + return self.gnome_shell_extensions.GetExtensionInfo(extension_uuid).get('state') == EXTENSION_STATE_ENABLED + + def do_set_property(self, prop, value): + if prop.name == 'breezy-enabled' and value != self.remote_extension_state: + self.enable() if value == True else self.disable() + + def do_get_property(self, prop): + if prop.name == 'breezy-enabled': + return self.remote_extension_state \ No newline at end of file diff --git a/ui/src/gtk/connected-device.ui b/ui/src/gtk/connected-device.ui new file mode 100644 index 0000000..4314de3 --- /dev/null +++ b/ui/src/gtk/connected-device.ui @@ -0,0 +1,201 @@ + + + + + diff --git a/ui/src/gtk/no-device.ui b/ui/src/gtk/no-device.ui new file mode 100644 index 0000000..54300e4 --- /dev/null +++ b/ui/src/gtk/no-device.ui @@ -0,0 +1,19 @@ + + + + + diff --git a/ui/src/gtk/no-extension.ui b/ui/src/gtk/no-extension.ui new file mode 100644 index 0000000..e56a9dc --- /dev/null +++ b/ui/src/gtk/no-extension.ui @@ -0,0 +1,19 @@ + + + + + diff --git a/ui/src/gtk/shortcut-dialog.ui b/ui/src/gtk/shortcut-dialog.ui new file mode 100644 index 0000000..8435a9b --- /dev/null +++ b/ui/src/gtk/shortcut-dialog.ui @@ -0,0 +1,43 @@ + + + + + \ No newline at end of file diff --git a/ui/src/gtk/window.ui b/ui/src/gtk/window.ui new file mode 100644 index 0000000..59c560f --- /dev/null +++ b/ui/src/gtk/window.ui @@ -0,0 +1,38 @@ + + + + + +
+ + _About BreezyDesktop + app.about + +
+
+
diff --git a/ui/src/main.py b/ui/src/main.py new file mode 100644 index 0000000..78789b4 --- /dev/null +++ b/ui/src/main.py @@ -0,0 +1,91 @@ +# main.py +# +# Copyright 2024 Unknown +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import sys +import gi + +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +gi.require_version('Gio', '2.0') + +from gi.repository import Adw, Gtk, Gio +from .statemanager import StateManager +from .window import BreezydesktopWindow + +class BreezydesktopApplication(Adw.Application): + """The main application singleton class.""" + + def __init__(self): + super().__init__(application_id='com.xronlinux.BreezyDesktop', + flags=Gio.ApplicationFlags.DEFAULT_FLAGS) + self.create_action('quit', self.on_quit_action, ['q']) + self.create_action('about', self.on_about_action) + + def do_activate(self): + """Called when the application is activated. + + We raise the application's main window, creating it if + necessary. + """ + win = self.props.active_window + if not win: + win = BreezydesktopWindow(application=self) + win.connect('close-request', lambda *_: self.on_quit_action()) + win.connect('destroy', lambda *_: self.on_quit_action()) + win.present() + + def on_about_action(self, widget, _): + """Callback for the app.about action.""" + about = Gtk.AboutDialog(transient_for=self.props.active_window, + modal=True, + program_name='Breezy Desktop', + logo_icon_name='com.xronlinux.BreezyDesktop', + version='0.1.0', + authors=['Wayne Heaney'], + copyright='© 2024 Wayne Heaney') + about.present() + + def create_action(self, name, callback, shortcuts=None): + """Add an application action. + + Args: + name: the name of the action + callback: the function to be called when the action is + activated + shortcuts: an optional list of accelerators + """ + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + if shortcuts: + self.set_accels_for_action(f"app.{name}", shortcuts) + + def on_quit_action(self, _action = None, _pspec = None): + win = self.props.active_window + if win: + win.close() + + StateManager.destroy_instance() + self.quit() + + +def main(version): + """The application's entry point.""" + app = BreezydesktopApplication() + return app.run(sys.argv) diff --git a/ui/src/meson.build b/ui/src/meson.build new file mode 100644 index 0000000..674d1f1 --- /dev/null +++ b/ui/src/meson.build @@ -0,0 +1,43 @@ +pkgdatadir = get_option('prefix') / get_option('datadir') / meson.project_name() +moduledir = pkgdatadir / 'breezydesktop' +gnome = import('gnome') + +gnome.compile_resources('breezydesktop', + 'breezydesktop.gresource.xml', + gresource_bundle: true, + install: true, + install_dir: pkgdatadir, +) + +python = import('python') + +conf = configuration_data() +conf.set('PYTHON', python.find_installation('python3').full_path()) +conf.set('VERSION', meson.project_version()) +conf.set('localedir', get_option('prefix') / get_option('localedir')) +conf.set('pkgdatadir', pkgdatadir) + +configure_file( + input: 'breezydesktop.in', + output: 'breezydesktop', + configuration: conf, + install: true, + install_dir: get_option('bindir'), + install_mode: 'r-xr-xr-x' +) + +breezydesktop_sources = [ + '../modules/PyXRLinuxDriverIPC/xrdriveripc.py', + '__init__.py', + 'connecteddevice.py', + 'extensionsmanager.py', + 'main.py', + 'nodevice.py', + 'noextension.py', + 'settingsmanager.py', + 'shortcutdialog.py', + 'statemanager.py', + 'window.py' +] + +install_data(breezydesktop_sources, install_dir: moduledir) diff --git a/ui/src/nodevice.py b/ui/src/nodevice.py new file mode 100644 index 0000000..7006584 --- /dev/null +++ b/ui/src/nodevice.py @@ -0,0 +1,5 @@ +from gi.repository import Adw, Gtk + +@Gtk.Template(resource_path='/com/xronlinux/BreezyDesktop/gtk/no-device.ui') +class NoDevice(Gtk.Box): + __gtype_name__ = "NoDevice" diff --git a/ui/src/noextension.py b/ui/src/noextension.py new file mode 100644 index 0000000..2c8e444 --- /dev/null +++ b/ui/src/noextension.py @@ -0,0 +1,5 @@ +from gi.repository import Adw, Gtk + +@Gtk.Template(resource_path='/com/xronlinux/BreezyDesktop/gtk/no-extension.ui') +class NoExtension(Gtk.Box): + __gtype_name__ = "NoExtension" diff --git a/ui/src/settingsmanager.py b/ui/src/settingsmanager.py new file mode 100644 index 0000000..c88ce68 --- /dev/null +++ b/ui/src/settingsmanager.py @@ -0,0 +1,15 @@ + +from gi.repository import Gio + +class SettingsManager: + _instance = None + + @staticmethod + def get_instance(): + if not SettingsManager._instance: + SettingsManager._instance = SettingsManager() + + return SettingsManager._instance + + def __init__(self): + self.settings = Gio.Settings.new_with_path("com.xronlinux.BreezyDesktop", "/com/xronlinux/BreezyDesktop/") diff --git a/ui/src/shortcutdialog.py b/ui/src/shortcutdialog.py new file mode 100644 index 0000000..66f1140 --- /dev/null +++ b/ui/src/shortcutdialog.py @@ -0,0 +1,109 @@ +from gi.repository import Gtk, Gdk + +from .settingsmanager import SettingsManager + +# ported from https://github.com/velitasali/gnome-shell-extension-awesome-tiles +@Gtk.Template(resource_path='/com/xronlinux/BreezyDesktop/gtk/shortcut-dialog.ui') +class ShortcutDialog(Gtk.Dialog): + __gtype_name__ = 'ShortcutDialog' + + event_controller = Gtk.Template.Child() + + def __init__(self, settings_key): + super(Gtk.Dialog, self).__init__() + self.init_template() + + self.settings_key = settings_key + self.key_pressed_connect_id = self.event_controller.connect('key-pressed', self._on_key_pressed) + + def _on_key_pressed(self, widget, keyval, keycode, state): + mask = state & Gtk.accelerator_get_default_mod_mask() + mask &= ~Gdk.ModifierType.LOCK_MASK + + done = True + if mask == 0 and keyval == Gdk.KEY_Escape: + self.visible = False + elif keyval == Gdk.KEY_BackSpace: + SettingsManager.get_instance().settings.set_strv(self.settings_key, []) + self.close() + elif is_binding_valid(mask, keycode, keyval) and is_accel_valid(state, keyval): + binding = Gtk.accelerator_name_with_keycode( + None, + keyval, + keycode, + state + ) + label = Gtk.accelerator_get_label(keyval, state) + + # hacky way to store the label, causes warnings from the WM + SettingsManager.get_instance().settings.set_strv(self.settings_key, [binding]) + + self.close() + else: + done = False + + if done and self.key_pressed_connect_id: + self.event_controller.disconnect(self.key_pressed_connect_id) + self.key_pressed_connect_id = None + + return Gdk.EVENT_STOP + +def is_binding_valid(mask, keycode, keyval): + if mask == 0 or mask == Gdk.ModifierType.SHIFT_MASK and keycode != 0: + if keyval >= Gdk.KEY_a and keyval <= Gdk.KEY_z or \ + keyval >= Gdk.KEY_A and keyval <= Gdk.KEY_Z or \ + keyval >= Gdk.KEY_0 and keyval <= Gdk.KEY_9 or \ + keyval >= Gdk.KEY_kana_fullstop and keyval <= Gdk.KEY_semivoicedsound or \ + keyval >= Gdk.KEY_Arabic_comma and keyval <= Gdk.KEY_Arabic_sukun or \ + keyval >= Gdk.KEY_Serbian_dje and keyval <= Gdk.KEY_Cyrillic_HARDSIGN or \ + keyval >= Gdk.KEY_Greek_ALPHAaccent and keyval <= Gdk.KEY_Greek_omega or \ + keyval >= Gdk.KEY_hebrew_doublelowline and keyval <= Gdk.KEY_hebrew_taf or \ + keyval >= Gdk.KEY_Thai_kokai and keyval <= Gdk.KEY_Thai_lekkao or \ + keyval >= Gdk.KEY_Hangul_Kiyeog and keyval <= Gdk.KEY_Hangul_J_YeorinHieuh or \ + keyval == Gdk.KEY_space and mask == 0 or \ + is_keyval_forbidden(keyval): + return False + return True + +def is_keyval_forbidden(keyval): + forbidden_keyvals = [ + Gdk.KEY_Home, + Gdk.KEY_Left, + Gdk.KEY_Up, + Gdk.KEY_Right, + Gdk.KEY_Down, + Gdk.KEY_Page_Up, + Gdk.KEY_Page_Down, + Gdk.KEY_End, + Gdk.KEY_Tab, + Gdk.KEY_KP_Enter, + Gdk.KEY_Return, + Gdk.KEY_Mode_switch + ] + return keyval in forbidden_keyvals + +def is_accel_valid(mask, keyval): + return Gtk.accelerator_valid(keyval, mask) or (keyval == Gdk.KEY_Tab and mask != 0) + +def bind_shortcut_settings(window, widget_tuples): + for widget_tuple in widget_tuples: + widget, label = widget_tuple + SettingsManager.get_instance().settings.connect('changed::' + widget.get_name(), + lambda *args, widget=widget, label=label: reload_shortcut_widget(widget, label)) + widget.connect('clicked', lambda *args, widget=widget: on_assign_shortcut(window, widget)) + + reload_shortcut_widgets(widget_tuples) + +def on_assign_shortcut(window, widget): + dialog = ShortcutDialog(widget.get_name()) + dialog.set_transient_for(widget.get_ancestor(Gtk.Window)) + dialog.present() + +def reload_shortcut_widget(widget, label): + shortcut = SettingsManager.get_instance().settings.get_strv(widget.get_name()) + label.set_accelerator(shortcut[0] if len(shortcut) > 0 else 'Disabled') + +def reload_shortcut_widgets(widget_tuples): + for widget_tuple in widget_tuples: + widget, label = widget_tuple + reload_shortcut_widget(widget, label) \ No newline at end of file diff --git a/ui/src/statemanager.py b/ui/src/statemanager.py new file mode 100644 index 0000000..78c54e1 --- /dev/null +++ b/ui/src/statemanager.py @@ -0,0 +1,74 @@ +import threading +from gi.repository import GObject +from .xrdriveripc import XRDriverIPC + +class Logger: + def info(self, message): + print(message) + + def error(self, message): + print(message) + +class StateManager(GObject.GObject): + __gsignals__ = { + 'device-update': (GObject.SIGNAL_RUN_FIRST, None, (str,)) + } + + __gproperties__ = { + 'follow-mode': (bool, 'Follow Mode', 'Whether the follow mode is enabled', False, GObject.ParamFlags.READWRITE) + } + + _instance = None + + @staticmethod + def get_instance(): + if not StateManager._instance: + StateManager._instance = StateManager() + + return StateManager._instance + + @staticmethod + def destroy_instance(): + if StateManager._instance: + StateManager._instance.stop() + StateManager._instance = None + + @staticmethod + def device_name(state): + if state.get('connected_device_brand') and state.get('connected_device_model'): + return f"{state['connected_device_brand']} {state['connected_device_model']}" + + return None + + def __init__(self): + GObject.GObject.__init__(self) + self.ipc = XRDriverIPC.get_instance() + self.connected_device_name = None + + self.start() + + def start(self): + self.running = True + self._refresh_state() + + def stop(self): + self.running = False + + def _refresh_state(self): + self.state = self.ipc.retrieve_driver_state() + new_device_name = StateManager.device_name(self.state) + if self.connected_device_name != new_device_name: + self.connected_device_name = new_device_name + self.emit('device-update', self.connected_device_name) + + self.set_property('follow-mode', self.state.get('breezy_desktop_smooth_follow_enabled')) + + if self.running: threading.Timer(1.0, self._refresh_state).start() + + def do_set_property(self, prop, value): + if prop.name == 'follow-mode': + self.follow_mode = value + + def do_get_property(self, prop): + if prop.name == 'follow-mode': + return self.follow_mode \ No newline at end of file diff --git a/ui/src/window.py b/ui/src/window.py new file mode 100644 index 0000000..3f719d9 --- /dev/null +++ b/ui/src/window.py @@ -0,0 +1,55 @@ +# window.py +# +# Copyright 2024 Unknown +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from gi.repository import Gtk +from .extensionsmanager import ExtensionsManager +from .statemanager import StateManager +from .connecteddevice import ConnectedDevice +from .nodevice import NoDevice +from .noextension import NoExtension + +@Gtk.Template(resource_path='/com/xronlinux/BreezyDesktop/gtk/window.ui') +class BreezydesktopWindow(Gtk.ApplicationWindow): + __gtype_name__ = 'BreezydesktopWindow' + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.state_manager = StateManager.get_instance() + self.state_manager.connect('device-update', self._handle_device_update) + + self.connected_device = ConnectedDevice() + self.no_device = NoDevice() + self.no_extension = NoExtension() + + self._handle_device_update(self.state_manager, StateManager.device_name(self.state_manager.state)) + + self.connect("destroy", self._on_window_destroy) + + def _handle_device_update(self, state_manager, connected_device_name): + if not ExtensionsManager.get_instance().is_installed(): + self.set_child(self.no_extension) + elif connected_device_name: + self.set_child(self.connected_device) + self.connected_device.set_device_name(connected_device_name) + else: + self.set_child(self.no_device) + + def _on_window_destroy(self, widget): + self.state_manager.disconnect_by_func(self._handle_device_update) \ No newline at end of file diff --git a/vulkan/custom_banner.png b/vulkan/custom_banner.png index d6c916525301f72bcd5ba2bf2a1783aab8076ee1..c31648b514fb89fcd6cb9437c75d5a5cb7ab7ef2 100644 GIT binary patch delta 765 zcmVUuPl{%dpYS+r@Gr9Mf7yG@v z*gJ%HP$n=YVsC)@;zZ4YVw|a1p%o2>%Qhp?0#1ia6FP6rn<>6nNgNw7S4z7YA_yOYV;H2mx zCH^ldw21NGxF7HCJ?`ECLaWL&vo8T?x@BZiNimmS6@#w`AdDgS5tEr&pOd6CJjd5P ze0;r&^DOUke~v-5U^2ib5+_)H-LQx^h-WsfI_G`j2rJ7f@j3B?K^G)`G`%*^&G- zg<=tSKcjET1H-q#z?$1zXCJ2zK$f~n-v9@PK&(XB>mKj!@9pj1GoAi^0AP)BpP_zK zQ2+oATWM5SbXZMHI%99Mkph4MBw{l(HZ(IZI4v+YHD)a|WHd4@VL3K2EjczaVq-Tp zGc{smW0N%nsS7zWHZ(9aH83_dGn45BaSAn5F)%tXGdeXjlVk=Y3N=(QFgh?ZIyE)3 zk_Hh0lfVXCBxPbYIXPrCV=ZB1Wo0ciHe_KfVqs!qEjBY{H!(FiVq`I8IFmpJOA0kq vF)%tXG&(Ravy}&K2n5(P-29UV5FiK-5d;9=$e=2NFA#?>5COL@5CUOFJ(yCV delta 706 zcmV;z0zLiqt_hH>36LZK#*rl-f7=biAPoFxijLqH#Ee4{Ds_X7UxSU5L_LYKb2b;_ z)W4s1dJPatlDuiDrGP=IRBDzeIND?cnLI%N9O_L^$GSu&jTUbMEgo^{abZPlmgS3) z9#Poug(~5%4UMp?-r)}QlLpZdZEFvYFmKsxduO)) zFi~e@FlH7XfX9)<&5~@4EY@gEGariZn#30JZ%+&92Ujs!nTcjOaFO;Qe~Tj3A{G&G z$WWc^q9Ts93Pq?8YK2xEOfLO`CJjl7i=*ILaPVWX>fqw6tAnc`2!4P#J2)x2NQwVT z3N2ziIPS;0dyl(!fKV?p&FYE)nr@q^L|n{dSH}9e&o91@f+uY%L2~~ zo9Wa%ahO;vw6W60tY~V))5H-~(`&;Q6#X4B}fpV zpo$X8uo0zIC&faF_TwG=Bd%W}mqM-*7&#VDfd<+2ga5(rZms;(f25Zbj01re$N3lo zx_5y_&2heu9j9>u_@99*z2&def!R;eYb`Bu1cbJMi|dxA>;acM!0?kHo3bnUX$tu~ z@P0<$lm!NFf!;N*x8^=hAAmGAR536*F*Q0gG?QlrBnmWCF)%tYH99mjvy}!B o0keq+U