mirror of
https://github.com/lbgos/rift-s-linux.git
synced 2026-10-18 09:00:57 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5caba039b7 | ||
|
|
b23b5e24d0 | ||
|
|
92d892f7ec | ||
|
|
2036af9170 |
No files matched your search
@@ -136,6 +136,10 @@ if(NOT WIN32)
|
||||
find_package(Systemd MODULE)
|
||||
find_package(OpenGLES MODULE COMPONENTS V3)
|
||||
find_library(RT_LIBRARY rt)
|
||||
# glibc before 2.34 (the Steam Runtime SDK) keeps shm_open in librt.
|
||||
if(RT_LIBRARY)
|
||||
link_libraries(${RT_LIBRARY})
|
||||
endif()
|
||||
endif()
|
||||
if(NOT ANDROID AND NOT MSVC)
|
||||
find_package(PkgConfig MODULE)
|
||||
|
||||
@@ -15,7 +15,7 @@ Free and open source, built on Monado and Basalt. Steam and SteamVR are installe
|
||||
- In-headset Room Setup with floor calibration, a persistent boundary and saved-room recognition.
|
||||
- A stable dashboard and a held position with IMU rotation when headset tracking is lost.
|
||||
- Headset and both Touch controller tracking, with controller haptics.
|
||||
- Rift S Settings for device state, software IPD, recentering and Room Setup, with an 80 fps zero-copy headset preview and fullscreen. The preview drops to 1 fps in standby.
|
||||
- Rift S Settings for device state, controller pairing, software IPD, recentering and Room Setup, with an 80 fps zero-copy headset preview and fullscreen. The preview drops to 1 fps in standby.
|
||||
- An in-headset Desktop overlay with mouse, scrolling and keyboard input on KDE Plasma Wayland.
|
||||
|
||||
## Requirements
|
||||
@@ -34,11 +34,21 @@ Close SteamVR and Settings before installing or upgrading.
|
||||
On Arch Linux and derivatives, install the prebuilt package from the [latest release](https://github.com/lbgos/rift-s-linux/releases/latest):
|
||||
|
||||
```sh
|
||||
curl -LO https://github.com/lbgos/rift-s-linux/releases/download/r29/rift-s-linux-bin-29-1-x86_64.pkg.tar.zst
|
||||
sudo pacman -U rift-s-linux-bin-29-1-x86_64.pkg.tar.zst
|
||||
curl -LO https://github.com/lbgos/rift-s-linux/releases/download/r30/rift-s-linux-bin-30-1-x86_64.pkg.tar.zst
|
||||
sudo pacman -U rift-s-linux-bin-30-1-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
To build the latest `main` instead, run `makepkg -si` in [packaging/aur/basalt-monado-rifts](packaging/aur/basalt-monado-rifts), then in [packaging/aur/rift-s-linux-git](packaging/aur/rift-s-linux-git). AUR submission is pending. Other distributions build from source.
|
||||
To build the latest `main` instead, run `makepkg -si` in [packaging/aur/basalt-monado-rifts](packaging/aur/basalt-monado-rifts), then in [packaging/aur/rift-s-linux-git](packaging/aur/rift-s-linux-git). AUR submission is pending.
|
||||
|
||||
On other distributions, install the universal build into your home directory. It needs no SteamVR launch option; the udev rule asks for your password once. Settings needs glibc 2.35 or newer and WebKitGTK 4.1 (Debian/Ubuntu `libwebkit2gtk-4.1-0`, Fedora `webkit2gtk4.1`).
|
||||
|
||||
```sh
|
||||
curl -LO https://github.com/lbgos/rift-s-linux/releases/download/r30/rift-s-linux-r30-x86_64.tar.xz
|
||||
tar -xf rift-s-linux-r30-x86_64.tar.xz
|
||||
./rift-s-linux-r30-x86_64/install.sh
|
||||
```
|
||||
|
||||
`install.sh --uninstall` removes it. Remove the universal build before installing a system package, and the reverse. [packaging/universal](packaging/universal) describes how it is built.
|
||||
|
||||
## Build from source
|
||||
|
||||
@@ -111,6 +121,8 @@ pnpm tauri build --no-bundle -- --locked
|
||||
5. Open Rift S Settings from the desktop launcher. For the full-resolution preview, enable its next-start preference and relaunch SteamVR once. On KDE, the native VR View opens minimized to supply the preview.
|
||||
6. Open the dashboard's Desktop tab to use the KDE desktop inside the headset.
|
||||
|
||||
To pair a new or replacement Touch controller, close SteamVR and use Pair controller in Settings. Hold Menu + Y (left) or Oculus + B (right) until the controller light flashes, then select it.
|
||||
|
||||
## Known issues
|
||||
|
||||
- USB or DisplayPort loss can crash SteamVR's compositor. Reconnect the headset and relaunch SteamVR manually. Recovery does not launch SteamVR automatically.
|
||||
|
||||
@@ -53,6 +53,8 @@ foreach(_xml ${_protocols})
|
||||
endforeach()
|
||||
|
||||
set(RIFTS_DESKTOP_MANIFEST "${CMAKE_INSTALL_FULL_DATADIR}/rifts-desktop/rifts-desktop.vrmanifest")
|
||||
# Relocated installs find the manifest relative to the binary.
|
||||
file(RELATIVE_PATH RIFTS_DESKTOP_MANIFEST_FROM_BIN "${CMAKE_INSTALL_FULL_BINDIR}" "${RIFTS_DESKTOP_MANIFEST}")
|
||||
add_executable(rifts-desktop
|
||||
src/capture.cpp
|
||||
src/gpu.cpp
|
||||
@@ -65,7 +67,8 @@ set_target_properties(rifts-desktop PROPERTIES CXX_STANDARD 20 CXX_STANDARD_REQU
|
||||
target_include_directories(rifts-desktop PRIVATE
|
||||
"${CMAKE_CURRENT_BINARY_DIR}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../src/external/openvr_includes")
|
||||
target_compile_definitions(rifts-desktop PRIVATE RIFTS_DESKTOP_MANIFEST="${RIFTS_DESKTOP_MANIFEST}")
|
||||
target_compile_definitions(rifts-desktop PRIVATE RIFTS_DESKTOP_MANIFEST="${RIFTS_DESKTOP_MANIFEST}"
|
||||
RIFTS_DESKTOP_MANIFEST_FROM_BIN="${RIFTS_DESKTOP_MANIFEST_FROM_BIN}")
|
||||
target_link_libraries(rifts-desktop PRIVATE PkgConfig::RIFTS_DESKTOP_DEPS Vulkan::Vulkan ${CMAKE_DL_LIBS})
|
||||
|
||||
configure_file(rifts-desktop.desktop.in rifts-desktop.desktop @ONLY)
|
||||
|
||||
@@ -289,7 +289,7 @@ private:
|
||||
auto *self = static_cast<KWinDesktop *>(data);
|
||||
if (strcmp(interface, wl_output_interface.name) == 0 && version >= 4) {
|
||||
auto o = std::make_unique<Output>(Output{self, id});
|
||||
o->output = static_cast<wl_output *>(wl_registry_bind(registry, id, &wl_output_interface, 4));
|
||||
o->output = static_cast<wl_output *>(wl_registry_bind(registry, id, &wl_output_interface, kOutputVersion));
|
||||
wl_output_add_listener(o->output, &kOutput, o.get());
|
||||
self->add_xdg(o.get());
|
||||
self->outputs_.push_back(std::move(o));
|
||||
@@ -425,14 +425,26 @@ private:
|
||||
.mode = [](void *, wl_output *, uint32_t, int32_t, int32_t, int32_t) {},
|
||||
.done = output_done,
|
||||
.scale = [](void *, wl_output *, int32_t) {},
|
||||
#ifdef WL_OUTPUT_NAME_SINCE_VERSION
|
||||
.name = output_name,
|
||||
.description = [](void *, wl_output *, const char *) {},
|
||||
#endif
|
||||
};
|
||||
// Older libwayland headers (the Steam Runtime SDK) stop at version 3; xdg-output
|
||||
// then supplies the same connector name.
|
||||
#ifdef WL_OUTPUT_NAME_SINCE_VERSION
|
||||
static constexpr uint32_t kOutputVersion = 4;
|
||||
#else
|
||||
static constexpr uint32_t kOutputVersion = 3;
|
||||
#endif
|
||||
static constexpr zxdg_output_v1_listener kXdgOutput = {
|
||||
.logical_position = xdg_position,
|
||||
.logical_size = xdg_size,
|
||||
.done = [](void *, zxdg_output_v1 *) {},
|
||||
.name = [](void *, zxdg_output_v1 *, const char *) {},
|
||||
.name = [](void *data, zxdg_output_v1 *, const char *name) {
|
||||
auto *o = static_cast<Output *>(data);
|
||||
if (o->name.empty()) o->name = name;
|
||||
},
|
||||
.description = [](void *, zxdg_output_v1 *, const char *) {},
|
||||
};
|
||||
static constexpr kde_output_order_v1_listener kOrder = {order_output, order_done};
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -36,6 +37,9 @@
|
||||
#ifndef RIFTS_DESKTOP_MANIFEST
|
||||
#define RIFTS_DESKTOP_MANIFEST "/usr/share/rifts-desktop/rifts-desktop.vrmanifest"
|
||||
#endif
|
||||
#ifndef RIFTS_DESKTOP_MANIFEST_FROM_BIN
|
||||
#define RIFTS_DESKTOP_MANIFEST_FROM_BIN "../share/rifts-desktop/rifts-desktop.vrmanifest"
|
||||
#endif
|
||||
|
||||
namespace rd {
|
||||
namespace {
|
||||
@@ -193,11 +197,25 @@ write_ppm(const std::string &path, const std::vector<uint8_t> &bgra, uint32_t w,
|
||||
return fclose(f) == 0;
|
||||
}
|
||||
|
||||
// The installed manifest, next to this binary when the install was moved to another prefix.
|
||||
const std::string &
|
||||
manifest_path()
|
||||
{
|
||||
static const std::string path = [] {
|
||||
std::error_code error;
|
||||
auto exe = std::filesystem::read_symlink("/proc/self/exe", error);
|
||||
auto local = (exe.parent_path() / RIFTS_DESKTOP_MANIFEST_FROM_BIN).lexically_normal();
|
||||
return !error && std::filesystem::is_regular_file(local, error) ? local.string()
|
||||
: std::string(RIFTS_DESKTOP_MANIFEST);
|
||||
}();
|
||||
return path;
|
||||
}
|
||||
|
||||
bool
|
||||
ensure_registered(const Vr &vr)
|
||||
{
|
||||
bool installed = vr.apps->IsApplicationInstalled(kAppKey);
|
||||
auto result = vr.apps->AddApplicationManifest(RIFTS_DESKTOP_MANIFEST, false);
|
||||
auto result = vr.apps->AddApplicationManifest(manifest_path().c_str(), false);
|
||||
if (result != vr::VRApplicationError_None) {
|
||||
fprintf(stderr, "rifts-desktop: manifest registration: %s\n", vr.apps->GetApplicationsErrorNameFromEnum(result));
|
||||
return false;
|
||||
@@ -223,7 +241,7 @@ run_registration(bool remove)
|
||||
// Not LaunchDashboardOverlay: SteamVR identifies this process as the app by its path,
|
||||
// so the overlay would count as running. Callers start it themselves.
|
||||
if (remove) {
|
||||
vr.apps->RemoveApplicationManifest(RIFTS_DESKTOP_MANIFEST);
|
||||
vr.apps->RemoveApplicationManifest(manifest_path().c_str());
|
||||
} else {
|
||||
if (!ensure_registered(vr)) { vr_disconnect(); return 1; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = rift-s-linux-bin
|
||||
pkgdesc = Oculus Rift S on Linux SteamVR: driver, Room Setup, Settings app and in-headset desktop (prebuilt)
|
||||
pkgver = 29
|
||||
pkgver = 30
|
||||
pkgrel = 1
|
||||
url = https://github.com/lbgos/rift-s-linux
|
||||
install = rift-s-linux.install
|
||||
@@ -72,7 +72,7 @@ pkgbase = rift-s-linux-bin
|
||||
conflicts = monado-rifts-git
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = https://github.com/lbgos/rift-s-linux/releases/download/r29/rift-s-linux-r29-x86_64.tar.zst
|
||||
sha256sums = c0833f3f3ffa0537be444bfd52ea5f731e764420bd0249b505891a387b64c754
|
||||
source = https://github.com/lbgos/rift-s-linux/releases/download/r30/rift-s-linux-r30-x86_64.tar.zst
|
||||
sha256sums = 6a4ff30493b8ee42acd4187fae2ea27bd01063b6c7e106e0c1494609302ef501
|
||||
|
||||
pkgname = rift-s-linux-bin
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: lbgos <285507185+lbgos@users.noreply.github.com>
|
||||
pkgname=rift-s-linux-bin
|
||||
pkgver=29
|
||||
pkgver=30
|
||||
pkgrel=1
|
||||
pkgdesc='Oculus Rift S on Linux SteamVR: driver, Room Setup, Settings app and in-headset desktop (prebuilt)'
|
||||
arch=('x86_64')
|
||||
@@ -24,7 +24,7 @@ conflicts=('rift-s-linux' 'monado' 'monado-git' 'rifts-settings' 'basalt-monado-
|
||||
install=rift-s-linux.install
|
||||
options=('!strip' '!debug')
|
||||
source=("https://github.com/lbgos/rift-s-linux/releases/download/r$pkgver/rift-s-linux-r$pkgver-x86_64.tar.zst")
|
||||
sha256sums=('c0833f3f3ffa0537be444bfd52ea5f731e764420bd0249b505891a387b64c754')
|
||||
sha256sums=('6a4ff30493b8ee42acd4187fae2ea27bd01063b6c7e106e0c1494609302ef501')
|
||||
|
||||
package() {
|
||||
cp -a usr "$pkgdir/"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Universal x86_64 build
|
||||
|
||||
A tarball for non-Arch distributions. `install.sh` puts everything under
|
||||
`~/.local/share/rift-s-linux`, which SteamVR also sees inside the Steam Linux
|
||||
Runtime container, so the driver needs no SteamVR launch option. Only the udev
|
||||
rule needs root.
|
||||
|
||||
The driver, Basalt and their libraries are built in the Steam Runtime sniper SDK
|
||||
(Debian 11, glibc 2.31) and bundle Basalt, oneTBB and a trimmed OpenCV. The
|
||||
Settings app needs webkit2gtk 4.1, which the SDK lacks, so it is built in an
|
||||
Ubuntu 22.04 root (glibc 2.35).
|
||||
|
||||
## Layout
|
||||
|
||||
`work/` is bind-mounted at `/work` in both roots:
|
||||
|
||||
- `src/monado` this repository
|
||||
- `src/basalt` Basalt prepared by `packaging/aur/basalt-monado-rifts` (`makepkg -o`)
|
||||
- `src/{opencv-4.12.0,oneTBB-2022.2.0,fmt-11.2.0}` upstream release tarballs
|
||||
- `hostdeps/usr` header-only build deps extracted from the Arch packages
|
||||
`boost`, `eigen`, `vulkan-headers`, `plasma-wayland-protocols`
|
||||
|
||||
`sysroot/` is `com.valvesoftware.SteamRuntime.Sdk-amd64,i386-sniper-sysroot.tar.gz`
|
||||
from `repo.steampowered.com/steamrt-images-sniper/snapshots/`. `sdk` runs a
|
||||
command in it with bwrap.
|
||||
|
||||
The Ubuntu root needs `build-essential pkg-config libwebkit2gtk-4.1-dev
|
||||
libgtk-3-dev libsoup-3.0-dev libvulkan-dev libwayland-dev glslang-tools
|
||||
python3`, rustup, Node 22 and pnpm 10. Ubuntu 22.04 has no `glslc` package; a
|
||||
wrapper around `glslangValidator -V -x` that adds the surrounding braces is enough
|
||||
for `settings-app/src-tauri/build.rs`.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
./sdk /work/src/monado/packaging/universal/deps.sh # once
|
||||
./sdk /work/src/monado/packaging/universal/build.sh
|
||||
<ubuntu root> /work/src/monado/packaging/universal/settings.sh
|
||||
packaging/universal/package.sh work <version> # host, needs patchelf
|
||||
```
|
||||
|
||||
The result is `work/dist/rift-s-linux-<version>-x86_64.tar.xz`. Users extract it
|
||||
and run `./install.sh`; `./install.sh --uninstall` removes it.
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Build Basalt and rift-s-linux against the bundled deps; stage the install under /work/stage.
|
||||
set -eu
|
||||
export CC=gcc-14 CXX=g++-14
|
||||
P=/work/prefix H=/work/hostdeps/usr J=${J:-8}
|
||||
export CMAKE_PREFIX_PATH="$P:$H"
|
||||
cmake -S /work/src/basalt -B /work/build/basalt -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=$P -DCMAKE_INSTALL_LIBDIR=lib -DBUILD_TESTS=OFF -DBUILD_TESTING=OFF \
|
||||
-DBASALT_INSTANTIATIONS_DOUBLE=OFF -DBASALT_BUILTIN_EIGEN=OFF -DEIGEN_ROOT=$H/include/eigen3 \
|
||||
-DBoost_INCLUDE_DIR=$H/include -DBASALT_BUILD_SHARED_LIBRARY_ONLY=ON -DBASALT_ENABLE_ROSBAG2=OFF \
|
||||
-DBASALT_BUILD_VISUALIZATION=OFF -DCXX_MARCH=x86-64 -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
-DCMAKE_INSTALL_RPATH="\$ORIGIN"
|
||||
cmake --build /work/build/basalt -j${BJ:-4} && cmake --install /work/build/basalt
|
||||
nm -D /work/build/basalt/libbasalt.so | grep -q " T vit_tracker_supports_safe_recreation"
|
||||
drivers=$(sed -n "s/.*(\(XRT_BUILD_DRIVER_[A-Z0-9_]*\) .*/-D\1=OFF/p" /work/src/monado/CMakeLists.txt | tr "\n" " ")
|
||||
# bin/ and share/steamvr-monado/bin/linux64/ both reach the bundled lib/.
|
||||
cmake -S /work/src/monado -B /work/build/monado -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/opt/rift-s-linux -DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_INSTALL_LIBEXECDIR=lib \
|
||||
-DCMAKE_INSTALL_RPATH="\$ORIGIN/../lib:\$ORIGIN/../../../../lib" \
|
||||
-DBUILD_TESTING=ON -DXRT_BUILD_SAMPLES=OFF $drivers \
|
||||
-DXRT_BUILD_DRIVER_RIFT_S=ON -DXRT_BUILD_DRIVER_SIMULATED=ON \
|
||||
-DXRT_FEATURE_STEAMVR_PLUGIN=ON -DXRT_FEATURE_SLAM=ON \
|
||||
-DXRT_HAVE_SYSTEM_CJSON=OFF -DXRT_HAVE_GST=OFF -DXRT_HAVE_BLUETOOTH=OFF \
|
||||
-DXRT_HAVE_DBUS=OFF -DXRT_HAVE_HIDAPI=OFF -DXRT_HAVE_JPEG=OFF -DXRT_HAVE_LIBBSD=OFF \
|
||||
-DXRT_HAVE_LIBUVC=OFF -DXRT_HAVE_OPENVR=OFF -DXRT_MODULE_MERCURY_HANDTRACKING=OFF \
|
||||
-DRIFTS_DESKTOP_OVERLAY=ON -DOpenCV_DIR=$P/lib/cmake/opencv4 -DEigen3_DIR=$H/share/eigen3/cmake
|
||||
cmake --build /work/build/monado -j$J
|
||||
VIT_SYSTEM_LIBRARY_PATH=$P/lib/libbasalt.so ctest --test-dir /work/build/monado --output-on-failure -j4
|
||||
rm -rf /work/stage && DESTDIR=/work/stage cmake --install /work/build/monado
|
||||
echo build-ok
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Build the bundled third-party libraries into /work/prefix inside the sniper SDK.
|
||||
set -eu
|
||||
export CC=gcc-14 CXX=g++-14
|
||||
P=/work/prefix
|
||||
J=${J:-8}
|
||||
common="-G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=$P -DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_POSITION_INDEPENDENT_CODE=ON"
|
||||
cd /work/src
|
||||
cmake -S fmt-11.2.0 -B /work/build/fmt $common -DFMT_TEST=OFF -DFMT_DOC=OFF -DBUILD_SHARED_LIBS=OFF
|
||||
cmake --build /work/build/fmt -j$J && cmake --install /work/build/fmt
|
||||
cmake -S oneTBB-2022.2.0 -B /work/build/tbb $common -DTBB_TEST=OFF -DTBB_EXAMPLES=OFF -DTBB_STRICT=OFF -DTBBMALLOC_BUILD=OFF
|
||||
cmake --build /work/build/tbb -j$J && cmake --install /work/build/tbb
|
||||
cmake -S opencv-4.12.0 -B /work/build/opencv $common \
|
||||
-DBUILD_LIST=core,imgproc,imgcodecs,highgui,video,calib3d,features2d,flann \
|
||||
-DWITH_QT=OFF -DWITH_GTK=OFF -DWITH_FFMPEG=OFF -DWITH_GSTREAMER=OFF -DWITH_V4L=OFF -DWITH_OPENCL=OFF \
|
||||
-DWITH_IPP=OFF -DWITH_ITT=OFF -DWITH_PROTOBUF=OFF -DWITH_TBB=OFF -DWITH_EIGEN=OFF -DWITH_LAPACK=OFF \
|
||||
-DWITH_TIFF=OFF -DWITH_WEBP=OFF -DWITH_OPENEXR=OFF -DWITH_OPENJPEG=OFF -DWITH_JASPER=OFF -DWITH_AVIF=OFF \
|
||||
-DWITH_IMGCODEC_HDR=OFF -DWITH_IMGCODEC_SUNRASTER=OFF -DWITH_IMGCODEC_PXM=OFF -DWITH_IMGCODEC_PFM=OFF \
|
||||
-DBUILD_PNG=ON -DBUILD_JPEG=ON -DBUILD_ZLIB=ON -DWITH_ADE=OFF -DWITH_VA=OFF -DWITH_1394=OFF \
|
||||
-DBUILD_TESTS=OFF -DBUILD_PERF_TESTS=OFF -DBUILD_EXAMPLES=OFF -DBUILD_opencv_apps=OFF -DBUILD_JAVA=OFF \
|
||||
-DBUILD_opencv_python3=OFF -DOPENCV_GENERATE_PKGCONFIG=OFF -DCPU_BASELINE=SSE3 -DOPENCV_SKIP_PYTHON_LOADER=ON
|
||||
cmake --build /work/build/opencv -j$J && cmake --install /work/build/opencv
|
||||
echo deps-ok
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
# Install or remove the universal rift-s-linux build for the current user.
|
||||
#
|
||||
# ./install.sh install into ~/.local/share/rift-s-linux
|
||||
# ./install.sh --uninstall remove it again
|
||||
#
|
||||
# Everything lands in the user's home, which SteamVR can also see inside the Steam
|
||||
# Linux Runtime container. Only the udev rule for headset access needs root.
|
||||
set -eu
|
||||
|
||||
BUILD_PREFIX=/opt/rift-s-linux
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
data=${XDG_DATA_HOME:-$HOME/.local/share}
|
||||
config=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||
prefix=$data/rift-s-linux
|
||||
units=$config/systemd/user
|
||||
service=rifts-steamvr-registration.service
|
||||
layer=$data/vulkan/implicit_layer.d/monado_roomsetup_vulkan.json
|
||||
rule=/etc/udev/rules.d/70-monado-rifts.rules
|
||||
desktop_files="rifts-steamvr.desktop rifts-desktop.desktop rifts-settings.desktop"
|
||||
|
||||
[ "$(id -u)" -ne 0 ] || { echo 'Run as your normal user, not root.' >&2; exit 1; }
|
||||
|
||||
as_root() {
|
||||
if command -v sudo >/dev/null; then sudo "$@"; else pkexec "$@"; fi
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
if [ -x "$prefix/bin/rifts-setup" ]; then
|
||||
systemctl --user disable --now "$service" 2>/dev/null || true
|
||||
"$prefix/bin/rifts-setup" uninstall || true
|
||||
fi
|
||||
rm -f "$units/$service" "$layer"
|
||||
for file in $desktop_files; do rm -f "$data/applications/$file"; done
|
||||
rm -rf "$prefix"
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
if [ -e "$rule" ]; then
|
||||
as_root rm -f "$rule" && as_root udevadm control --reload-rules || true
|
||||
fi
|
||||
echo 'rift-s-linux removed.'
|
||||
}
|
||||
|
||||
if [ "${1:-}" = --uninstall ]; then
|
||||
uninstall
|
||||
exit
|
||||
fi
|
||||
|
||||
if [ -d /usr/share/steamvr-monado ]; then
|
||||
echo 'A system-wide rift-s-linux package is installed. Remove it first.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop the running watcher before replacing its files.
|
||||
systemctl --user stop "$service" 2>/dev/null || true
|
||||
rm -rf "$prefix.new"
|
||||
mkdir -p "$prefix.new"
|
||||
cp -a "$here/files/." "$prefix.new/"
|
||||
# Text files carry the build prefix; binaries locate their files at run time.
|
||||
grep -rlI --null "$BUILD_PREFIX" "$prefix.new" | xargs -0 -r sed -i "s|$BUILD_PREFIX|$prefix|g"
|
||||
rm -rf "$prefix"
|
||||
mv "$prefix.new" "$prefix"
|
||||
|
||||
mkdir -p "$units" "$(dirname "$layer")" "$data/applications"
|
||||
cp "$prefix/lib/systemd/user/$service" "$units/$service"
|
||||
cp "$prefix/share/vulkan/implicit_layer.d/monado_roomsetup_vulkan.json" "$layer"
|
||||
for file in $desktop_files; do
|
||||
if [ -f "$prefix/share/applications/$file" ]; then cp "$prefix/share/applications/$file" "$data/applications/$file"; fi
|
||||
done
|
||||
|
||||
if ! cmp -s "$prefix/share/udev/70-monado-rifts.rules" "$rule" 2>/dev/null; then
|
||||
echo 'Installing the udev rule for headset access (needs your password once).'
|
||||
as_root install -Dm644 "$prefix/share/udev/70-monado-rifts.rules" "$rule"
|
||||
as_root udevadm control --reload-rules
|
||||
as_root udevadm trigger --subsystem-match=usb --attr-match=idVendor=2833 || true
|
||||
as_root udevadm trigger --subsystem-match=hidraw || true
|
||||
fi
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "$service"
|
||||
echo 'rift-s-linux installed. Reconnect the Rift S, then start SteamVR from Steam.'
|
||||
if ldd "$prefix/bin/rifts-settings" 2>/dev/null | grep -q 'not found'; then
|
||||
echo 'The Settings app needs WebKitGTK 4.1 and glibc 2.35 or newer'
|
||||
echo '(Debian/Ubuntu: libwebkit2gtk-4.1-0, Fedora: webkit2gtk4.1, openSUSE: libwebkit2gtk-4_1-0).'
|
||||
fi
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
# Assemble the universal tarball from a finished SDK build (see build.sh).
|
||||
#
|
||||
# package.sh <work dir> <version>
|
||||
#
|
||||
# <work dir> holds src/{monado,basalt,...}, prefix/ (bundled libraries) and
|
||||
# stage/opt/rift-s-linux (the installed tree). Needs patchelf and xz on the host.
|
||||
set -eu
|
||||
|
||||
work=$(cd "$1" && pwd)
|
||||
version=$2
|
||||
name=rift-s-linux-$version-x86_64
|
||||
out=$work/dist/$name
|
||||
files=$out/files
|
||||
src=$work/src
|
||||
licenses=$files/share/licenses/rift-s-linux
|
||||
|
||||
rm -rf "$out"
|
||||
mkdir -p "$files"
|
||||
cp -a "$work/stage/opt/rift-s-linux/." "$files/"
|
||||
# The universal install never registers itself as the system OpenXR runtime.
|
||||
rm -rf "$files/share/openxr" "$files/lib/libopenxr_monado.so"
|
||||
|
||||
# Bundled libraries: Basalt and its OpenCV/oneTBB. Everything else comes from the
|
||||
# host or, for the SteamVR driver, from the Steam Linux Runtime.
|
||||
for lib in libbasalt.so.2 libtbb.so.12 $(cd "$work/prefix/lib" && ls libopencv_*.so.412); do
|
||||
cp -L "$work/prefix/lib/$lib" "$files/lib/$lib"
|
||||
done
|
||||
ln -s libbasalt.so.2 "$files/lib/libbasalt.so"
|
||||
|
||||
# Relocatable lookup from each install location to the bundled lib/.
|
||||
set_rpath() {
|
||||
for f in "$@"; do
|
||||
if [ ! -L "$f" ] && file -b "$f" | grep -q ELF; then patchelf --set-rpath "$rpath" "$f"; fi
|
||||
done
|
||||
}
|
||||
rpath='$ORIGIN/../lib' set_rpath "$files"/bin/*
|
||||
rpath='$ORIGIN' set_rpath "$files"/lib/*.so*
|
||||
rpath='$ORIGIN/../../../../lib' set_rpath "$files"/share/steamvr-monado/bin/linux64/*
|
||||
|
||||
install -Dm644 "$src/monado/packaging/aur/rift-s-linux-git/70-monado-rifts.rules" \
|
||||
"$files/share/udev/70-monado-rifts.rules"
|
||||
|
||||
mkdir -p "$licenses"
|
||||
install -m644 "$src/monado/LICENSE" "$licenses/LICENSE"
|
||||
cp -a "$src/monado/LICENSES" "$licenses/Monado"
|
||||
(cd "$src/monado" && find src/external -type f \( -iname LICENSE -o -iname LICENSE.txt -o -iname COPYING \
|
||||
-o -iname '*.license' \) -exec install -Dm644 {} "$licenses/{}" \;)
|
||||
for item in LICENSE thirdparty/basalt-headers/LICENSE thirdparty/basalt-headers/thirdparty/Sophus/LICENSE.txt \
|
||||
thirdparty/basalt-headers/thirdparty/cereal/LICENSE thirdparty/CLI11/LICENSE thirdparty/magic_enum/LICENSE \
|
||||
thirdparty/opengv/License.txt thirdparty/vit/LICENSE; do
|
||||
install -Dm644 "$src/basalt/$item" "$licenses/Basalt/$item"
|
||||
done
|
||||
cp -a "$work/prefix/share/licenses/opencv4" "$licenses/OpenCV"
|
||||
install -m644 "$src"/opencv-*/LICENSE "$licenses/OpenCV/LICENSE"
|
||||
install -Dm644 "$src"/oneTBB-*/LICENSE.txt "$licenses/oneTBB/LICENSE.txt"
|
||||
install -Dm644 "$src"/fmt-*/LICENSE "$licenses/fmt/LICENSE"
|
||||
|
||||
install -m755 "$src/monado/packaging/universal/install.sh" "$out/install.sh"
|
||||
tar -C "$work/dist" --sort=name --owner=0 --group=0 --numeric-owner -cJf "$work/dist/$name.tar.xz" "$name"
|
||||
echo "$work/dist/$name.tar.xz"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Run a command inside the Steam Runtime sniper SDK sysroot, with ~/src/universal/work at /work.
|
||||
here=$(dirname "$(readlink -f "$0")")
|
||||
exec bwrap --bind "$here/sysroot" / --dev /dev --proc /proc --tmpfs /tmp \
|
||||
--bind "$here/work" /work --ro-bind /etc/resolv.conf /etc/resolv.conf \
|
||||
--setenv HOME /work/home --setenv PATH /usr/local/bin:/usr/bin:/bin --chdir /work "$@"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# Build the Settings app and add it to /work/stage. Runs in an Ubuntu 22.04 root
|
||||
# (webkit2gtk 4.1, glibc 2.35) after build.sh; needs cargo, pnpm and glslc.
|
||||
set -eu
|
||||
app=/work/src/monado/settings-app
|
||||
aur=/work/src/monado/packaging/aur/rift-s-linux-git
|
||||
dest=/work/stage/opt/rift-s-linux
|
||||
licenses=$dest/share/licenses/rift-s-linux
|
||||
meta=/work/build/settings
|
||||
mkdir -p "$meta"
|
||||
|
||||
cd "$app"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
# custom-protocol embeds the built frontend instead of using the dev server.
|
||||
cargo build --manifest-path src-tauri/Cargo.toml --release --locked --features custom-protocol -j${J:-8}
|
||||
cargo test --manifest-path src-tauri/Cargo.toml --release --locked --features custom-protocol -j${J:-8}
|
||||
cargo metadata --manifest-path src-tauri/Cargo.toml --locked --features custom-protocol --format-version 1 \
|
||||
--filter-platform x86_64-unknown-linux-gnu > "$meta/rust.json"
|
||||
pnpm licenses list --prod --json > "$meta/js.json"
|
||||
|
||||
install -Dm755 src-tauri/target/release/rifts-settings "$dest/bin/rifts-settings"
|
||||
install -Dm644 rifts-settings.desktop "$dest/share/applications/rifts-settings.desktop"
|
||||
sed -i -e "s|^Exec=.*|Exec=/opt/rift-s-linux/bin/rifts-settings|" \
|
||||
-e "s|^Icon=.*|Icon=/opt/rift-s-linux/share/icons/hicolor/128x128/apps/rifts-settings.png|" \
|
||||
"$dest/share/applications/rifts-settings.desktop"
|
||||
install -Dm644 src-tauri/icons/icon.png "$dest/share/icons/hicolor/128x128/apps/rifts-settings.png"
|
||||
install -Dm755 install-vrview-rule.sh "$dest/share/rifts-settings/install-vrview-rule.sh"
|
||||
install -Dm644 vrview.kwinrule "$dest/share/rifts-settings/vrview.kwinrule"
|
||||
|
||||
install -Dm644 LICENSE "$licenses/Settings-MIT"
|
||||
install -Dm644 src/fonts/OFL.txt "$licenses/OpenSans-OFL"
|
||||
install -Dm644 src-tauri/native/OpenVR-LICENSE "$licenses/Settings-OpenVR"
|
||||
python3 "$aur/settings-licenses.py" "$meta/rust.json" "$meta/js.json" "$aur/settings-license-fallbacks.json" \
|
||||
> "$licenses/Settings-third-party-notices.txt"
|
||||
echo settings-ok
|
||||
+13
-5
@@ -8,6 +8,7 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -16,8 +17,9 @@ DRIVER = Path('@CMAKE_INSTALL_FULL_DATADIR@/steamvr-monado')
|
||||
DESKTOP = Path('@CMAKE_INSTALL_FULL_BINDIR@/rifts-desktop')
|
||||
STEAMVR_APP = '250820'
|
||||
# Steam starts SteamVR inside the Steam Linux Runtime container, which cannot see the
|
||||
# host's /usr: the driver and its libraries would be missing. vrmonitor.sh starts
|
||||
# SteamVR on the host instead.
|
||||
# host's /usr: a system-wide driver and its libraries would be missing. vrmonitor.sh
|
||||
# starts SteamVR on the host instead. The universal build installs into the home
|
||||
# directory, which the container shares, and needs no launch option.
|
||||
LAUNCH_SUFFIX = '/bin/vrmonitor.sh %command%'
|
||||
LAUNCH_PATH = ['UserLocalConfigStore', 'Software', 'Valve', 'Steam', 'apps', STEAMVR_APP]
|
||||
|
||||
@@ -91,7 +93,8 @@ def register(home, registry_path, uninstall=False):
|
||||
if not registry.get('log'):
|
||||
pathreg(tool, 'setlog', str(root / 'logs'))
|
||||
pathreg(tool, 'adddriver', str(DRIVER))
|
||||
return True, roots, libraries, host_launch(roots, runtime)
|
||||
# A home install drops the option a former system-wide install may have set.
|
||||
return True, roots, libraries, host_launch(roots, runtime, uninstall=home in DRIVER.parents)
|
||||
|
||||
|
||||
def vdf_tokens(text):
|
||||
@@ -244,8 +247,13 @@ def host_launch(roots, runtime, uninstall=False):
|
||||
def launch_desktop():
|
||||
# The client registers its manifest and preserves the user's autolaunch choice.
|
||||
# It also locks out duplicates when SteamVR has already started it.
|
||||
subprocess.Popen([str(DESKTOP), '--autolaunch'], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
command = [str(DESKTOP), '--autolaunch']
|
||||
# Run it in its own unit: this service's memory limit only covers the watcher.
|
||||
if shutil.which('systemd-run'):
|
||||
subprocess.run(['systemd-run', '--user', '--quiet', '--collect', '--', *command],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
|
||||
else:
|
||||
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
|
||||
|
||||
class DirectoryEvents:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod pairing;
|
||||
mod pairing_protocol;
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -856,6 +859,10 @@ fn main() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
pairing::controller_pairing_status,
|
||||
pairing::controller_pairing_start,
|
||||
pairing::controller_pairing_select,
|
||||
pairing::controller_pairing_cancel,
|
||||
snapshot,
|
||||
preview_layout,
|
||||
preview_status,
|
||||
@@ -869,6 +876,7 @@ fn main() {
|
||||
.expect("Unable to start Rift S settings")
|
||||
.run(|_, event| {
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
pairing::shutdown();
|
||||
if let Ok(_lock) = VR.lock() {
|
||||
unsafe { rifts_shutdown() };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026, lbgos
|
||||
// SPDX-License-Identifier: BSL-1.0
|
||||
use crate::pairing_protocol::{Device, Radio, Transport, LEFT, RIGHT};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
os::fd::AsRawFd,
|
||||
sync::{mpsc, Mutex},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Status {
|
||||
stage: String,
|
||||
message: String,
|
||||
candidates: Vec<Candidate>,
|
||||
hand: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct Candidate {
|
||||
id: String,
|
||||
}
|
||||
|
||||
enum Request {
|
||||
Pair(u64),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Session {
|
||||
status: Status,
|
||||
sender: Option<mpsc::Sender<Request>>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
static SESSION: Mutex<Session> = Mutex::new(Session {
|
||||
status: Status {
|
||||
stage: String::new(),
|
||||
message: String::new(),
|
||||
candidates: Vec::new(),
|
||||
hand: String::new(),
|
||||
},
|
||||
sender: None,
|
||||
worker: None,
|
||||
});
|
||||
|
||||
fn publish(stage: &str, message: impl Into<String>, devices: &[Device]) {
|
||||
if let Ok(mut session) = SESSION.lock() {
|
||||
session.status = Status {
|
||||
stage: stage.into(),
|
||||
message: message.into(),
|
||||
candidates: devices
|
||||
.iter()
|
||||
.map(|device| Candidate {
|
||||
id: format!("{:016x}", device.id),
|
||||
})
|
||||
.collect(),
|
||||
hand: session.status.hand.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
struct Hid(File);
|
||||
impl Hid {
|
||||
fn open() -> Result<Self, String> {
|
||||
if radio_in_use() {
|
||||
return Err("Close SteamVR and Monado before pairing a controller.".into());
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for entry in fs::read_dir("/sys/class/hidraw")
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
{
|
||||
let Ok(device) = fs::canonicalize(entry.path().join("device")) else {
|
||||
continue;
|
||||
};
|
||||
let interface = device.ancestors().any(|path| {
|
||||
fs::read_to_string(path.join("bInterfaceNumber")).is_ok_and(|s| s.trim() == "06")
|
||||
});
|
||||
let headset = device.ancestors().any(|path| {
|
||||
fs::read_to_string(path.join("idVendor")).is_ok_and(|s| s.trim() == "2833")
|
||||
&& fs::read_to_string(path.join("idProduct")).is_ok_and(|s| s.trim() == "0051")
|
||||
});
|
||||
if interface && headset {
|
||||
paths.push(std::path::Path::new("/dev").join(entry.file_name()));
|
||||
}
|
||||
}
|
||||
if paths.len() != 1 {
|
||||
return Err(if paths.is_empty() {
|
||||
"Rift S headset is not connected."
|
||||
} else {
|
||||
"Connect only one Rift S headset before pairing."
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&paths[0])
|
||||
.map_err(|e| {
|
||||
format!("Cannot open headset radio. Check Rift S device permissions: {e}")
|
||||
})?;
|
||||
unsafe extern "C" {
|
||||
fn flock(fd: i32, operation: i32) -> i32;
|
||||
}
|
||||
if unsafe { flock(file.as_raw_fd(), 2 | 4) } < 0 {
|
||||
return Err("Another pairing session owns the headset radio.".into());
|
||||
}
|
||||
Ok(Self(file))
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for Hid {
|
||||
fn get(&mut self, report: u8) -> Result<Vec<u8>, String> {
|
||||
let mut data = vec![0u8; 200];
|
||||
data[0] = report;
|
||||
let size = feature(&self.0, 0x07, &mut data)?;
|
||||
data.truncate(size);
|
||||
Ok(data)
|
||||
}
|
||||
fn set(&mut self, data: &[u8]) -> Result<(), String> {
|
||||
// hidraw locks are advisory; the driver does not participate in them.
|
||||
// Check again before every write if SteamVR was started during the scan.
|
||||
if radio_in_use() {
|
||||
return Err("A VR runtime started during pairing. Close it before retrying.".into());
|
||||
}
|
||||
let mut data = data.to_vec();
|
||||
let size = feature(&self.0, 0x06, &mut data)?;
|
||||
if size != data.len() {
|
||||
return Err("Incomplete headset radio write. Pairing state may have changed.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn feature(file: &File, operation: u32, data: &mut [u8]) -> Result<usize, String> {
|
||||
unsafe extern "C" {
|
||||
fn ioctl(fd: i32, request: std::ffi::c_ulong, ...) -> i32;
|
||||
}
|
||||
// Linux HIDIOCGFEATURE/HIDIOCSFEATURE, both _IOC_READ | _IOC_WRITE.
|
||||
let request = (3u32 << 30) | ((data.len() as u32) << 16) | (u32::from(b'H') << 8) | operation;
|
||||
let result = unsafe {
|
||||
ioctl(
|
||||
file.as_raw_fd(),
|
||||
request as std::ffi::c_ulong,
|
||||
data.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
if result < 0 {
|
||||
Err(format!(
|
||||
"Headset USB operation failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
))
|
||||
} else {
|
||||
Ok(result as usize)
|
||||
}
|
||||
}
|
||||
|
||||
fn run(kind: u32, receiver: mpsc::Receiver<Request>) {
|
||||
let result = (|| {
|
||||
let mut radio = Radio(Hid::open()?);
|
||||
let previous = radio.mode()?;
|
||||
// Restore the original mode even when entering discovery only partly succeeds.
|
||||
let outcome = (|| {
|
||||
radio.set_mode(1)?;
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
loop {
|
||||
let paired = radio.list(0x11)?;
|
||||
let candidates: Vec<_> = radio
|
||||
.list(0x0c)?
|
||||
.into_iter()
|
||||
.filter(|device| {
|
||||
device.kind == kind
|
||||
&& device.id != 0
|
||||
&& !device.corrupt
|
||||
&& !paired.iter().any(|old| old.id == device.id)
|
||||
})
|
||||
.collect();
|
||||
publish(
|
||||
if candidates.is_empty() {
|
||||
"searching"
|
||||
} else {
|
||||
"ready"
|
||||
},
|
||||
if candidates.is_empty() {
|
||||
"Waiting for a controller."
|
||||
} else {
|
||||
"Select the controller to pair."
|
||||
},
|
||||
&candidates,
|
||||
);
|
||||
match receiver.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(Request::Cancel) | Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Ok(false)
|
||||
}
|
||||
Ok(Request::Pair(id)) => {
|
||||
let selected = candidates
|
||||
.iter()
|
||||
.find(|device| device.id == id)
|
||||
.ok_or("Controller is no longer available. Scan again.")?;
|
||||
publish(
|
||||
"pairing",
|
||||
"Pairing controller. Keep Settings open and SteamVR closed.",
|
||||
&[],
|
||||
);
|
||||
radio.pair(selected).map_err(|error| format!("{error}. Pairing did not finish; the saved pairing list may have changed."))?;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("No controller selected within 90 seconds. Try again.".into());
|
||||
}
|
||||
}
|
||||
})();
|
||||
let restore = radio.set_mode(previous);
|
||||
match (outcome, restore) {
|
||||
(Ok(paired), Ok(())) => Ok(paired),
|
||||
(Err(error), Ok(())) => Err(error),
|
||||
(outcome, Err(error)) => Err(format!("{} Radio mode could not be restored: {error}. Reconnect the headset before starting SteamVR.",
|
||||
match outcome { Ok(true) => "Controller was added to the pairing list.".into(), Ok(false) => "Scan cancelled.".into(), Err(error) => error })),
|
||||
}
|
||||
})();
|
||||
match result {
|
||||
Ok(true) => publish(
|
||||
"complete",
|
||||
"Controller added to the headset pairing list. You can start SteamVR.",
|
||||
&[],
|
||||
),
|
||||
Ok(false) => publish("cancelled", "Pairing scan cancelled.", &[]),
|
||||
Err(error) => publish("failed", error, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn controller_pairing_status() -> Result<Status, String> {
|
||||
Ok(SESSION.lock().map_err(|e| e.to_string())?.status.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn controller_pairing_start(hand: String) -> Result<(), String> {
|
||||
let kind = match hand.as_str() {
|
||||
"left" => LEFT,
|
||||
"right" => RIGHT,
|
||||
_ => return Err("Invalid controller hand".into()),
|
||||
};
|
||||
if radio_in_use() {
|
||||
return Err("Close SteamVR and Monado before pairing a controller.".into());
|
||||
}
|
||||
let mut session = SESSION.lock().map_err(|e| e.to_string())?;
|
||||
if session
|
||||
.worker
|
||||
.as_ref()
|
||||
.is_some_and(|worker| !worker.is_finished())
|
||||
{
|
||||
return Err("A controller pairing session is already running.".into());
|
||||
}
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
session.sender = Some(sender);
|
||||
session.status = Status {
|
||||
stage: "searching".into(),
|
||||
message: "Opening headset radio.".into(),
|
||||
candidates: vec![],
|
||||
hand,
|
||||
};
|
||||
session.worker = Some(thread::spawn(move || run(kind, receiver)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn radio_in_use() -> bool {
|
||||
["vrserver", "monado-service", "monado-cli"]
|
||||
.iter()
|
||||
.any(|name| super::process_pid(name).is_some())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn controller_pairing_select(id: String) -> Result<(), String> {
|
||||
let mut session = SESSION.lock().map_err(|e| e.to_string())?;
|
||||
if session.status.stage != "ready"
|
||||
|| !session
|
||||
.status
|
||||
.candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.id == id)
|
||||
{
|
||||
return Err("Controller is no longer available.".into());
|
||||
}
|
||||
let id = u64::from_str_radix(&id, 16).map_err(|e| e.to_string())?;
|
||||
session
|
||||
.sender
|
||||
.as_ref()
|
||||
.ok_or("No pairing session")?
|
||||
.send(Request::Pair(id))
|
||||
.map_err(|e| e.to_string())?;
|
||||
session.status.stage = "pairing".into();
|
||||
session.status.message = "Pairing controller.".into();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn controller_pairing_cancel() -> Result<(), String> {
|
||||
let session = SESSION.lock().map_err(|e| e.to_string())?;
|
||||
if !["searching", "ready"].contains(&session.status.stage.as_str()) {
|
||||
return Err("Wait for the pairing transaction to finish.".into());
|
||||
}
|
||||
session
|
||||
.sender
|
||||
.as_ref()
|
||||
.ok_or("No pairing session")?
|
||||
.send(Request::Cancel)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn shutdown() {
|
||||
let worker = if let Ok(mut session) = SESSION.lock() {
|
||||
if let Some(sender) = session.sender.take() {
|
||||
let _ = sender.send(Request::Cancel);
|
||||
}
|
||||
session.worker.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(worker) = worker {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Copyright 2026, lbgos
|
||||
// SPDX-License-Identifier: BSL-1.0
|
||||
//! Rift S wireless-host HID protocol. Lengths include the report ID.
|
||||
//! Pairing uses discovery mode, a temporary connection, then an app restart.
|
||||
//! This module has no firmware-update or unpair commands.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const LEFT: u32 = 0x13001101;
|
||||
pub const RIGHT: u32 = 0x13011101;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Device {
|
||||
pub id: u64,
|
||||
pub kind: u32,
|
||||
pub corrupt: bool,
|
||||
}
|
||||
|
||||
pub trait Transport {
|
||||
fn get(&mut self, report: u8) -> Result<Vec<u8>, String>;
|
||||
fn set(&mut self, data: &[u8]) -> Result<(), String>;
|
||||
}
|
||||
|
||||
pub fn devices(data: &[u8], report: u8) -> Result<Vec<Device>, String> {
|
||||
let count_offset = if report == 0x11 { 1 } else { 2 };
|
||||
if data.first() != Some(&report) || data.len() <= count_offset {
|
||||
return Err("Invalid controller list report".into());
|
||||
}
|
||||
let count = usize::from(data[count_offset]);
|
||||
let start = count_offset + 1;
|
||||
if count > 4 || data.len() < start + count * 28 {
|
||||
return Err("Truncated or oversized controller list".into());
|
||||
}
|
||||
data[start..start + count * 28]
|
||||
.chunks_exact(28)
|
||||
.map(|record| {
|
||||
Ok(Device {
|
||||
id: u64::from_le_bytes(record[..8].try_into().unwrap()),
|
||||
kind: u32::from_le_bytes(record[8..12].try_into().unwrap()),
|
||||
corrupt: record[27] & 1 != 0,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct Radio<T>(pub T);
|
||||
|
||||
impl<T: Transport> Radio<T> {
|
||||
fn idle(&mut self) -> Result<Vec<u8>, String> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let data = self.0.get(0x0b)?;
|
||||
if data.len() < 7 || data[0] != 0x0b {
|
||||
return Err("Truncated wireless status".into());
|
||||
}
|
||||
if data[2] == 0 {
|
||||
return Ok(data);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("Headset radio is busy".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(&mut self, data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let sequence = self.idle()?[1];
|
||||
// Never retry a write: an ambiguous USB failure may have committed it.
|
||||
self.0.set(data)?;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let reply = self.idle()?;
|
||||
if reply[1] != sequence {
|
||||
let status = u32::from_le_bytes(reply[3..7].try_into().unwrap());
|
||||
return if status == 0 {
|
||||
Ok(reply)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Headset radio rejected command {:#04x}: {status:#x}",
|
||||
data[0]
|
||||
))
|
||||
};
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("Headset radio did not acknowledge the command".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mode(&mut self) -> Result<u8, String> {
|
||||
let data = self.0.get(0x0a)?;
|
||||
match data.as_slice() {
|
||||
[0x0a, mode @ 0..=2, ..] => Ok(*mode),
|
||||
_ => Err("Invalid headset radio mode".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: u8) -> Result<(), String> {
|
||||
self.command(&[0x0a, mode])?;
|
||||
if self.mode()? != mode {
|
||||
return Err("Headset radio mode did not change".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list(&mut self, report: u8) -> Result<Vec<Device>, String> {
|
||||
devices(&self.0.get(report)?, report)
|
||||
}
|
||||
|
||||
pub fn pair(&mut self, selected: &Device) -> Result<(), String> {
|
||||
if ![LEFT, RIGHT].contains(&selected.kind) || selected.id == 0 || selected.corrupt {
|
||||
return Err("Controller type or app firmware is unsupported".into());
|
||||
}
|
||||
let whitelist = self.list(0x11)?;
|
||||
if whitelist.iter().any(|device| device.id == selected.id) {
|
||||
return Err("This controller is already paired".into());
|
||||
}
|
||||
if whitelist.len() == 4 {
|
||||
return Err("Headset pairing list is full. No existing pairing was removed.".into());
|
||||
}
|
||||
if !self.list(0x0c)?.contains(selected) {
|
||||
return Err(
|
||||
"Controller stopped advertising. Hold its pairing buttons and try again.".into(),
|
||||
);
|
||||
}
|
||||
let mut connect = vec![0x0d];
|
||||
connect.extend(selected.id.to_le_bytes());
|
||||
connect.extend(4000u16.to_le_bytes());
|
||||
if let Err(error) = self.command(&connect) {
|
||||
let cleanup = self.command(&[0x0e, 0]);
|
||||
return Err(match cleanup {
|
||||
Ok(_) => error,
|
||||
Err(cleanup) => format!("{error}. Temporary connection cleanup failed: {cleanup}"),
|
||||
});
|
||||
}
|
||||
let result: Result<(), String> = (|| {
|
||||
let mut pair = vec![0x0f];
|
||||
pair.extend(selected.kind.to_le_bytes());
|
||||
self.command(&pair)?;
|
||||
// Register 0x15 restarts the existing app, with no flash data.
|
||||
let mut restart = vec![0u8; 61];
|
||||
restart[0] = 0x13;
|
||||
restart[1..9].copy_from_slice(&selected.id.to_le_bytes());
|
||||
restart[9] = 0x15;
|
||||
restart[11..13].copy_from_slice(&1000u16.to_le_bytes());
|
||||
self.command(&restart)?;
|
||||
Ok(())
|
||||
})();
|
||||
// End the temporary radio connection on success and failure.
|
||||
// The disconnect report carries only the low byte of the target ID.
|
||||
let disconnect = self.command(&[0x0e, selected.id as u8]);
|
||||
match (result, disconnect) {
|
||||
(Err(error), Err(cleanup)) => {
|
||||
return Err(format!(
|
||||
"{error}. Temporary connection cleanup failed: {cleanup}"
|
||||
))
|
||||
}
|
||||
(Err(error), _) => return Err(error),
|
||||
(_, Err(error)) => return Err(error),
|
||||
_ => {}
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let saved = self.list(0x11)?;
|
||||
if whitelist.iter().any(|old| {
|
||||
!saved
|
||||
.iter()
|
||||
.any(|device| device.id == old.id && device.kind == old.kind)
|
||||
}) {
|
||||
return Err("An existing controller is missing from the pairing list. Stop and check the headset.".into());
|
||||
}
|
||||
if saved
|
||||
.iter()
|
||||
.any(|device| device.id == selected.id && device.kind == selected.kind)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(
|
||||
"Pair command completed but the controller is absent from the pairing list"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_lists() {
|
||||
for data in [vec![], vec![0x0c, 0], vec![0x0c, 0, 5], vec![0x0c, 0, 1]] {
|
||||
assert!(devices(&data, 0x0c).is_err());
|
||||
}
|
||||
let mut data = vec![0x11, 1];
|
||||
data.extend(42u64.to_le_bytes());
|
||||
data.extend(LEFT.to_le_bytes());
|
||||
data.extend([0; 16]);
|
||||
assert_eq!(
|
||||
devices(&data, 0x11).unwrap()[0],
|
||||
Device {
|
||||
id: 42,
|
||||
kind: LEFT,
|
||||
corrupt: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
struct Fake {
|
||||
reads: VecDeque<Vec<u8>>,
|
||||
writes: Vec<Vec<u8>>,
|
||||
fail_write: bool,
|
||||
}
|
||||
impl Transport for Fake {
|
||||
fn get(&mut self, report: u8) -> Result<Vec<u8>, String> {
|
||||
let data = self.reads.pop_front().expect("unexpected read");
|
||||
assert_eq!(data[0], report);
|
||||
Ok(data)
|
||||
}
|
||||
fn set(&mut self, data: &[u8]) -> Result<(), String> {
|
||||
self.writes.push(data.to_vec());
|
||||
if self.fail_write {
|
||||
Err("USB disconnected".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_requires_new_sequence_and_checks_result() {
|
||||
let mut radio = Radio(Fake {
|
||||
reads: [vec![11, 255, 0, 0, 0, 0, 0], vec![11, 0, 0, 3, 0, 0, 0]].into(),
|
||||
writes: vec![],
|
||||
fail_write: false,
|
||||
});
|
||||
assert!(radio.command(&[10, 1]).unwrap_err().contains("rejected"));
|
||||
assert_eq!(radio.0.writes, [vec![10, 1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_write_failure_is_never_retried() {
|
||||
let mut radio = Radio(Fake {
|
||||
reads: [vec![11, 1, 0, 0, 0, 0, 0]].into(),
|
||||
writes: vec![],
|
||||
fail_write: true,
|
||||
});
|
||||
assert!(radio.command(&[10, 1]).is_err());
|
||||
assert_eq!(radio.0.writes.len(), 1);
|
||||
}
|
||||
|
||||
fn list(report: u8, entries: &[Device]) -> Vec<u8> {
|
||||
let mut data = if report == 0x11 {
|
||||
vec![report, entries.len() as u8]
|
||||
} else {
|
||||
vec![report, 1, entries.len() as u8]
|
||||
};
|
||||
for device in entries {
|
||||
data.extend(device.id.to_le_bytes());
|
||||
data.extend(device.kind.to_le_bytes());
|
||||
data.extend([0; 15]);
|
||||
data.push(u8::from(device.corrupt));
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_preserves_existing_entries_and_verifies_new_identity() {
|
||||
let old = Device {
|
||||
id: 3,
|
||||
kind: RIGHT,
|
||||
corrupt: false,
|
||||
};
|
||||
let new = Device {
|
||||
id: 0x12345678abcdef01,
|
||||
kind: LEFT,
|
||||
corrupt: false,
|
||||
};
|
||||
let mut reads = VecDeque::from([list(0x11, &[old.clone()]), list(0x0c, &[new.clone()])]);
|
||||
for seq in 0..4 {
|
||||
reads.push_back(vec![11, seq, 0, 0, 0, 0, 0]);
|
||||
reads.push_back(vec![11, seq + 1, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
reads.push_back(list(0x11, &[old, new.clone()]));
|
||||
let mut radio = Radio(Fake {
|
||||
reads,
|
||||
writes: vec![],
|
||||
fail_write: false,
|
||||
});
|
||||
radio.pair(&new).unwrap();
|
||||
assert!(radio.0.reads.is_empty());
|
||||
let writes = &radio.0.writes;
|
||||
assert_eq!(
|
||||
writes.iter().map(|data| data[0]).collect::<Vec<_>>(),
|
||||
[0x0d, 0x0f, 0x13, 0x0e]
|
||||
);
|
||||
assert_eq!(&writes[0][1..9], &new.id.to_le_bytes());
|
||||
assert_eq!(&writes[1][1..], &LEFT.to_le_bytes());
|
||||
assert_eq!(writes[2].len(), 61);
|
||||
assert_eq!(writes[2][9], 0x15);
|
||||
assert_eq!(writes[2][10], 0);
|
||||
assert_eq!(writes[3], [0x0e, new.id as u8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_whitelist_never_removes_existing_pairing() {
|
||||
let entries: Vec<_> = (1..=4)
|
||||
.map(|id| Device {
|
||||
id,
|
||||
kind: LEFT,
|
||||
corrupt: false,
|
||||
})
|
||||
.collect();
|
||||
let mut radio = Radio(Fake {
|
||||
reads: [list(0x11, &entries)].into(),
|
||||
writes: vec![],
|
||||
fail_write: false,
|
||||
});
|
||||
assert!(radio
|
||||
.pair(&Device {
|
||||
id: 5,
|
||||
kind: LEFT,
|
||||
corrupt: false
|
||||
})
|
||||
.unwrap_err()
|
||||
.contains("full"));
|
||||
assert!(radio.0.writes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_pair_still_disconnects_without_restarting_controller() {
|
||||
let new = Device {
|
||||
id: 42,
|
||||
kind: RIGHT,
|
||||
corrupt: false,
|
||||
};
|
||||
let mut radio = Radio(Fake {
|
||||
reads: [
|
||||
list(0x11, &[]),
|
||||
list(0x0c, &[new.clone()]),
|
||||
vec![11, 0, 0, 0, 0, 0, 0],
|
||||
vec![11, 1, 0, 0, 0, 0, 0],
|
||||
vec![11, 1, 0, 0, 0, 0, 0],
|
||||
vec![11, 2, 0, 1, 0, 0, 0],
|
||||
vec![11, 2, 0, 0, 0, 0, 0],
|
||||
vec![11, 3, 0, 0, 0, 0, 0],
|
||||
]
|
||||
.into(),
|
||||
writes: vec![],
|
||||
fail_write: false,
|
||||
});
|
||||
assert!(radio.pair(&new).unwrap_err().contains("rejected"));
|
||||
assert_eq!(
|
||||
radio
|
||||
.0
|
||||
.writes
|
||||
.iter()
|
||||
.map(|data| data[0])
|
||||
.collect::<Vec<_>>(),
|
||||
[0x0d, 0x0f, 0x0e]
|
||||
);
|
||||
assert!(radio.0.reads.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,53 @@ function KeyValues({ rows }: { rows: [string, string][] }) {
|
||||
|
||||
interface PreviewStatus { presented: number; width: number; height: number; error: string | null }
|
||||
|
||||
interface PairingStatus { stage: string; message: string; candidates: { id: string }[]; hand?: 'left' | 'right' }
|
||||
function ControllerPairing() {
|
||||
const [hand, setHand] = useState<'left' | 'right'>('left');
|
||||
const [status, setStatus] = useState<PairingStatus>({ stage: '', message: '', candidates: [] });
|
||||
const [error, setError] = useState('');
|
||||
const [pollError, setPollError] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
const active = ['searching', 'ready', 'pairing'].includes(status.stage);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function poll() {
|
||||
try {
|
||||
const next = await invoke<PairingStatus>('controller_pairing_status');
|
||||
if (!cancelled) {
|
||||
setStatus(next);
|
||||
if (['searching', 'ready', 'pairing'].includes(next.stage) && next.hand) setHand(next.hand);
|
||||
setPollError('');
|
||||
}
|
||||
} catch (failure) { if (!cancelled) setPollError(String(failure)); }
|
||||
if (!cancelled) timer = setTimeout(poll, 500);
|
||||
}
|
||||
void poll();
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, []);
|
||||
async function command(name: string, args?: Record<string, string>) {
|
||||
setPending(true); setError('');
|
||||
try {
|
||||
await invoke(name, args);
|
||||
setStatus(await invoke<PairingStatus>('controller_pairing_status'));
|
||||
} catch (failure) { setError(String(failure)); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
return <div className="sec pairing">
|
||||
<h2>Pair controller</h2>
|
||||
<p>Close SteamVR. Put a battery in the controller, then hold {hand === 'left' ? 'Menu + Y' : 'Oculus + B'} until its light flashes. Remove the old controller's battery when replacing it.</p>
|
||||
<div className="pairing-controls">
|
||||
<label>Hand <select aria-label="Controller hand" value={hand} disabled={active || pending} onChange={event => setHand(event.target.value as 'left' | 'right')}><option value="left">Left</option><option value="right">Right</option></select></label>
|
||||
<button className="btn" disabled={active || pending} onClick={() => void command('controller_pairing_start', { hand })}>Find controller</button>
|
||||
{active && status.stage !== 'pairing' && <button className="btn" disabled={pending} onClick={() => void command('controller_pairing_cancel')}>Cancel</button>}
|
||||
</div>
|
||||
{status.stage === 'ready' && status.candidates.map(candidate => <div className="pairing-candidate" key={candidate.id}><code>{candidate.id}</code><button className="btn sm" disabled={pending} onClick={() => void command('controller_pairing_select', { id: candidate.id })}>Pair {hand} controller</button></div>)}
|
||||
<output className="fb" role="status">{error || pollError || status.message}</output>
|
||||
<p className="pairing-note">Existing pairings stay saved. Firmware is never updated.</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// The headset view is drawn natively: a Wayland subsurface owned by the Rust
|
||||
// side covers the preview box and receives GPU frames at the headset refresh
|
||||
// rate. This component only reports where the box is and shows status text
|
||||
@@ -234,6 +281,7 @@ function App() {
|
||||
return <div key={name}><b>{name}</b><span>{device?.tracking ?? missing}</span>{i === 0 ? <span className="r">{device?.tracking ? 'Wired' : missing}</span> : <span className="bat">{battery != null && <i style={{ '--v': `${battery * 100}%` } as React.CSSProperties}/>} {format(battery == null ? null : battery * 100, '%')}</span>}</div>;
|
||||
})}
|
||||
</div></div>
|
||||
<ControllerPairing/>
|
||||
<div className="sec"><h2>Cameras</h2><div className="t cam">
|
||||
<div className="h"><span>Position</span><span>State</span><span className="r">Rate</span></div>
|
||||
{['Front left', 'Front right', 'Top', 'Left', 'Right'].map((name, i) => <div key={name}><b>{name}</b><span>{i < 2 && data.cameraExport ? data.cameraActive ? 'Active' : 'No frames' : missing}</span><span className="r">{format(i < 2 ? data.cameraRate : null, ' Hz', 1)}</span></div>)}
|
||||
|
||||
@@ -27,6 +27,13 @@ main{flex:1;min-height:0;padding:1.5rem 2rem;display:flex}
|
||||
|
||||
h2{margin:0 0 .75rem;font-size:.8125rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;line-height:2.25rem;display:flex;align-items:center;justify-content:space-between}
|
||||
.sec+.sec{margin-top:1.75rem}
|
||||
.pairing p{font-size:.8125rem;margin:0 0 .75rem}
|
||||
.pairing-controls,.pairing-candidate{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
|
||||
.pairing-controls label{display:flex;align-items:center;gap:.5rem;font-size:.8125rem}
|
||||
.pairing select{background:#000;border:1px solid var(--line);padding:.375rem}
|
||||
.pairing-candidate{justify-content:space-between;margin-top:.5rem}
|
||||
.pairing .pairing-note{margin:.5rem 0 0;color:var(--mut)}
|
||||
#headset>.col:last-child{overflow-y:auto}
|
||||
|
||||
/* controls */
|
||||
.btn{height:2.25rem;padding:0 1rem;border:0;border-radius:3px;background:var(--ctl);font-weight:600;font-size:.9rem;cursor:pointer;display:inline-flex;align-items:center;gap:.5rem;white-space:nowrap}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Keep the pre-setup calibration if SteamVR's compositor dies during native drawing.
|
||||
#include "openvr.h"
|
||||
#include "ovrd_roomsetup_floor.hpp"
|
||||
#include "ovrd_spawn_close.hpp"
|
||||
#include "../../drivers/rift_s/rift_s_world_control.h"
|
||||
#include <cjson/cJSON.h>
|
||||
#include <chrono>
|
||||
@@ -357,7 +358,7 @@ public:
|
||||
posix_spawn_file_actions_adddup2(&actions, server_child, 3);
|
||||
posix_spawn_file_actions_adddup2(&actions, monitor_child, 4);
|
||||
posix_spawn_file_actions_adddup2(&actions, compositor_child, 5);
|
||||
posix_spawn_file_actions_addclosefrom_np(&actions, 6);
|
||||
roomsetup_spawn_close_from(&actions, 6);
|
||||
int spawned = posix_spawn(&requester, executable, &actions, nullptr, arguments, environ);
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
close(server_child); close(monitor_child); close(compositor_child);
|
||||
@@ -460,7 +461,10 @@ static void log_coordinates(vr::IVRSystem &system, vr::IVRChaperoneSetup &setup,
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc == 2 && strcmp(argv[1], "--recover-display") == 0) return recover_display();
|
||||
bool recover = argc == 2 && strcmp(argv[1], "--recover-display") == 0;
|
||||
// Spawners pass descriptors 3 (guard) or 3-5 (display recovery); drop the rest.
|
||||
roomsetup_close_inherited(recover ? 6 : 4);
|
||||
if (recover) return recover_display();
|
||||
if (argc != 4) return 1;
|
||||
int parent = static_cast<int>(syscall(SYS_pidfd_open, std::atoi(argv[2]), 0));
|
||||
int ready = std::atoi(argv[3]);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// SPDX-License-Identifier: BSL-1.0
|
||||
#pragma once
|
||||
#ifdef __linux__
|
||||
#include "ovrd_spawn_close.hpp"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
@@ -64,7 +66,7 @@ public:
|
||||
posix_spawn_file_actions_adddup2(&actions, descriptors[1], child_acknowledgement);
|
||||
// SteamVR also owns IPC descriptors without CLOEXEC. The guard must not
|
||||
// retain those connections or shared buffers after a compositor exits.
|
||||
posix_spawn_file_actions_addclosefrom_np(&actions, child_acknowledgement + 1);
|
||||
roomsetup_spawn_close_from(&actions, child_acknowledgement + 1);
|
||||
char destination[] = "3";
|
||||
char *arguments[] = {executable.data(), library.data(), parent.data(), destination, nullptr};
|
||||
int error = posix_spawn(&pid, executable.c_str(), &actions, nullptr, arguments, environ);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026, lbgos
|
||||
// SPDX-License-Identifier: BSL-1.0
|
||||
/*!
|
||||
* @file
|
||||
* @brief Keep SteamVR's inherited descriptors out of spawned guard processes.
|
||||
*
|
||||
* The spawner calls roomsetup_spawn_close_from() on its spawn actions. glibc before
|
||||
* 2.34 (the Steam Runtime) has no close-from spawn action, so the guard also calls
|
||||
* roomsetup_close_inherited() first thing in main().
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <spawn.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
inline void
|
||||
roomsetup_spawn_close_from(posix_spawn_file_actions_t *actions, int first)
|
||||
{
|
||||
#if defined(__GLIBC__) && (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 34))
|
||||
posix_spawn_file_actions_addclosefrom_np(actions, first);
|
||||
#else
|
||||
(void)actions;
|
||||
(void)first;
|
||||
#endif
|
||||
}
|
||||
|
||||
inline void
|
||||
roomsetup_close_inherited(int first)
|
||||
{
|
||||
#ifdef SYS_close_range
|
||||
if (syscall(SYS_close_range, first, ~0U, 0) == 0) return;
|
||||
#endif
|
||||
// Kernels before 5.9.
|
||||
rlimit limit{};
|
||||
int last = getrlimit(RLIMIT_NOFILE, &limit) == 0 && limit.rlim_cur != RLIM_INFINITY ? int(limit.rlim_cur) : 65536;
|
||||
for (int descriptor = first; descriptor < last; ++descriptor) close(descriptor);
|
||||
}
|
||||
Reference in new issue
Block a user