diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 41aebbe..f947193 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "debian:unstable" +image: "ubuntu:24.04" stages: - check @@ -13,52 +13,6 @@ commitcheck: # only run for merge requests - if [ -z "$CI_MERGE_REQUEST_TITLTE" ]; then true; else python ./dist/tagging/check_conventional_commit.py "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"; fi -cargo:fmtcheck: - image: "rust:slim" - stage: check - script: - - rustup component add rustfmt - # Create blank versions of our configured files - # so rustfmt does not yell about non-existent files or completely empty files - - echo -e "" >> src/constants.rs - - rustc -Vv && cargo -Vv - - cargo fmt --version - - cargo fmt --all -- --check - -cargo:clippy: - stage: check - variables: - RUSTFLAGS: "-Dwarnings" - script: - - apt-get update - - apt-get install libgtk-4-dev libadwaita-1-dev libssl-dev libjxl-dev libvte-2.91-gtk4-dev meson ninja-build git desktop-file-utils gettext file libusb-dev libusb-1.0-0-dev libopenxr-dev curl -y - - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup.sh - - chmod +x /tmp/rustup.sh - - /tmp/rustup.sh -y - - source "$HOME/.cargo/env" - - rustup component add clippy - - rustc -Vv && cargo -Vv - - cp src/constants.rs.in src/constants.rs - - cargo clippy --version - - cargo clippy --all-targets --all-features - -cargo:test: - stage: check - script: - - apt-get update - - apt-get install libgtk-4-dev libadwaita-1-dev libssl-dev libjxl-dev libvte-2.91-gtk4-dev meson ninja-build git desktop-file-utils gettext file libusb-dev libusb-1.0-0-dev libopenxr-dev curl -y - - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup.sh - - chmod +x /tmp/rustup.sh - - /tmp/rustup.sh -y - - source "$HOME/.cargo/env" - - rustc --version && cargo --version # Print version info for debugging - - meson setup build -Dprefix="$PWD/build/localprefix" -Dprofile=development - - ninja -C build - - cargo test --workspace --verbose - cache: - paths: - - /var/cache/apt - appimage: stage: deploy script: @@ -68,6 +22,7 @@ appimage: - chmod +x /tmp/rustup.sh - /tmp/rustup.sh -y - source "$HOME/.cargo/env" + - rustup component add clippy - bash ./dist/appimage/build_appimage.sh artifacts: paths: diff --git a/Cargo.lock b/Cargo.lock index a7e323f..cb2d639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -420,6 +420,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -436,6 +445,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "delicious-adwaita" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53548c789a95211e0ce6d26c213067002b9b4360f8de69046d84de78ad9da3f" +dependencies = [ + "gtk4", + "libadwaita", +] + [[package]] name = "deranged" version = "0.3.11" @@ -554,10 +573,11 @@ dependencies = [ [[package]] name = "envision" -version = "1.1.1" +version = "3.1.1" dependencies = [ "anyhow", "ash", + "delicious-adwaita", "gettext-rs", "git2", "gtk4", @@ -574,8 +594,12 @@ dependencies = [ "rusb", "serde", "serde_json", + "serde_yaml", "sha2", "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", "tracker", "uuid", "vte4", @@ -1060,9 +1084,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.9.4" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9376d14d7e33486c54823a42bef296e882b9f25cb4c52b52f4d1d57bbadb5b6d" +checksum = "af1c491051f030994fd0cde6f3c44f3f5640210308cff1298c7673c47408091d" dependencies = [ "cairo-rs", "field-offset", @@ -1093,9 +1117,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.9.4" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e653b0a9001ba9be1ffddb9373bfe9a111f688222f5aeee2841481300d91b55a" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -1510,9 +1534,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libadwaita" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8611ee9fb85e7606c362b513afcaf5b59853f79e4d98caaaf581d99465014247" +checksum = "500135d29c16aabf67baafd3e7741d48e8b8978ca98bac39e589165c8dc78191" dependencies = [ "gdk4", "gio", @@ -1698,6 +1722,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1820,6 +1853,16 @@ dependencies = [ "zbus 4.4.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1945,6 +1988,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "pango" version = "0.20.6" @@ -2180,8 +2229,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -2192,9 +2250,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -2503,6 +2567,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2525,6 +2602,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2713,6 +2799,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.36" @@ -2720,10 +2816,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", "time-core", + "time-macros", ] [[package]] @@ -2732,6 +2830,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -2844,6 +2952,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.28" @@ -2862,6 +2982,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -2925,6 +3088,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -2964,6 +3133,12 @@ dependencies = [ "rand", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index ca66f0b..4ecd109 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,16 @@ [package] name = "envision" -version = "1.1.1" +version = "3.1.1" edition = "2021" +authors = [ + "Gabriele Musco ", +] +description = "Orchestrator for the free XR stack" +repository = "https://gitlab.com/gabmus/envision" +documentation = "https://gitlab.com/gabmus/envision" +license = "AGPL-3.0-or-later" +keywords = ["desktop", "linux", "vr", "xr", "gtk"] +readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -31,3 +40,8 @@ sha2 = "0.10.8" tokio = { version = "1.39.3", features = ["process"] } notify-rust = "4.11.3" zbus = { version = "5.1.1", features = ["tokio"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] } +tracing = "0.1.41" +tracing-appender = "0.2.3" +serde_yaml = "0.9.34" +delicious-adwaita = { version = "0.3.0", features = ["all_themes"] } diff --git a/README.md b/README.md index 05a865c..d9830f3 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ cd envision +# Debugging + +To view all the logs you need to run envision with the env var `RUST_LOG=trace`. + # Common issues ## NOSUID with systemd-homed diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 1c57821..76b4dd4 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -2,22 +2,174 @@ @APP_ID@ CC0 - AGPL-3.0 + AGPL-3.0-or-later @PRETTY_NAME@ - GUI for Monado + Orchestrator for the free XR stack -

GUI for Monado

+

Orchestrator for the free XR stack

- + + https://gitlab.com/gabmus/envision/raw/main/data/screenshots/02.png + Profile editor + + + https://gitlab.com/gabmus/envision/raw/main/data/screenshots/03.png + Profile running + + + https://gitlab.com/gabmus/envision/raw/main/data/screenshots/04.png + Profile running with debug view open + + @REPO_URL@ @REPO_URL@/issues + + +

Fixes

+
    +
  • add libusb and libusb-dev deps
  • +
  • Revert "disable and blacklist wayvr dashboard plugin"
  • +
+
+
+ + +

What's new

+
    +
  • don't set openvrpaths as read only during profile startup
  • +
  • small design changes to build window ui
  • +
  • add support for vapor openvr compatibility module
  • +
  • remove monado vulkan layers check for nvidia
  • +
+

Fixes

+
    +
  • disable and blacklist wayvr dashboard plugin
  • +
  • monado dependencies: use wayland-protocols-devel on Fedora
  • +
+

Other changes

+
    +
  • clippy
  • +
+
+
+ + +

Fixes

+
    +
  • libnotify headers path in wivrn depcheck
  • +
+
+
+ + +

Breaking changes

+
    +
  • plugin store
  • +
+

What's new

+
    +
  • Add WayVR Dashboard to the plugin list
  • +
  • wivrn: replace pulse dependency with pipewire
  • +
  • set wivrn launch options in the default profile
  • +
  • support for plugin dependencies and wayvr dashboards (using unreleased api)
  • +
  • launch options for plugins
  • +
  • homepage and author in plugin details
  • +
  • write rolling logs to file
  • +
  • add xrizer as an option for openvr compatibility module
  • +
  • switch wlx manifest
  • +
  • fetch plugins manifests online
  • +
  • add telescope to plugin store
  • +
  • ask to build profile after editing it
  • +
  • add cpu to debug info
  • +
  • make env var description selectable
  • +
  • press enter on env var entry to add
  • +
  • clearer messaging around setcap failures; getcap after setcap
  • +
  • version command line option
  • +
  • single stage ci with tests, clippy and fmt check all in one
  • +
  • use ubuntu for the ci
  • +
+

Fixes

+
    +
  • onnxruntime build error when latest release has no artifacts
  • +
  • typo in install wivrn box
  • +
  • typo in XRT_COMPOSITOR_SCALE_PERCENTAGE
  • +
  • add plugin to config via function instead of signal
  • +
  • refresh all rows on plugin install (fixes dependencies showing up as not installed)
  • +
  • always mark plugin executable as executable
  • +
  • wrap single plugin cmd parts in single quotes
  • +
  • use correct wayland-protocols package name for open suse
  • +
  • typo in plugin schema
  • +
  • remove canonicalize from get steamvr bin dir path function
  • +
  • actually return steamvr dir in get_steamvr_base_dir
  • +
  • canonicalize some steamvr related paths to hopefully resolve symlinks
  • +
  • get ovr compatibility module runtime dir from profile ovr compatibility module struct
  • +
  • use exists() to verify existance of socket file
  • +
  • correct wording of lighthouse calibration
  • +
  • get steamvr bin dir by parsing libraryfolders.vdf
  • +
  • switch to searching for the xml for deb based distros
  • +
  • Include not shared object wayland-protocols
  • +
  • add libbsd deps for monado
  • +
  • add wayland drm-lease protocols dep for monado
  • +
  • use boost dev packages
  • +
  • debian package name for gstreamer plugins base
  • +
  • print active runtime related informative logs as debug
  • +
+

Other changes

+
    +
  • plugins: point to stardust hosted manifest
  • +
  • cargo: revert back to serde_yaml over unsound advisory
  • +
+
+
+ + +

Fixes

+
    +
  • add screenshots to appdata
  • +
+
+
+ + +

Breaking changes

+
    +
  • enable support for different openvr compatibility modules other than opencomposite
  • +
+

What's new

+
    +
  • add metadata to Cargo.toml; get developers from Cargo.toml authors; rectify SPDX id for license as AGPL-3.0-or-later
  • +
  • refactor builders cmake vars and env to use inner blocks
  • +
  • disable wivrnctl; refactor cmake vars in wivrn builder
  • +
  • make left and right qwerty controllers appear as no controller detected
  • +
  • try to find libmonado and openxr shared objects by reading openxr config
  • +
  • prefer symlinks over generating files for openxr active runtime json file
  • +
  • move steam library folders parser to own module; function to find steam openxr json; format
  • +
  • proper logging framework
  • +
+

Fixes

+
    +
  • build profile can be specified manually
  • +
  • update wivrn libmonado path to wirvn/libmonado_wivrn.so
  • +
  • create openxr config dir when starting profile
  • +
  • add libnotify-dev dependency for wivrn
  • +
  • openssl dep is an include
  • +
  • add openssl-devel dep for wivrn
  • +
  • negative logic and early return in start xrservice func
  • +
  • use let err instead of match in restore xr files func
  • +
+

Other changes

+
    +
  • format
  • +
+
+

Fixes

diff --git a/data/screenshots/01.png b/data/screenshots/01.png new file mode 100644 index 0000000..7591b4b Binary files /dev/null and b/data/screenshots/01.png differ diff --git a/data/screenshots/02.png b/data/screenshots/02.png new file mode 100644 index 0000000..4074f24 Binary files /dev/null and b/data/screenshots/02.png differ diff --git a/data/screenshots/03.png b/data/screenshots/03.png new file mode 100644 index 0000000..d29fe47 Binary files /dev/null and b/data/screenshots/03.png differ diff --git a/data/screenshots/04.png b/data/screenshots/04.png new file mode 100644 index 0000000..e51e5d0 Binary files /dev/null and b/data/screenshots/04.png differ diff --git a/dist/appimage/build_appimage.sh b/dist/appimage/build_appimage.sh index 510cd7d..14dcbf7 100755 --- a/dist/appimage/build_appimage.sh +++ b/dist/appimage/build_appimage.sh @@ -8,6 +8,7 @@ if [[ ! -f Cargo.toml ]]; then fi meson setup appimage_build -Dprefix=/usr -Dprofile=default +meson test -C appimage_build --print-errorlogs DESTDIR="$PWD/AppDir" ninja -C appimage_build install curl -SsLO https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage chmod +x linuxdeploy-x86_64.AppImage diff --git a/dist/arch/PKGBUILD b/dist/arch/PKGBUILD index 3ff8a64..ee1a4b2 100644 --- a/dist/arch/PKGBUILD +++ b/dist/arch/PKGBUILD @@ -33,7 +33,6 @@ makedepends=( ) optdepends=( 'libudev0-shim: steamvr_lh lighthouse driver support' - 'monado-vulkan-layers-git: Vulkan layers for NVIDIA users' ) provides=(envision) conflicts=(envision) diff --git a/meson.build b/meson.build index d10129a..d58f4ed 100644 --- a/meson.build +++ b/meson.build @@ -1,9 +1,9 @@ project( 'envision', 'rust', - version: '1.1.1', # version number row + version: '3.1.1', # version number row meson_version: '>= 0.59', - license: 'AGPL-3.0', + license: 'AGPL-3.0-or-later', ) i18n = import('i18n') @@ -38,17 +38,30 @@ iconsdir = datadir / 'icons' podir = meson.project_source_root() / 'po' gettext_package = meson.project_name() -# are we building a tagged version? -if run_command('git', 'describe', '--tags', '--exact-match').returncode() != 0 - profile = 'Devel' - vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD', check: false).stdout().strip() - if vcs_tag == '' - version_suffix = '-devel' +opt_profile = get_option('profile') + +# if a profile isn't specified infer from git +if opt_profile == 'default' + # are we building a tagged version? + if run_command('git', 'describe', '--tags', '--exact-match').returncode() != 0 + profile = 'Devel' + vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD', check: false).stdout().strip() + if vcs_tag == '' + version_suffix = '-devel' + else + version_suffix = '-@0@'.format(vcs_tag) + endif + application_id = '@0@.@1@'.format(base_id, profile) else - version_suffix = '-@0@'.format(vcs_tag) + profile = '' + version_suffix = '' + application_id = base_id endif +elif opt_profile == 'development' + profile = 'Devel' + version_suffix = '-devel' application_id = '@0@.@1@'.format(base_id, profile) -else +elif opt_profile == 'release' profile = '' version_suffix = '' application_id = base_id diff --git a/meson_options.txt b/meson_options.txt index 7d397e1..aaebed1 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -3,6 +3,7 @@ option( type: 'combo', choices: [ 'default', + 'release', 'development' ], value: 'default', diff --git a/scripts/build_mercury.sh b/scripts/build_mercury.sh index fffd979..932d343 100755 --- a/scripts/build_mercury.sh +++ b/scripts/build_mercury.sh @@ -2,42 +2,6 @@ set -ev -PREFIX=$1 - -CACHE_DIR=$2 - -if [[ -z $PREFIX ]] || [[ -z $CACHE_DIR ]]; then - echo "Usage: $0 PREFIX CACHE_DIR" - exit 1 -fi - -ONNX_VER=$(curl -sSL "https://api.github.com/repos/microsoft/onnxruntime/releases/latest" | jq -r .tag_name | tr -d v) -SYS_ARCH=$(uname -m) - -if [[ $SYS_ARCH == x*64 ]]; then - ARCH="x64" -elif [[ $SYS_ARCH == arm64 ]] || [[ $ARCH == aarch64 ]]; then - ARCH="aarch64" -else - echo "CPU architecture '$SYS_ARCH' is not supported" - exit 1 -fi - -ONNX="onnxruntime-linux-${ARCH}-${ONNX_VER}" -ONNX_URL="https://github.com/microsoft/onnxruntime/releases/download/v${ONNX_VER}/${ONNX}.tgz" - -mkdir -p "$CACHE_DIR" - -curl -sSL "$ONNX_URL" -o "${CACHE_DIR}/onnxruntime.tgz" - -tar xf "${CACHE_DIR}/onnxruntime.tgz" --directory="${CACHE_DIR}" - -mkdir -p "${PREFIX}/lib" -mkdir -p "${PREFIX}/include" - -cp -r "${CACHE_DIR}/${ONNX}/include/"* "${PREFIX}/include/" -cp -r "${CACHE_DIR}/${ONNX}/lib/"* "${PREFIX}/lib/" - if [[ -z $XDG_DATA_HOME ]]; then DATA_HOME=$HOME/.local/share else diff --git a/src/build_tools/cmake.rs b/src/build_tools/cmake.rs index 5946ec6..ba9adbb 100644 --- a/src/build_tools/cmake.rs +++ b/src/build_tools/cmake.rs @@ -22,7 +22,7 @@ impl Cmake { if k.contains(' ') { panic!("Cmake vars cannot contain spaces!"); } - args.push(format!("-D{k}={v}", k = k, v = v)); + args.push(format!("-D{k}={v}")); } } args.push(self.source_dir.to_string_lossy().to_string()); diff --git a/src/builders/build_basalt.rs b/src/builders/build_basalt.rs index e67e081..291fdc0 100644 --- a/src/builders/build_basalt.rs +++ b/src/builders/build_basalt.rs @@ -5,7 +5,10 @@ use crate::{ ui::job_worker::job::WorkerJob, util::file_utils::rm_rf, }; -use std::collections::{HashMap, VecDeque}; +use std::{ + collections::{HashMap, VecDeque}, + num::NonZero, +}; pub fn get_build_basalt_jobs(profile: &Profile, clean_build: bool) -> VecDeque { let mut jobs = VecDeque::::new(); @@ -35,28 +38,52 @@ pub fn get_build_basalt_jobs(profile: &Profile, clean_build: bool) -> VecDeque = HashMap::new(); - cmake_vars.insert("CMAKE_EXPORT_COMPILE_COMMANDS".into(), "ON".into()); - cmake_vars.insert("CMAKE_BUILD_TYPE".into(), "RelWithDebInfo".into()); - cmake_vars.insert( - "CMAKE_INSTALL_PREFIX".into(), - profile.prefix.to_string_lossy().to_string(), - ); - cmake_vars.insert("BUILD_TESTS".into(), "OFF".into()); - cmake_vars.insert("BASALT_INSTANTIATIONS_DOUBLE".into(), "OFF".into()); - cmake_vars.insert( - "CMAKE_INSTALL_LIBDIR".into(), - profile.prefix.join("lib").to_string_lossy().to_string(), - ); - - let mut cmake_env: HashMap = HashMap::new(); - cmake_env.insert("CMAKE_BUILD_PARALLEL_LEVEL".into(), "2".into()); - cmake_env.insert("CMAKE_BUILD_TYPE".into(), "RelWithDebInfo".into()); - cmake_env.insert("BUILD_TESTS".into(), "off".into()); let cmake = Cmake { - env: Some(cmake_env), - vars: Some(cmake_vars), + env: Some({ + let mut cmake_env: HashMap = HashMap::new(); + for (k, v) in [ + // The basalt build uses a lot of RAM, so we have to limit the number of + // build processes to not starve the system of memory + // Limit to 6 build processes at most + ( + "CMAKE_BUILD_PARALLEL_LEVEL", + std::cmp::min( + 6, + std::thread::available_parallelism() + .map(NonZero::get) + .unwrap_or(2), + ) + .to_string(), + ), + ("CMAKE_BUILD_TYPE", "RelWithDebInfo".into()), + ("CMAKE_POLICY_VERSION_MINIMUM", "3.5".into()), + ("BUILD_TESTS", "off".into()), + ] { + cmake_env.insert(k.to_string(), v); + } + cmake_env + }), + vars: Some({ + let mut cmake_vars: HashMap = HashMap::new(); + for (k, v) in [ + ("CMAKE_EXPORT_COMPILE_COMMANDS", "ON"), + ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), + ("BUILD_TESTS", "OFF"), + ("BASALT_INSTANTIATIONS_DOUBLE", "OFF"), + ] { + cmake_vars.insert(k.to_string(), v.to_string()); + } + cmake_vars.insert( + "CMAKE_INSTALL_PREFIX".into(), + profile.prefix.to_string_lossy().to_string(), + ); + cmake_vars.insert( + "CMAKE_INSTALL_LIBDIR".into(), + profile.prefix.join("lib").to_string_lossy().to_string(), + ); + cmake_vars + }), source_dir: profile.features.basalt.path.as_ref().unwrap().clone(), build_dir: build_dir.clone(), }; diff --git a/src/builders/build_libsurvive.rs b/src/builders/build_libsurvive.rs index b4b0dc3..9f59353 100644 --- a/src/builders/build_libsurvive.rs +++ b/src/builders/build_libsurvive.rs @@ -44,24 +44,30 @@ pub fn get_build_libsurvive_jobs(profile: &Profile, clean_build: bool) -> VecDeq .as_ref() .unwrap() .join("build"); - let mut cmake_vars: HashMap = HashMap::new(); - cmake_vars.insert("CMAKE_EXPORT_COMPILE_COMMANDS".into(), "ON".into()); - cmake_vars.insert("CMAKE_BUILD_TYPE".into(), "RelWithDebInfo".into()); - cmake_vars.insert("ENABLE_api_example".into(), "OFF".into()); - cmake_vars.insert("USE_HIDAPI".into(), "ON".into()); - cmake_vars.insert("CMAKE_SKIP_INSTALL_RPATH".into(), "YES".into()); - cmake_vars.insert( - "CMAKE_INSTALL_PREFIX".into(), - profile.prefix.to_string_lossy().to_string(), - ); - cmake_vars.insert( - "CMAKE_INSTALL_LIBDIR".into(), - profile.prefix.join("lib").to_string_lossy().to_string(), - ); let cmake = Cmake { env: None, - vars: Some(cmake_vars), + vars: Some({ + let mut cmake_vars: HashMap = HashMap::new(); + for (k, v) in [ + ("CMAKE_EXPORT_COMPILE_COMMANDS", "ON"), + ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), + ("ENABLE_api_example", "OFF"), + ("USE_HIDAPI", "ON"), + ("CMAKE_SKIP_INSTALL_RPATH", "YES"), + ] { + cmake_vars.insert(k.to_string(), v.to_string()); + } + cmake_vars.insert( + "CMAKE_INSTALL_PREFIX".into(), + profile.prefix.to_string_lossy().to_string(), + ); + cmake_vars.insert( + "CMAKE_INSTALL_LIBDIR".into(), + profile.prefix.join("lib").to_string_lossy().to_string(), + ); + cmake_vars + }), source_dir: profile.features.libsurvive.path.as_ref().unwrap().clone(), build_dir: build_dir.clone(), }; diff --git a/src/builders/build_mercury.rs b/src/builders/build_mercury.rs index 36b5c23..28185d1 100644 --- a/src/builders/build_mercury.rs +++ b/src/builders/build_mercury.rs @@ -1,11 +1,8 @@ use std::collections::VecDeque; -use crate::{ - constants::pkg_data_dir, paths::get_cache_dir, profile::Profile, termcolor::TermColor, - ui::job_worker::job::WorkerJob, -}; +use crate::{constants::pkg_data_dir, termcolor::TermColor, ui::job_worker::job::WorkerJob}; -pub fn get_build_mercury_jobs(profile: &Profile) -> VecDeque { +pub fn get_build_mercury_jobs() -> VecDeque { let mut jobs = VecDeque::new(); jobs.push_back(WorkerJob::new_printer( "Building Mercury...", @@ -17,10 +14,7 @@ pub fn get_build_mercury_jobs(profile: &Profile) -> VecDeque { .join("scripts/build_mercury.sh") .to_string_lossy() .to_string(), - Some(vec![ - profile.prefix.to_string_lossy().to_string(), - get_cache_dir().to_string_lossy().to_string(), - ]), + None, )); jobs diff --git a/src/builders/build_monado.rs b/src/builders/build_monado.rs index f379d6f..8837874 100644 --- a/src/builders/build_monado.rs +++ b/src/builders/build_monado.rs @@ -43,37 +43,43 @@ pub fn get_build_monado_jobs(profile: &Profile, clean_build: bool) -> VecDeque = HashMap::new(); - cmake_vars.insert("CMAKE_EXPORT_COMPILE_COMMANDS".into(), "ON".into()); - cmake_vars.insert("CMAKE_BUILD_TYPE".into(), "RelWithDebInfo".into()); - cmake_vars.insert("XRT_HAVE_SYSTEM_CJSON".into(), "NO".into()); - cmake_vars.insert( - "CMAKE_LIBDIR".into(), - profile.prefix.join("lib").to_string_lossy().to_string(), - ); - cmake_vars.insert( - "CMAKE_INSTALL_PREFIX".into(), - profile.prefix.to_string_lossy().to_string(), - ); - cmake_vars.insert( - "CMAKE_C_FLAGS".into(), - format!("-Wl,-rpath='{}/lib'", profile.prefix.to_string_lossy(),), - ); - cmake_vars.insert( - "CMAKE_CXX_FLAGS".into(), - format!("-Wl,-rpath='{}/lib'", profile.prefix.to_string_lossy(),), - ); - profile.xrservice_cmake_flags.iter().for_each(|(k, v)| { - if k == "CMAKE_C_FLAGS" || k == "CMAKE_CXX_FLAGS" { - cmake_vars.insert(k.clone(), format!("{} {}", cmake_vars.get(k).unwrap(), v)); - } else { - cmake_vars.insert(k.clone(), v.clone()); - } - }); let cmake = Cmake { env: Some(env), - vars: Some(cmake_vars), + vars: Some({ + let mut cmake_vars: HashMap = HashMap::new(); + for (k, v) in [ + ("CMAKE_EXPORT_COMPILE_COMMANDS", "ON"), + ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), + ("XRT_HAVE_SYSTEM_CJSON", "NO"), + ] { + cmake_vars.insert(k.to_string(), v.to_string()); + } + cmake_vars.insert( + "CMAKE_LIBDIR".into(), + profile.prefix.join("lib").to_string_lossy().to_string(), + ); + cmake_vars.insert( + "CMAKE_INSTALL_PREFIX".into(), + profile.prefix.to_string_lossy().to_string(), + ); + cmake_vars.insert( + "CMAKE_C_FLAGS".into(), + format!("-Wl,-rpath='{}/lib'", profile.prefix.to_string_lossy(),), + ); + cmake_vars.insert( + "CMAKE_CXX_FLAGS".into(), + format!("-Wl,-rpath='{}/lib'", profile.prefix.to_string_lossy(),), + ); + profile.xrservice_cmake_flags.iter().for_each(|(k, v)| { + if k == "CMAKE_C_FLAGS" || k == "CMAKE_CXX_FLAGS" { + cmake_vars.insert(k.clone(), format!("{} {}", cmake_vars.get(k).unwrap(), v)); + } else { + cmake_vars.insert(k.clone(), v.clone()); + } + }); + cmake_vars + }), source_dir: profile.xrservice_path.clone(), build_dir: build_dir.clone(), }; diff --git a/src/builders/build_opencomposite.rs b/src/builders/build_opencomposite.rs index 631b69f..ce6e2da 100644 --- a/src/builders/build_opencomposite.rs +++ b/src/builders/build_opencomposite.rs @@ -19,13 +19,15 @@ pub fn get_build_opencomposite_jobs(profile: &Profile, clean_build: bool) -> Vec let git = Git { repo: profile - .opencomposite_repo + .ovr_comp + .repo .as_ref() .unwrap_or(&"https://gitlab.com/znixian/OpenOVR.git".into()) .clone(), - dir: profile.opencomposite_path.clone(), + dir: profile.ovr_comp.path.clone(), branch: profile - .opencomposite_branch + .ovr_comp + .branch .as_ref() .unwrap_or(&"openxr".into()) .clone(), @@ -33,14 +35,20 @@ pub fn get_build_opencomposite_jobs(profile: &Profile, clean_build: bool) -> Vec jobs.extend(git.get_pre_build_jobs(profile.pull_on_build)); - let build_dir = profile.opencomposite_path.join("build"); - let mut cmake_vars: HashMap = HashMap::new(); - cmake_vars.insert("CMAKE_EXPORT_COMPILE_COMMANDS".into(), "ON".into()); - cmake_vars.insert("CMAKE_BUILD_TYPE".into(), "RelWithDebInfo".into()); + let build_dir = profile.ovr_comp.path.join("build"); let cmake = Cmake { env: None, - vars: Some(cmake_vars), - source_dir: profile.opencomposite_path.clone(), + vars: Some({ + let mut cmake_vars: HashMap = HashMap::new(); + for (k, v) in [ + ("CMAKE_EXPORT_COMPILE_COMMANDS", "ON"), + ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), + ] { + cmake_vars.insert(k.to_string(), v.to_string()); + } + cmake_vars + }), + source_dir: profile.ovr_comp.path.clone(), build_dir: build_dir.clone(), }; if !Path::new(&build_dir).is_dir() || clean_build { diff --git a/src/builders/build_vapor.rs b/src/builders/build_vapor.rs new file mode 100644 index 0000000..5a05932 --- /dev/null +++ b/src/builders/build_vapor.rs @@ -0,0 +1,68 @@ +use crate::{ + build_tools::{cmake::Cmake, git::Git}, + profile::Profile, + termcolor::TermColor, + ui::job_worker::job::WorkerJob, + util::file_utils::rm_rf, +}; +use std::{ + collections::{HashMap, VecDeque}, + path::Path, +}; + +pub fn get_build_vapor_jobs(profile: &Profile, clean_build: bool) -> VecDeque { + let mut jobs = VecDeque::::new(); + jobs.push_back(WorkerJob::new_printer( + "Building VapoR...", + Some(TermColor::Blue), + )); + + let git = Git { + repo: profile + .ovr_comp + .repo + .as_ref() + .unwrap_or(&"https://github.com/micheal65536/VapoR.git".into()) + .clone(), + dir: profile.ovr_comp.path.clone(), + branch: profile + .ovr_comp + .branch + .as_ref() + .unwrap_or(&"master".into()) + .clone(), + }; + + jobs.extend(git.get_pre_build_jobs(profile.pull_on_build)); + + let build_dir = profile.ovr_comp.path.join("build"); + let install_dir = build_dir.join("install_pfx"); + let cmake = Cmake { + env: None, + vars: Some({ + let mut cmake_vars: HashMap = HashMap::new(); + for (k, v) in [ + ("VAPOR_LOG_SILENT", "ON"), + ("USE_SYSTEM_OPENXR", "OFF"), + ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), + ] { + cmake_vars.insert(k.to_string(), v.to_string()); + } + cmake_vars.insert( + "CMAKE_INSTALL_PREFIX".into(), + install_dir.to_string_lossy().to_string(), + ); + cmake_vars + }), + source_dir: profile.ovr_comp.path.clone(), + build_dir: build_dir.clone(), + }; + if !Path::new(&build_dir).is_dir() || clean_build { + rm_rf(&build_dir); + jobs.push_back(cmake.get_prepare_job()); + } + jobs.push_back(cmake.get_build_job()); + jobs.push_back(cmake.get_install_job()); + + jobs +} diff --git a/src/builders/build_wivrn.rs b/src/builders/build_wivrn.rs index f2a415d..cd9d757 100644 --- a/src/builders/build_wivrn.rs +++ b/src/builders/build_wivrn.rs @@ -34,23 +34,29 @@ pub fn get_build_wivrn_jobs(profile: &Profile, clean_build: bool) -> VecDeque = HashMap::new(); - cmake_vars.insert("CMAKE_EXPORT_COMPILE_COMMANDS".into(), "ON".into()); - cmake_vars.insert("CMAKE_BUILD_TYPE".into(), "RelWithDebInfo".into()); - cmake_vars.insert("XRT_HAVE_SYSTEM_CJSON".into(), "NO".into()); - cmake_vars.insert("WIVRN_BUILD_CLIENT".into(), "OFF".into()); - cmake_vars.insert( - "CMAKE_INSTALL_PREFIX".into(), - profile.prefix.to_string_lossy().to_string(), - ); - - profile.xrservice_cmake_flags.iter().for_each(|(k, v)| { - cmake_vars.insert(k.clone(), v.clone()); - }); let cmake = Cmake { env: None, - vars: Some(cmake_vars), + vars: Some({ + let mut cmake_vars: HashMap = HashMap::new(); + for (k, v) in [ + ("CMAKE_EXPORT_COMPILE_COMMANDS", "ON"), + ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), + ("XRT_HAVE_SYSTEM_CJSON", "NO"), + ("WIVRN_BUILD_CLIENT", "OFF"), + ("WIVRN_BUILD_WIVRNCTL", "OFF"), + ] { + cmake_vars.insert(k.to_string(), v.to_string()); + } + cmake_vars.insert( + "CMAKE_INSTALL_PREFIX".into(), + profile.prefix.to_string_lossy().to_string(), + ); + profile.xrservice_cmake_flags.iter().for_each(|(k, v)| { + cmake_vars.insert(k.clone(), v.clone()); + }); + cmake_vars + }), source_dir: profile.xrservice_path.clone(), build_dir: build_dir.clone(), }; diff --git a/src/builders/build_xrizer.rs b/src/builders/build_xrizer.rs new file mode 100644 index 0000000..6fc1b6b --- /dev/null +++ b/src/builders/build_xrizer.rs @@ -0,0 +1,50 @@ +use crate::{ + build_tools::git::Git, profile::Profile, termcolor::TermColor, ui::job_worker::job::WorkerJob, + util::file_utils::rm_rf, +}; +use std::{collections::VecDeque, path::Path}; + +pub fn get_build_xrizer_jobs(profile: &Profile, clean_build: bool) -> VecDeque { + let mut jobs = VecDeque::::new(); + jobs.push_back(WorkerJob::new_printer( + "Building xrizer...", + Some(TermColor::Blue), + )); + + let git = Git { + repo: profile + .ovr_comp + .repo + .as_ref() + .unwrap_or(&"https://github.com/Supreeeme/xrizer".into()) + .clone(), + dir: profile.ovr_comp.path.clone(), + branch: profile + .ovr_comp + .branch + .as_ref() + .unwrap_or(&"main".into()) + .clone(), + }; + + jobs.extend(git.get_pre_build_jobs(profile.pull_on_build)); + + let build_dir = profile.ovr_comp.path.join("target"); + if !Path::new(&build_dir).is_dir() || clean_build { + rm_rf(&build_dir); + } + + jobs.push_back(WorkerJob::new_cmd( + None, + "sh".into(), + Some(vec![ + "-c".into(), + format!( + "cd '{}' && cargo xbuild --release", + profile.ovr_comp.path.to_string_lossy() + ), + ]), + )); + + jobs +} diff --git a/src/builders/mod.rs b/src/builders/mod.rs index c84be3b..9d57245 100644 --- a/src/builders/mod.rs +++ b/src/builders/mod.rs @@ -4,4 +4,6 @@ pub mod build_mercury; pub mod build_monado; pub mod build_opencomposite; pub mod build_openhmd; +pub mod build_vapor; pub mod build_wivrn; +pub mod build_xrizer; diff --git a/src/config.rs b/src/config.rs index 1ee2430..80cf0f5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,21 +7,48 @@ use crate::{ lighthouse::lighthouse_profile, openhmd::openhmd_profile, simulated::simulated_profile, survive::survive_profile, wivrn::wivrn_profile, wmr::wmr_profile, }, + ui::plugins::Plugin, util::file_utils::get_writer, }; use serde::{de::Error, Deserialize, Serialize}; use std::{ + collections::HashMap, fs::File, io::BufReader, path::{Path, PathBuf}, }; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginConfig { + pub plugin: Plugin, + pub enabled: bool, +} + +impl From<&Plugin> for PluginConfig { + fn from(p: &Plugin) -> Self { + Self { + plugin: p.clone(), + enabled: true, + } + } +} + +impl From<&PluginConfig> for Plugin { + fn from(cp: &PluginConfig) -> Self { + cp.plugin.clone() + } +} + const DEFAULT_WIN_SIZE: [i32; 2] = [360, 400]; const fn default_win_size() -> [i32; 2] { DEFAULT_WIN_SIZE } +fn default_theme_name() -> String { + "Follow system".into() +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Config { pub selected_profile_uuid: String, @@ -29,6 +56,10 @@ pub struct Config { pub user_profiles: Vec, #[serde(default = "default_win_size")] pub win_size: [i32; 2], + #[serde(default)] + pub plugins: HashMap, + #[serde(default = "default_theme_name")] + pub theme_name: String, } impl Default for Config { @@ -37,8 +68,10 @@ impl Default for Config { // TODO: using an empty string here is ugly selected_profile_uuid: "".to_string(), debug_view_enabled: false, - user_profiles: vec![], + user_profiles: Vec::default(), win_size: DEFAULT_WIN_SIZE, + plugins: HashMap::default(), + theme_name: default_theme_name(), } } } @@ -65,10 +98,42 @@ impl Config { } fn from_path(path: &Path) -> Self { - File::open(path) + let mut this: Self = File::open(path) .ok() .and_then(|file| serde_json::from_reader(BufReader::new(file)).ok()) - .unwrap_or_default() + .unwrap_or_default(); + + let mut needs_save = false; + + // remap legacy opencomposite data to new ovr_comp + #[allow(deprecated)] + for prof in this.user_profiles.iter_mut() { + if prof + .ovr_comp + .path + .file_name() + .unwrap_or_default() + .to_string_lossy() + == "__envision__fallbackovrcomp" + { + prof.ovr_comp.path = prof.opencomposite_path.clone(); + needs_save = true; + } + if prof.opencomposite_repo.is_some() && prof.ovr_comp.repo.is_none() { + prof.ovr_comp.repo = prof.opencomposite_repo.take(); + needs_save = true; + } + if prof.opencomposite_branch.is_some() && prof.ovr_comp.branch.is_none() { + prof.ovr_comp.branch = prof.opencomposite_branch.take(); + needs_save = true; + } + } + + if needs_save { + this.save_to_path(path).expect("Failed to save config"); + } + + this } fn save_to_path(&self, path: &Path) -> Result<(), serde_json::Error> { diff --git a/src/constants.rs.in b/src/constants.rs.in index 0c71035..db00776 100644 --- a/src/constants.rs.in +++ b/src/constants.rs.in @@ -16,10 +16,6 @@ pub const LOCALE_DIR: &str = "@LOCALEDIR@"; pub const BUILD_PROFILE: &str = "@PROFILE@"; pub const BUILD_DATETIME: &str = "@BUILD_DATETIME@"; -pub fn get_developers() -> Vec { - vec!["Gabriele Musco ".into()] -} - pub fn get_artists() -> Vec { vec!["App Icon: Yannick (@Yandr)".into()] } diff --git a/src/depcheck/basalt_deps.rs b/src/depcheck/basalt_deps.rs index 2a05826..00a29b6 100644 --- a/src/depcheck/basalt_deps.rs +++ b/src/depcheck/basalt_deps.rs @@ -1,7 +1,7 @@ use super::{ boost_deps::boost_deps, common::{dep_cmake, dep_eigen, dep_gpp, dep_libgl, dep_ninja, dep_opencv}, - DepType, Dependency, DependencyCheckResult, + DepType, DepcheckResultGetMissing, Dependency, DependencyCheckResult, }; use crate::linux_distro::LinuxDistro; use std::collections::HashMap; @@ -181,9 +181,5 @@ pub fn check_basalt_deps() -> Vec { } pub fn get_missing_basalt_deps() -> Vec { - check_basalt_deps() - .iter() - .filter(|res| !res.found) - .map(|res| res.dependency.clone()) - .collect() + check_basalt_deps().filter_missing_deps() } diff --git a/src/depcheck/boost_deps.rs b/src/depcheck/boost_deps.rs index 6db64cf..38df244 100644 --- a/src/depcheck/boost_deps.rs +++ b/src/depcheck/boost_deps.rs @@ -52,8 +52,8 @@ pub fn boost_deps() -> Vec { packages: HashMap::from([ (LinuxDistro::Arch, "boost".into()), (LinuxDistro::Debian, "libboost-all-dev".into()), - (LinuxDistro::Fedora, "boost".into()), - (LinuxDistro::Alpine, "boost".into()), + (LinuxDistro::Fedora, "boost-devel".into()), + (LinuxDistro::Alpine, "boost-dev".into()), (LinuxDistro::Gentoo, "dev-libs/boost".into()), (LinuxDistro::Suse, package.into()), ]), diff --git a/src/depcheck/common.rs b/src/depcheck/common.rs index 56ac4ef..901d0ff 100644 --- a/src/depcheck/common.rs +++ b/src/depcheck/common.rs @@ -303,3 +303,35 @@ pub fn dep_adb() -> Dependency { ]), } } + +pub fn dep_getcap_setcap() -> Dependency { + Dependency { + name: "libcap".into(), + dep_type: DepType::Executable, + filename: "setcap".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "libcap".into()), + (LinuxDistro::Debian, "libcap2-bin".into()), + (LinuxDistro::Fedora, "libcap".into()), + (LinuxDistro::Alpine, "libcap".into()), + (LinuxDistro::Gentoo, "sys-libs/libcap".into()), + (LinuxDistro::Suse, "libcap-progs".into()), + ]), + } +} + +pub fn dep_glslc() -> Dependency { + Dependency { + name: "glslc".into(), + dep_type: DepType::Executable, + filename: "glslc".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "shaderc".into()), + (LinuxDistro::Debian, "glslc".into()), + (LinuxDistro::Fedora, "glslc".into()), + (LinuxDistro::Alpine, "shaderc".into()), + (LinuxDistro::Gentoo, "media-libs/shaderc".into()), + (LinuxDistro::Suse, "shaderc".into()), + ]), + } +} diff --git a/src/depcheck/libsurvive_deps.rs b/src/depcheck/libsurvive_deps.rs index c9e8d9c..46eec55 100644 --- a/src/depcheck/libsurvive_deps.rs +++ b/src/depcheck/libsurvive_deps.rs @@ -1,6 +1,6 @@ use super::{ common::{dep_cmake, dep_eigen, dep_gcc, dep_git, dep_gpp, dep_ninja}, - Dependency, DependencyCheckResult, + DepcheckResultGetMissing, Dependency, DependencyCheckResult, }; fn libsurvive_deps() -> Vec { @@ -19,9 +19,5 @@ pub fn check_libsurvive_deps() -> Vec { } pub fn get_missing_libsurvive_deps() -> Vec { - check_libsurvive_deps() - .iter() - .filter(|res| !res.found) - .map(|res| res.dependency.clone()) - .collect() + check_libsurvive_deps().filter_missing_deps() } diff --git a/src/depcheck/mercury_deps.rs b/src/depcheck/mercury_deps.rs index cad3c80..d8c61b5 100644 --- a/src/depcheck/mercury_deps.rs +++ b/src/depcheck/mercury_deps.rs @@ -1,10 +1,27 @@ -use super::{common::dep_opencv, DepType, Dependency, DependencyCheckResult}; +use super::{ + common::dep_opencv, DepType, DepcheckResultGetMissing, Dependency, DependencyCheckResult, +}; use crate::linux_distro::LinuxDistro; use std::collections::HashMap; fn mercury_deps() -> Vec { vec![ dep_opencv(), + Dependency { + name: "onnxruntime-dev".into(), + dep_type: DepType::Include, + filename: "onnxruntime/onnxruntime_c_api.h".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "onnxruntime".into()), + (LinuxDistro::Debian, "libonnxruntime-dev".into()), + (LinuxDistro::Fedora, "onnxruntime-devel".into()), + // alpine doesn't seem to have the package + // (LinuxDistro::Alpine, "".into()), + (LinuxDistro::Gentoo, "sci-ml/onnx".into()), + // opensuse doesn't seem to have the package + // (LinuxDistro::Suse, "".into()), + ]), + }, Dependency { name: "jq".into(), dep_type: DepType::Executable, @@ -39,9 +56,5 @@ pub fn check_mercury_deps() -> Vec { } pub fn get_missing_mercury_deps() -> Vec { - check_mercury_deps() - .iter() - .filter(|res| !res.found) - .map(|res| res.dependency.clone()) - .collect() + check_mercury_deps().filter_missing_deps() } diff --git a/src/depcheck/mod.rs b/src/depcheck/mod.rs index 54da91d..f2565eb 100644 --- a/src/depcheck/mod.rs +++ b/src/depcheck/mod.rs @@ -6,6 +6,7 @@ pub mod mercury_deps; pub mod monado_deps; pub mod openhmd_deps; pub mod wivrn_deps; +pub mod xrizer_deps; use crate::linux_distro::LinuxDistro; use std::{collections::HashMap, env, fmt::Display, path::Path}; @@ -16,6 +17,7 @@ pub enum DepType { Executable, Include, UdevRule, + Share, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -49,6 +51,7 @@ impl Dependency { .collect(), DepType::Include => include_paths(), DepType::UdevRule => udev_rules_paths(), + DepType::Share => share_paths(), } { let path_s = &format!("{dir}/{fname}", dir = dir, fname = self.filename); let path = Path::new(&path_s); @@ -106,6 +109,24 @@ impl Display for DependencyCheckResult { } } +pub trait DepcheckResultGetMissing { + fn filter_missing_deps(self) -> Vec; +} + +impl DepcheckResultGetMissing for Vec { + fn filter_missing_deps(self) -> Vec { + self.into_iter() + .filter_map(|res| { + if !res.found { + Some(res.dependency) + } else { + None + } + }) + .collect() + } +} + fn shared_obj_paths() -> Vec { vec![ "/lib".into(), @@ -115,6 +136,24 @@ fn shared_obj_paths() -> Vec { "/usr/local/lib64".into(), "/usr/lib/x86_64-linux-gnu".into(), "/usr/lib/aarch64-linux-gnu".into(), + // Debian puts libclang in /usr/lib/llvm-[llvm major version]/lib. + "/usr/lib/llvm-15/lib".into(), + "/usr/lib/llvm-16/lib".into(), + "/usr/lib/llvm-19/lib".into(), + // Fedora puts libclang in /usr/lib64/llvm[llvm major version]/lib as well as /usr/lib64. + "/usr/lib64/llvm15/lib".into(), + "/usr/lib64/llvm16/lib".into(), + "/usr/lib64/llvm17/lib".into(), + "/usr/lib64/llvm18/lib".into(), + "/usr/lib64/llvm19/lib".into(), + "/usr/lib64/llvm20/lib".into(), + // Gentoo puts libclang in /usr/lib/llvm/[llvm major version]/lib64. + "/usr/lib/llvm/15/lib64".into(), + "/usr/lib/llvm/16/lib64".into(), + "/usr/lib/llvm/17/lib64".into(), + "/usr/lib/llvm/18/lib64".into(), + "/usr/lib/llvm/19/lib64".into(), + "/usr/lib/llvm/20/lib64".into(), "/lib/x86_64-linux-gnu".into(), "/lib/aarch64-linux-gnu".into(), "/app/lib".into(), @@ -138,6 +177,8 @@ fn include_paths() -> Vec { "/usr/include/ffmpeg/libpostproc".into(), "/usr/include/ffmpeg/libswresample".into(), "/usr/include/ffmpeg/libswscale".into(), + // opensuse puts wayland-client.h here + "/usr/include/wayland".into(), ] } @@ -145,6 +186,10 @@ fn udev_rules_paths() -> Vec { vec!["/usr/lib/udev/rules.d".into()] } +fn share_paths() -> Vec { + vec!["/usr/share".into()] +} + #[cfg(test)] mod tests { use super::{DepType, Dependency}; diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index f92ffd4..f9e1b9b 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -4,9 +4,12 @@ use super::{ dep_libgl, dep_libudev, dep_libx11, dep_libxcb, dep_ninja, dep_openxr, dep_vulkan_headers, dep_vulkan_icd_loader, }, - DepType, Dependency, DependencyCheckResult, + DepType, DepcheckResultGetMissing, Dependency, DependencyCheckResult, +}; +use crate::{ + depcheck::common::{dep_glslc, dep_libxrandr}, + linux_distro::LinuxDistro, }; -use crate::{depcheck::common::dep_libxrandr, linux_distro::LinuxDistro}; use std::collections::HashMap; fn monado_deps() -> Vec { @@ -30,25 +33,37 @@ fn monado_deps() -> Vec { (LinuxDistro::Suse, "wayland-devel".into()), ]), }, + Dependency { + name: "wayland-protocols".into(), + dep_type: DepType::Share, + filename: "wayland-protocols/staging/drm-lease/drm-lease-v1.xml".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "wayland-protocols".into()), + (LinuxDistro::Debian, "wayland-protocols".into()), + (LinuxDistro::Fedora, "wayland-protocols-devel".into()), + (LinuxDistro::Gentoo, "dev-libs/wayland-protocols".into()), + (LinuxDistro::Suse, "wayland-protocols-devel".into()), + ]), + }, + Dependency { + name: "libbsd".into(), + dep_type: DepType::SharedObject, + filename: "libbsd.so".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "libbsd".into()), + (LinuxDistro::Debian, "libbsd-dev".into()), + (LinuxDistro::Fedora, "libbsd-devel".into()), + (LinuxDistro::Gentoo, "dev-libs/libbsd".into()), + (LinuxDistro::Suse, "libbsd-devel".into()), + ]), + }, dep_cmake(), dep_eigen(), dep_git(), dep_ninja(), dep_gcc(), dep_gpp(), - Dependency { - name: "glslc".into(), - dep_type: DepType::Executable, - filename: "glslc".into(), - packages: HashMap::from([ - (LinuxDistro::Arch, "shaderc".into()), - (LinuxDistro::Debian, "glslc".into()), - (LinuxDistro::Fedora, "glslc".into()), - (LinuxDistro::Alpine, "shaderc".into()), - (LinuxDistro::Gentoo, "media-libs/shaderc".into()), - (LinuxDistro::Suse, "shaderc".into()), - ]), - }, + dep_glslc(), dep_glslang_validator(), Dependency { name: "sdl2".into(), @@ -59,10 +74,34 @@ fn monado_deps() -> Vec { (LinuxDistro::Debian, "libsdl2-dev".into()), (LinuxDistro::Fedora, "SDL2-devel".into()), (LinuxDistro::Gentoo, "media-libs/libsdl2".into()), - (LinuxDistro::Suse, "SDL2-devel".into()), + (LinuxDistro::Suse, "sdl2-compat-devel".into()), ]), }, dep_libudev(), + Dependency { + name: "libusb".into(), + dep_type: DepType::SharedObject, + filename: "libusb-1.0.so".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "libusb".into()), + (LinuxDistro::Debian, "libusb-1.0-0".into()), + (LinuxDistro::Fedora, "libusb1".into()), + (LinuxDistro::Gentoo, "dev-libs/libusb".into()), + (LinuxDistro::Suse, "libusb-1_0-0".into()), + ]), + }, + Dependency { + name: "libusb-dev".into(), + dep_type: DepType::Include, + filename: "libusb-1.0/libusb.h".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "libusb".into()), + (LinuxDistro::Debian, "libusb-1.0-0-dev".into()), + (LinuxDistro::Fedora, "libusb1-devel".into()), + (LinuxDistro::Gentoo, "dev-libs/libusb".into()), + (LinuxDistro::Suse, "libusb-1_0-devel".into()), + ]), + }, Dependency { name: "mesa-common-dev".into(), dep_type: DepType::Include, @@ -83,9 +122,5 @@ pub fn check_monado_deps() -> Vec { } pub fn get_missing_monado_deps() -> Vec { - check_monado_deps() - .iter() - .filter(|res| !res.found) - .map(|res| res.dependency.clone()) - .collect() + check_monado_deps().filter_missing_deps() } diff --git a/src/depcheck/openhmd_deps.rs b/src/depcheck/openhmd_deps.rs index 819324f..d31d013 100644 --- a/src/depcheck/openhmd_deps.rs +++ b/src/depcheck/openhmd_deps.rs @@ -1,6 +1,6 @@ use super::{ common::{dep_gcc, dep_git, dep_gpp, dep_ninja}, - Dependency, DependencyCheckResult, + DepcheckResultGetMissing, Dependency, DependencyCheckResult, }; use crate::linux_distro::LinuxDistro; use std::collections::HashMap; @@ -31,9 +31,5 @@ pub fn check_openhmd_deps() -> Vec { } pub fn get_missing_openhmd_deps() -> Vec { - check_openhmd_deps() - .iter() - .filter(|res| !res.found) - .map(|res| res.dependency.clone()) - .collect() + check_openhmd_deps().filter_missing_deps() } diff --git a/src/depcheck/wivrn_deps.rs b/src/depcheck/wivrn_deps.rs index ee430cb..9126272 100644 --- a/src/depcheck/wivrn_deps.rs +++ b/src/depcheck/wivrn_deps.rs @@ -4,7 +4,7 @@ use super::{ dep_libudev, dep_libx11, dep_libxcb, dep_ninja, dep_openxr, dep_vulkan_headers, dep_vulkan_icd_loader, }, - DepType, Dependency, DependencyCheckResult, + DepType, DepcheckResultGetMissing, Dependency, DependencyCheckResult, }; use crate::{ depcheck::common::{dep_libgl, dep_libxrandr}, @@ -78,15 +78,15 @@ fn wivrn_deps() -> Vec { ]), }, Dependency { - name: "libpulse-dev".into(), + name: "libpipewire-dev".into(), dep_type: DepType::Include, - filename: "pulse/context.h".into(), + filename: "pipewire-0.3/pipewire/pipewire.h".into(), packages: HashMap::from([ - (LinuxDistro::Arch, "libpulse".into()), - (LinuxDistro::Debian, "libpulse-dev".into()), - (LinuxDistro::Fedora, "pulseaudio-libs-devel".into()), - (LinuxDistro::Gentoo, "media-libs/libpulse".into()), - (LinuxDistro::Suse, "libpulse-devel".into()), + (LinuxDistro::Arch, "libpipewire".into()), + (LinuxDistro::Debian, "libpipewire-0.3-dev".into()), + (LinuxDistro::Fedora, "pipewire-devel".into()), + (LinuxDistro::Gentoo, "media-video/pipewire".into()), + (LinuxDistro::Suse, "pipewire-devel".into()), ]), }, dep_eigen(), @@ -169,7 +169,10 @@ fn wivrn_deps() -> Vec { filename: "pkgconfig/gstreamer-app-1.0.pc".into(), packages: HashMap::from([ (LinuxDistro::Arch, "gst-plugins-base-libs".into()), - (LinuxDistro::Debian, "libgstreamer1.0-dev".into()), + ( + LinuxDistro::Debian, + "libgstreamer-plugins-base1.0-dev".into(), + ), (LinuxDistro::Fedora, "gstreamer1-plugins-base-devel".into()), (LinuxDistro::Gentoo, "media-libs/gst-plugins-base".into()), (LinuxDistro::Suse, "gstreamer-plugins-base-devel".into()), @@ -235,6 +238,30 @@ fn wivrn_deps() -> Vec { (LinuxDistro::Suse, "glib2-devel".into()), ]), }, + Dependency { + name: "openssl-dev".into(), + dep_type: DepType::Include, + filename: "openssl/ssl3.h".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "openssl".into()), + (LinuxDistro::Alpine, "openssl-dev".into()), + (LinuxDistro::Debian, "libssl-dev".into()), + (LinuxDistro::Fedora, "openssl-devel".into()), + (LinuxDistro::Suse, "openssl-devel".into()), + ]), + }, + Dependency { + name: "libnotify-dev".into(), + dep_type: DepType::Include, + filename: "libnotify/notify.h".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "libnotify".into()), + (LinuxDistro::Alpine, "libnotify-dev".into()), + (LinuxDistro::Debian, "libnotify-dev".into()), + (LinuxDistro::Fedora, "libnotify-devel".into()), + (LinuxDistro::Suse, "libnotify-devel".into()), + ]), + }, ] } @@ -243,9 +270,5 @@ pub fn check_wivrn_deps() -> Vec { } pub fn get_missing_wivrn_deps() -> Vec { - check_wivrn_deps() - .iter() - .filter(|res| !res.found) - .map(|res| res.dependency.clone()) - .collect() + check_wivrn_deps().filter_missing_deps() } diff --git a/src/depcheck/xrizer_deps.rs b/src/depcheck/xrizer_deps.rs new file mode 100644 index 0000000..49d9354 --- /dev/null +++ b/src/depcheck/xrizer_deps.rs @@ -0,0 +1,65 @@ +use super::{DepType, DepcheckResultGetMissing, Dependency, DependencyCheckResult}; +use crate::{depcheck::common::dep_glslc, linux_distro::LinuxDistro}; +use std::collections::HashMap; + +fn xrizer_deps() -> Vec { + vec![ + dep_glslc(), + Dependency { + name: "cargo".into(), + dep_type: DepType::Executable, + filename: "cargo".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "rust".into()), + (LinuxDistro::Debian, "cargo".into()), + (LinuxDistro::Fedora, "cargo".into()), + (LinuxDistro::Alpine, "cargo".into()), + (LinuxDistro::Suse, "cargo".into()), + ]), + }, + Dependency { + name: "libxcb-glx".into(), + dep_type: DepType::Include, + filename: "xcb/glx.h".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "libxcb".into()), + (LinuxDistro::Debian, "libxcb-glx0-dev".into()), + (LinuxDistro::Fedora, "libxcb-devel".into()), + (LinuxDistro::Gentoo, "x11-libs/libxcb".into()), + (LinuxDistro::Suse, "libxcb-devel".into()), + ]), + }, + Dependency { + name: "libclang".into(), + dep_type: DepType::SharedObject, + filename: "libclang.so".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "clang".into()), + (LinuxDistro::Debian, "libclang-19-dev".into()), + (LinuxDistro::Fedora, "clang19-devel".into()), + (LinuxDistro::Gentoo, "llvm-core/clang-runtime".into()), + (LinuxDistro::Suse, "clang19-devel".into()), + ]), + }, + Dependency { + name: "wayland-dev".into(), + dep_type: DepType::Include, + filename: "wayland-client.h".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "wayland".into()), + (LinuxDistro::Debian, "libwayland-dev".into()), + (LinuxDistro::Fedora, "wayland-devel".into()), + (LinuxDistro::Gentoo, "dev-libs/wayland".into()), + (LinuxDistro::Suse, "wayland-devel".into()), + ]), + }, + ] +} + +pub fn check_xrizer_deps() -> Vec { + Dependency::check_many(xrizer_deps()) +} + +pub fn get_missing_xrizer_deps() -> Vec { + check_xrizer_deps().filter_missing_deps() +} diff --git a/src/downloader.rs b/src/downloader.rs index bb9a4b7..3b1b16b 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -13,7 +13,7 @@ const CHUNK_SIZE: usize = 1024; fn headers() -> HeaderMap { let mut headers = HeaderMap::new(); - headers.insert(USER_AGENT, format!("{}/1.0", APP_ID).parse().unwrap()); + headers.insert(USER_AGENT, format!("{APP_ID}/1.0").parse().unwrap()); headers } diff --git a/src/env_var_descriptions.rs b/src/env_var_descriptions.rs index 34e646d..84a404b 100644 --- a/src/env_var_descriptions.rs +++ b/src/env_var_descriptions.rs @@ -3,7 +3,7 @@ use lazy_static::lazy_static; fn env_var_descriptions() -> Vec<(&'static str, &'static str)> { vec![ ( - "XRT_COMPOSITOR_SCALE_PECENTAGE", + "XRT_COMPOSITOR_SCALE_PERCENTAGE", "Render resolution percentage. A percentage higher than the native resolution (>100) will help with antialiasing and image clarity." ), ( @@ -65,7 +65,7 @@ fn env_var_descriptions() -> Vec<(&'static str, &'static str)> { fn env_var_descriptions_as_paragraph() -> String { ENV_VAR_DESCRIPTIONS .iter() - .map(|(k, v)| format!("{}\n{}", k, v)) + .map(|(k, v)| format!("{k}\n{v}")) .collect::>() .join("\n\n") } diff --git a/src/file_builders/active_runtime_json.rs b/src/file_builders/active_runtime_json.rs index 3f5e73f..e637385 100644 --- a/src/file_builders/active_runtime_json.rs +++ b/src/file_builders/active_runtime_json.rs @@ -1,14 +1,17 @@ use crate::{ - paths::{get_backup_dir, SYSTEM_PREFIX}, + paths::SYSTEM_PREFIX, profile::Profile, - util::file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly}, + util::file_utils::{deserialize_file, get_writer, set_file_readonly}, xdg::XDG, }; +use anyhow::bail; use serde::{Deserialize, Serialize}; use std::{ - fs::remove_file, + fs::{create_dir_all, remove_file, rename}, + os::unix::fs::symlink, path::{Path, PathBuf}, }; +use tracing::{debug, warn}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActiveRuntimeInnerRuntime { @@ -34,29 +37,6 @@ fn get_active_runtime_json_path() -> PathBuf { get_openxr_conf_dir().join("1/active_runtime.json") } -pub fn is_steam(active_runtime: &ActiveRuntime) -> bool { - matches!(active_runtime.runtime.valve_runtime_is_steamvr, Some(true)) -} - -fn get_backup_steam_active_runtime_path() -> PathBuf { - get_backup_dir().join("active_runtime.json.steam.bak") -} - -fn get_backed_up_steam_active_runtime() -> Option { - get_active_runtime_from_path(&get_backup_steam_active_runtime_path()) -} - -fn backup_steam_active_runtime() { - if let Some(ar) = get_current_active_runtime() { - if is_steam(&ar) { - copy_file( - &get_active_runtime_json_path(), - &get_backup_steam_active_runtime_path(), - ); - } - } -} - fn get_active_runtime_from_path(path: &Path) -> Option { deserialize_file(path) } @@ -78,29 +58,6 @@ pub fn dump_current_active_runtime(active_runtime: &ActiveRuntime) -> anyhow::Re dump_active_runtime_to_path(active_runtime, &get_active_runtime_json_path()) } -fn build_steam_active_runtime() -> ActiveRuntime { - if let Some(backup) = get_backed_up_steam_active_runtime() { - return backup; - } - ActiveRuntime { - file_format_version: "1.0.0".into(), - runtime: ActiveRuntimeInnerRuntime { - valve_runtime_is_steamvr: Some(true), - libmonado_path: None, - library_path: XDG - .get_data_home() - .join("Steam/steamapps/common/SteamVR/bin/linux64/vrclient.so"), - name: Some("SteamVR".into()), - }, - } -} - -pub fn set_current_active_runtime_to_steam() -> anyhow::Result<()> { - set_file_readonly(&get_active_runtime_json_path(), false)?; - dump_current_active_runtime(&build_steam_active_runtime())?; - Ok(()) -} - pub fn build_profile_active_runtime(profile: &Profile) -> anyhow::Result { let Some(libopenxr_path) = profile.libopenxr_so() else { anyhow::bail!( @@ -137,18 +94,67 @@ fn relativize_active_runtime_lib_path(ar: &ActiveRuntime, path: &Path) -> Active res } +const ACTIVE_RUNTIME_BAK: &str = "active_runtime.json.envision.bak"; + pub fn set_current_active_runtime_to_profile(profile: &Profile) -> anyhow::Result<()> { let dest = get_active_runtime_json_path(); - set_file_readonly(&dest, false)?; - backup_steam_active_runtime(); - let pfx = profile.clone().prefix; - let mut ar = build_profile_active_runtime(profile)?; - // hack: relativize libopenxr_monado.so path for system installs - if pfx == PathBuf::from(SYSTEM_PREFIX) { - ar = relativize_active_runtime_lib_path(&ar, &dest); + if dest.is_dir() { + bail!("{} is a directory", dest.to_string_lossy()); } - dump_current_active_runtime(&ar)?; - set_file_readonly(&dest, true)?; + if !dest.is_symlink() { + set_file_readonly(&dest, false)?; + } + if dest.is_file() || dest.is_symlink() { + rename(&dest, dest.parent().unwrap().join(ACTIVE_RUNTIME_BAK))?; + } else { + debug!("no active_runtime.json file to backup") + } + + let profile_openxr_json = profile.openxr_json_path(); + if profile_openxr_json.is_file() { + create_dir_all(dest.parent().unwrap())?; + symlink(profile_openxr_json, &dest)?; + } else { + warn!("profile openxr json file doesn't exist"); + // fallback: build the file from scratch + let pfx = profile.clone().prefix; + let mut ar = build_profile_active_runtime(profile)?; + // hack: relativize libopenxr_monado.so path for system installs + if pfx == PathBuf::from(SYSTEM_PREFIX) { + ar = relativize_active_runtime_lib_path(&ar, &dest); + } + dump_current_active_runtime(&ar)?; + set_file_readonly(&dest, true)?; + } + Ok(()) +} + +pub fn remove_current_active_runtime() -> anyhow::Result<()> { + let dest = get_active_runtime_json_path(); + if dest.is_dir() { + bail!("{} is a directory", dest.to_string_lossy()); + } + if !dest.exists() { + debug!("no current active_runtime.json to remove") + } + Ok(remove_file(dest)?) +} + +pub fn restore_active_runtime_backup() -> anyhow::Result<()> { + let dest = get_active_runtime_json_path(); + let bak = dest.parent().unwrap().join(ACTIVE_RUNTIME_BAK); + if bak.is_file() || bak.is_symlink() { + if dest.is_dir() { + bail!("{} is a directory", dest.to_string_lossy()); + } + if !dest.is_symlink() { + set_file_readonly(&dest, false)?; + } + rename(&bak, &dest)?; + } else { + debug!("{ACTIVE_RUNTIME_BAK} does not exist, nothing to restore"); + } + Ok(()) } diff --git a/src/file_builders/mod.rs b/src/file_builders/mod.rs index b6078ae..33c9829 100644 --- a/src/file_builders/mod.rs +++ b/src/file_builders/mod.rs @@ -1,5 +1,6 @@ pub mod active_runtime_json; pub mod monado_autorun; pub mod openvrpaths_vrpath; +pub mod wayvr_dashboard_config; pub mod wivrn_config; pub mod wivrn_encoder_presets; diff --git a/src/file_builders/openvrpaths_vrpath.rs b/src/file_builders/openvrpaths_vrpath.rs index 1108717..1b888c2 100644 --- a/src/file_builders/openvrpaths_vrpath.rs +++ b/src/file_builders/openvrpaths_vrpath.rs @@ -1,5 +1,3 @@ -use std::path::{Path, PathBuf}; - use crate::{ paths::get_backup_dir, profile::Profile, @@ -7,6 +5,7 @@ use crate::{ xdg::XDG, }; use serde::{ser::Error, Deserialize, Serialize}; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OpenVrPaths { @@ -86,6 +85,7 @@ fn build_steam_openvrpaths() -> OpenVrPaths { } pub fn set_current_openvrpaths_to_steam() -> anyhow::Result<()> { + // removing readonly flag just in case, remove this line in the future set_file_readonly(&get_openvrpaths_vrpath_path(), false)?; dump_current_openvrpaths(&build_steam_openvrpaths())?; Ok(()) @@ -98,25 +98,24 @@ pub fn build_profile_openvrpaths(profile: &Profile) -> OpenVrPaths { external_drivers: None, jsonid: "vrpathreg".into(), log: vec![datadir.join("Steam/logs")], - runtime: vec![profile.opencomposite_path.join("build")], + runtime: vec![profile.ovr_comp.runtime_dir()], version: 1, } } pub fn set_current_openvrpaths_to_profile(profile: &Profile) -> anyhow::Result<()> { let dest = get_openvrpaths_vrpath_path(); + // removing readonly flag just in case, remove this line in the future set_file_readonly(&dest, false)?; backup_steam_openvrpaths(); dump_current_openvrpaths(&build_profile_openvrpaths(profile))?; - set_file_readonly(&dest, true)?; Ok(()) } #[cfg(test)] mod tests { - use std::path::Path; - use super::{dump_openvrpaths_to_path, get_openvrpaths_from_path, OpenVrPaths}; + use std::path::Path; #[test] fn can_read_openvrpaths_vrpath_steamvr() { diff --git a/src/file_builders/wayvr_dashboard_config.rs b/src/file_builders/wayvr_dashboard_config.rs new file mode 100644 index 0000000..6e9315c --- /dev/null +++ b/src/file_builders/wayvr_dashboard_config.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WayVrDashboardConfigFragmentInner { + pub exec: String, + pub args: Option, + pub env: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WayVrDashboardConfigFragment { + pub dashboard: WayVrDashboardConfigFragmentInner, +} diff --git a/src/file_builders/wivrn_config.rs b/src/file_builders/wivrn_config.rs index 53c966e..16a8b46 100644 --- a/src/file_builders/wivrn_config.rs +++ b/src/file_builders/wivrn_config.rs @@ -143,7 +143,7 @@ pub struct WivrnConfig { pub encoders: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub application: Option, - #[serde(default)] + #[serde(default, rename = "tcp-only")] pub tcp_only: bool, /// contains unknown fields #[serde(flatten)] diff --git a/src/gpu_profile.rs b/src/gpu_profile.rs index 5a57c22..98eea11 100644 --- a/src/gpu_profile.rs +++ b/src/gpu_profile.rs @@ -116,7 +116,7 @@ fn list_gpus() -> Vec { for i in 0..5 { // arbitrary range, find a better way - let card_dir = PathBuf::from(format!("/sys/class/drm/card{}", i)); + let card_dir = PathBuf::from(format!("/sys/class/drm/card{i}")); let vendor_file = card_dir.join("device/vendor"); if let Some(mut reader) = get_reader(&vendor_file) { let mut buf = String::new(); diff --git a/src/linux_distro.rs b/src/linux_distro.rs index 7bdfe0b..2fcd76a 100644 --- a/src/linux_distro.rs +++ b/src/linux_distro.rs @@ -49,13 +49,13 @@ impl LinuxDistro { Ok(_) if buf.starts_with("PRETTY_NAME=\"") => { return buf .split('=') - .last() + .next_back() .map(|b| b.trim().trim_matches('"').trim().to_string()); } Ok(_) if buf.starts_with("NAME=\"") => { name = buf .split('=') - .last() + .next_back() .map(|b| b.trim().trim_matches('"').trim().to_string()); } _ => {} @@ -79,7 +79,7 @@ impl LinuxDistro { { let name = buf .split('=') - .last() + .next_back() .unwrap_or_default() .trim() .trim_matches('"') @@ -115,6 +115,7 @@ impl LinuxDistro { || s.contains("steamos") || s.contains("steam os") || s.contains("endeavour") + || s.contains("cachyos") || s.contains("garuda") { return Some(Self::Arch); @@ -150,7 +151,33 @@ impl LinuxDistro { Self::Alpine => format!("sudo apk add {}", packages.join(" ")), Self::Debian => format!("sudo apt install {}", packages.join(" ")), Self::Gentoo => format!("sudo emerge -av {}", packages.join(" ")), - Self::Suse => format!("sudo zypper install {}", packages.join(" ")), + Self::Suse => { + let mut opi_pkgs = Vec::new(); + let mut zypper_pkgs = Vec::new(); + for pkg in packages { + if ["OpenXR-SDK-devel"].contains(&pkg.as_str()) { + opi_pkgs.push(pkg.clone()); + } else { + zypper_pkgs.push(pkg.clone()); + } + } + [ + if opi_pkgs.is_empty() { + None + } else { + Some(format!("opi {}", opi_pkgs.join(" "))) + }, + if zypper_pkgs.is_empty() { + None + } else { + Some(format!("sudo zypper install {}", zypper_pkgs.join(" "))) + }, + ] + .iter() + .filter_map(|c| c.clone()) + .collect::>() + .join(" && ") + } Self::Fedora => { let mut install_rpmfusion_cmd: Option = None; let mut swap_ffmpeg_cmd: Option = None; @@ -190,9 +217,9 @@ impl LinuxDistro { #[cfg(test)] mod tests { - use std::path::Path; - use super::LinuxDistro; + use crate::depcheck::common::{dep_openxr, dep_pkexec, dep_vulkan_icd_loader}; + use std::path::Path; #[test] fn can_detect_arch_linux_from_etc_os_release() { @@ -203,4 +230,34 @@ mod tests { Some(LinuxDistro::Arch) ) } + + #[test] + fn can_account_for_opensuse_opi_packages() { + assert_eq!( + LinuxDistro::Suse + .install_command( + &[dep_openxr(), dep_vulkan_icd_loader()] + .iter() + .map(|dep| dep.package_name_for_distro(Some(&LinuxDistro::Suse))) + .collect::>() + ) + .as_str(), + "opi OpenXR-SDK-devel && sudo zypper install vulkan-devel" + ) + } + + #[test] + fn opensuse_opi_does_not_interfere_if_not_needed() { + assert_eq!( + LinuxDistro::Suse + .install_command( + &[dep_pkexec(), dep_vulkan_icd_loader()] + .iter() + .map(|dep| dep.package_name_for_distro(Some(&LinuxDistro::Suse))) + .collect::>() + ) + .as_str(), + "sudo zypper install polkit vulkan-devel" + ) + } } diff --git a/src/main.rs b/src/main.rs index 2185480..64a6e59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,29 @@ use anyhow::Result; use constants::{resources, APP_ID, APP_NAME, GETTEXT_PACKAGE, LOCALE_DIR, RESOURCES_BASE_PATH}; use file_builders::{ - active_runtime_json::{get_current_active_runtime, set_current_active_runtime_to_steam}, + active_runtime_json::restore_active_runtime_backup, openvrpaths_vrpath::{get_current_openvrpaths, set_current_openvrpaths_to_steam}, }; use gettextrs::LocaleCategory; +use paths::get_logs_dir; use relm4::{ adw, gtk::{self, gdk, gio, glib, prelude::*}, MessageBroker, RelmApp, }; -use std::env; -use steam_linux_runtime_injector::restore_runtime_entrypoint; +use std::{ + env, + fs::{read_dir, remove_file}, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, +}; +use steam_linux_runtime_injector::{ + restore_sniper_runtime_entrypoint, restore_soldier_runtime_entrypoint, +}; +use tracing::{error, warn}; +use tracing_subscriber::{ + filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, +}; use ui::{ app::{App, AppInit, Msg}, cmdline_opts::CmdLineOpts, @@ -22,6 +34,7 @@ pub mod build_tools; pub mod builders; pub mod cmd_runner; pub mod config; +#[rustfmt::skip] pub mod constants; pub mod depcheck; pub mod device_prober; @@ -46,25 +59,61 @@ pub mod xdg; pub mod xr_devices; fn restore_steam_xr_files() { - let active_runtime = get_current_active_runtime(); let openvrpaths = get_current_openvrpaths(); - if let Some(ar) = active_runtime { - if !file_builders::active_runtime_json::is_steam(&ar) { - match set_current_active_runtime_to_steam() { - Ok(_) => {} - Err(e) => eprintln!("Warning: failed to restore active runtime to steam: {e}"), - }; - } + if let Err(e) = restore_active_runtime_backup() { + warn!("failed to restore active runtime to steam: {e}"); } if let Some(ovrp) = openvrpaths { if !file_builders::openvrpaths_vrpath::is_steam(&ovrp) { - match set_current_openvrpaths_to_steam() { - Ok(_) => {} - Err(e) => eprintln!("Warning: failed to restore openvrpaths to steam: {e}"), - }; + if let Err(e) = set_current_openvrpaths_to_steam() { + warn!("failed to restore openvrpaths to steam: {e}"); + } } } - restore_runtime_entrypoint(); + restore_sniper_runtime_entrypoint(); + restore_soldier_runtime_entrypoint(); +} + +const LOGS_MAX_SIZE_BYTES: u64 = 1000000000; // 1GB + +fn remove_old_logs(dir: &Path, log_files: Option>) -> anyhow::Result<()> { + let log_files: Vec = log_files + .map::>, _>(Ok) + .unwrap_or_else(|| { + let mut files: Vec = read_dir(dir)? + .filter_map(|de| { + let p = de.ok()?.path(); + if p.is_file() && !p.is_symlink() { + Some(p) + } else { + None + } + }) + .collect(); + files.sort_unstable(); + Ok(files) + })?; + let total_size = log_files + .iter() + .filter_map(|p| Some(p.metadata().ok()?.size())) + .reduce(u64::saturating_add) + .unwrap_or(0); + // if size is under threshold, finish + if total_size < LOGS_MAX_SIZE_BYTES { + return Ok(()); + } + // keep a minimum of 3 logs + if log_files.len() <= 3 { + return Ok(()); + } + + remove_file(log_files.first().ok_or_else(|| + anyhow::Error::msg( + "Could not get first item in log files list, but they should be more than 3! This is a bug!" + ) + )?)?; + + remove_old_logs(dir, Some(log_files)) } fn main() -> Result<()> { @@ -72,6 +121,30 @@ fn main() -> Result<()> { panic!("{APP_NAME} cannot run as root"); } restore_steam_xr_files(); + // deferring error logging for this since tracing isn't initialized yet + let old_logs_removal_res = remove_old_logs(&get_logs_dir(), None); + + let rolling_log_writer = tracing_appender::rolling::daily(get_logs_dir(), "log"); + let (non_blocking_appender, _appender_guard) = + tracing_appender::non_blocking(rolling_log_writer); + tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer().pretty().with_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ), + ) + .with( + tracing_subscriber::fmt::layer() + .json() + .with_writer(non_blocking_appender), + ) + .init(); + + if let Err(e) = old_logs_removal_res { + error!("Failed to remove old log files: {e}"); + } // Prepare i18n gettextrs::setlocale(LocaleCategory::LcAll, ""); @@ -87,7 +160,7 @@ fn main() -> Result<()> { } let provider = gtk::CssProvider::new(); - provider.load_from_resource(&format!("{}/style.css", RESOURCES_BASE_PATH)); + provider.load_from_resource(&format!("{RESOURCES_BASE_PATH}/style.css")); if let Some(display) = gdk::Display::default() { gtk::style_context_add_provider_for_display( &display, diff --git a/src/meson.build b/src/meson.build index 133b83f..9d4cce9 100644 --- a/src/meson.build +++ b/src/meson.build @@ -3,7 +3,7 @@ config = configure_file( output: 'constants.rs', configuration: global_conf ) -# Copy the config.rs output to the source directory. +# Copy the constants.rs output to the source directory. run_command( 'cp', meson.project_build_root() / 'src' / 'constants.rs', @@ -43,3 +43,24 @@ cargo_build = custom_target( 'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@', ] ) + +test( + 'cargo-fmt-check', + cargo, + args: ['fmt', '--all', '--check'] +) + +test( + 'cargo-clippy', + cargo, + env: ['RUSTFLAGS=-Dwarnings'], + args: ['clippy', '--all-targets', '--all-features'], + timeout: 0, +) + +test( + 'cargo-test', + cargo, + args: ['test'], + timeout: 0, +) diff --git a/src/openxr_prober.rs b/src/openxr_prober.rs index c013959..de756e3 100644 --- a/src/openxr_prober.rs +++ b/src/openxr_prober.rs @@ -17,7 +17,7 @@ pub fn is_openxr_ready() -> bool { let Ok(xr_instance) = entry.create_instance( &xr::ApplicationInfo { - application_name: &format!("{}-openxr-prober", CMD_NAME), + application_name: &format!("{CMD_NAME}-openxr-prober"), application_version: 0, engine_name: CMD_NAME, engine_version: 0, diff --git a/src/paths.rs b/src/paths.rs index 06b7575..6384380 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -1,4 +1,6 @@ -use crate::{constants::CMD_NAME, xdg::XDG}; +use anyhow::bail; + +use crate::{constants::CMD_NAME, util::steam_library_folder::SteamLibraryFolder, xdg::XDG}; use std::{ env, fs::create_dir_all, @@ -54,6 +56,10 @@ pub fn get_cache_dir() -> PathBuf { XDG.get_cache_home().join(CMD_NAME) } +pub fn get_logs_dir() -> PathBuf { + get_cache_dir().join("logs") +} + pub fn get_backup_dir() -> PathBuf { let p = get_data_dir().join("backups"); if !p.is_dir() { @@ -83,7 +89,26 @@ pub fn get_exec_prefix() -> PathBuf { .into() } -pub fn get_steamvr_bin_dir_path() -> PathBuf { - XDG.get_data_home() - .join("Steam/steamapps/common/SteamVR/bin/linux64") +const STEAMVR_STEAM_APPID: u32 = 250820; + +fn get_steamvr_base_dir() -> anyhow::Result { + SteamLibraryFolder::get_folders()? + .into_iter() + .find(|(_, lf)| lf.apps.contains_key(&STEAMVR_STEAM_APPID)) + .map(|(_, lf)| PathBuf::from(lf.path).join("steamapps/common/SteamVR")) + .ok_or(anyhow::Error::msg( + "Could not find SteamVR in Steam libraryfolders.vdf", + )) +} + +pub fn get_steamvr_bin_dir_path() -> anyhow::Result { + let res = get_steamvr_base_dir()?.join("bin/linux64"); + if !res.is_dir() { + bail!("SteamVR bin dir `{}` does not exist", res.to_string_lossy()); + } + Ok(res) +} + +pub fn get_plugins_dir() -> PathBuf { + get_data_dir().join("plugins") } diff --git a/src/profile.rs b/src/profile.rs index a0a3903..2666291 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -2,10 +2,12 @@ use crate::{ depcheck::{ basalt_deps::get_missing_basalt_deps, libsurvive_deps::get_missing_libsurvive_deps, mercury_deps::get_missing_mercury_deps, monado_deps::get_missing_monado_deps, - openhmd_deps::get_missing_openhmd_deps, wivrn_deps::get_missing_wivrn_deps, Dependency, + openhmd_deps::get_missing_openhmd_deps, wivrn_deps::get_missing_wivrn_deps, + xrizer_deps::get_missing_xrizer_deps, Dependency, }, + file_builders::active_runtime_json::ActiveRuntime, paths::{get_data_dir, BWRAP_SYSTEM_PREFIX, SYSTEM_PREFIX}, - util::file_utils::get_writer, + util::file_utils::{deserialize_file, get_writer}, xdg::XDG, }; use nix::NixPath; @@ -13,10 +15,11 @@ use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, fmt::Display, - fs::File, + fs::{remove_dir_all, File}, io::BufReader, path::{Path, PathBuf}, slice::Iter, + str::FromStr, }; use uuid::Uuid; @@ -43,7 +46,14 @@ impl XRServiceType { pub fn libmonado_path(&self) -> &'static str { match self { Self::Monado => "libmonado.so", - Self::Wivrn => "wivrn/libmonado.so", + Self::Wivrn => "wivrn/libmonado_wivrn.so", + } + } + + pub fn openxr_json_rel_path(&self) -> &'static str { + match self { + Self::Monado => "share/openxr/1/openxr_monado.json", + Self::Wivrn => "share/openxr/1/openxr_wivrn.json", } } @@ -251,6 +261,105 @@ impl Display for LighthouseDriver { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum OvrCompatibilityModuleType { + #[default] + Opencomposite, + Xrizer, + Vapor, +} + +impl Display for OvrCompatibilityModuleType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Opencomposite => "OpenComposite", + Self::Xrizer => "xrizer", + Self::Vapor => "VapoR", + }) + } +} + +impl OvrCompatibilityModuleType { + pub fn iter() -> Iter<'static, Self> { + [Self::Opencomposite, Self::Xrizer, Self::Vapor].iter() + } + + pub fn get_missing_deps(&self) -> Vec { + match self { + OvrCompatibilityModuleType::Xrizer => get_missing_xrizer_deps(), + OvrCompatibilityModuleType::Opencomposite | OvrCompatibilityModuleType::Vapor => { + Vec::default() + } + } + } +} + +impl FromStr for OvrCompatibilityModuleType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().trim() { + "opencomposite" => Ok(Self::Opencomposite), + "xrizer" => Ok(Self::Xrizer), + "vapor" => Ok(Self::Vapor), + _ => Err(format!("no match for ovr compatibility module `{s}`")), + } + } +} + +impl From for OvrCompatibilityModuleType { + fn from(value: u32) -> Self { + match value { + 0 => Self::Opencomposite, + 1 => Self::Xrizer, + 2 => Self::Vapor, + _ => panic!("OvrCompatibilityModuleType index out of bounds"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileOvrCompatibilityModule { + pub mod_type: OvrCompatibilityModuleType, + pub repo: Option, + pub branch: Option, + pub path: PathBuf, +} + +impl ProfileOvrCompatibilityModule { + pub fn default_for_uuid(uuid: &str) -> Self { + let mod_type = OvrCompatibilityModuleType::default(); + Self { + mod_type, + repo: None, + branch: None, + path: get_data_dir().join(uuid).join(mod_type.to_string()), + } + } + + /// get the directory corresponding to the openvr runtime. + /// this should correspond to the build output directory + pub fn runtime_dir(&self) -> PathBuf { + match self.mod_type { + OvrCompatibilityModuleType::Opencomposite => self.path.join("build"), + OvrCompatibilityModuleType::Vapor => self.path.join("build/install_pfx/lib/VapoR"), + OvrCompatibilityModuleType::Xrizer => self.path.join("target/release"), + } + } +} + +impl Default for ProfileOvrCompatibilityModule { + fn default() -> Self { + let mod_type = OvrCompatibilityModuleType::default(); + Self { + mod_type, + repo: None, + branch: None, + path: get_data_dir().join("__envision__fallbackovrcomp"), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Profile { pub uuid: String, @@ -261,9 +370,15 @@ pub struct Profile { pub xrservice_branch: Option, #[serde(default = "HashMap::::default")] pub xrservice_cmake_flags: HashMap, + #[deprecated] + #[serde(default)] pub opencomposite_path: PathBuf, + #[deprecated] pub opencomposite_repo: Option, + #[deprecated] pub opencomposite_branch: Option, + #[serde(default)] + pub ovr_comp: ProfileOvrCompatibilityModule, pub features: ProfileFeatures, pub environment: HashMap, /// Install prefix @@ -276,7 +391,6 @@ pub struct Profile { pub lighthouse_driver: LighthouseDriver, #[serde(default = "String::default")] pub xrservice_launch_options: String, - pub autostart_command: Option, #[serde(default)] pub skip_dependency_check: bool, } @@ -288,6 +402,7 @@ impl Display for Profile { } impl Default for Profile { + #[allow(deprecated)] fn default() -> Self { let uuid = Self::new_uuid(); let profile_dir = get_data_dir().join(&uuid); @@ -323,23 +438,50 @@ impl Default for Profile { mercury_enabled: false, }, environment: HashMap::new(), - prefix: get_data_dir().join("prefixes").join(&uuid), + prefix: Self::default_prefix_path(&uuid), can_be_built: true, pull_on_build: true, opencomposite_path: profile_dir.join("opencomposite"), opencomposite_repo: None, opencomposite_branch: None, + ovr_comp: ProfileOvrCompatibilityModule::default_for_uuid(&uuid), editable: true, lighthouse_driver: LighthouseDriver::default(), xrservice_launch_options: String::default(), uuid, - autostart_command: None, skip_dependency_check: false, } } } impl Profile { + fn default_prefix_path(uuid: &str) -> PathBuf { + get_data_dir().join("prefixes").join(uuid) + } + + /// deletes files and folders associated to this profile (mostly repo clones) + pub fn delete_files(&self) -> Vec> { + [ + Some(&self.xrservice_path), + Some(&self.ovr_comp.path), + self.features.libsurvive.path.as_ref(), + self.features.basalt.path.as_ref(), + self.features.openhmd.path.as_ref(), + ] + .iter() + .map(|dir| match dir { + Some(dir) => { + if dir.try_exists().unwrap_or_default() { + remove_dir_all(dir) + } else { + Ok(()) + } + } + None => Ok(()), + }) + .collect() + } + pub fn xr_runtime_json_env_var(&self) -> String { format!( "XR_RUNTIME_JSON=\"{prefix}/share/openxr/1/openxr_{runtime}.json\"", @@ -358,8 +500,8 @@ impl Profile { pub fn env_vars_full(&self) -> Vec { vec![ // format!( - // "VR_OVERRIDE={opencomp}/build", - // opencomp = self.opencomposite_path, + // "VR_OVERRIDE={}", + // self.ovr_comp.runtime_dir(), // ), self.xr_runtime_json_env_var(), format!( @@ -417,8 +559,8 @@ impl Profile { } let uuid = Self::new_uuid(); let profile_dir = get_data_dir().join(&uuid); + #[allow(deprecated)] let mut dup = Self { - uuid, name: format!("Duplicate of {}", self.name), xrservice_type: self.xrservice_type.clone(), xrservice_repo: self.xrservice_repo.clone(), @@ -450,7 +592,6 @@ impl Profile { mercury_enabled: self.features.mercury_enabled, }, environment: self.environment.clone(), - autostart_command: self.autostart_command.clone(), pull_on_build: self.pull_on_build, lighthouse_driver: self.lighthouse_driver, opencomposite_repo: self.opencomposite_repo.clone(), @@ -458,7 +599,16 @@ impl Profile { opencomposite_path: profile_dir.join("opencomposite"), skip_dependency_check: self.skip_dependency_check, xrservice_launch_options: self.xrservice_launch_options.clone(), - ..Default::default() + prefix: Self::default_prefix_path(&uuid), + ovr_comp: ProfileOvrCompatibilityModule { + mod_type: self.ovr_comp.mod_type, + repo: self.ovr_comp.repo.clone(), + branch: self.ovr_comp.branch.clone(), + path: profile_dir.join(self.ovr_comp.mod_type.to_string()), + }, + can_be_built: self.can_be_built, + editable: true, + uuid, }; if dup.environment.contains_key("LD_LIBRARY_PATH") { dup.environment.insert( @@ -544,21 +694,37 @@ impl Profile { } /// absolute path to a given shared object in the profile prefix - pub fn find_so(&self, rel_path: &str) -> Option { + pub fn find_so>(&self, rel_path: P) -> Option { ["lib", "lib64"] .into_iter() - .map(|lib| self.prefix.join(lib).join(rel_path)) + .map(|lib| self.prefix.join(lib).join(rel_path.as_ref())) .find(|path| path.is_file()) } /// absolute path to the libmonado shared object pub fn libmonado_so(&self) -> Option { - self.find_so(self.xrservice_type.libmonado_path()) + // try by reading the openxr json file + self.openxr_config() + .and_then(|conf| conf.runtime.libmonado_path) + .and_then(|libmonado_path| self.find_so(&libmonado_path)) + .or_else(|| + // try with the hardcoded paths + self.find_so(self.xrservice_type.libmonado_path())) + } + + fn openxr_config(&self) -> Option { + deserialize_file(&self.openxr_json_path()) } /// absolute path to the libopenxr shared object pub fn libopenxr_so(&self) -> Option { - self.find_so(self.xrservice_type.libopenxr_path()) + // try by reading the openxr json file + self.openxr_config() + .map(|conf| conf.runtime.library_path) + .and_then(|libmonado_path| self.find_so(&libmonado_path)) + .or_else(|| + // try with the hardcoded paths + self.find_so(self.xrservice_type.libopenxr_path())) } pub fn missing_dependencies(&self) -> Vec { @@ -580,12 +746,18 @@ impl Profile { if self.features.mercury_enabled { missing_deps.extend(get_missing_mercury_deps()); } - // no listed deps for opencomp + missing_deps.extend(self.ovr_comp.mod_type.get_missing_deps()); } missing_deps.sort_unstable(); missing_deps.dedup(); // dedup only works if sorted, hence the above missing_deps } + + /// the file that will become active_runtime.json, as installed in the + /// prefix + pub fn openxr_json_path(&self) -> PathBuf { + self.prefix.join(self.xrservice_type.openxr_json_rel_path()) + } } pub fn prepare_ld_library_path(prefix: &Path) -> String { @@ -599,7 +771,10 @@ mod tests { path::{Path, PathBuf}, }; - use crate::profile::{ProfileFeature, ProfileFeatureType, ProfileFeatures, XRServiceType}; + use crate::profile::{ + OvrCompatibilityModuleType, ProfileFeature, ProfileFeatureType, ProfileFeatures, + ProfileOvrCompatibilityModule, XRServiceType, + }; use super::Profile; @@ -609,7 +784,7 @@ mod tests { assert_eq!(profile.name, "Demo profile"); assert_eq!(profile.xrservice_path, PathBuf::from("/home/user/monado")); assert_eq!( - profile.opencomposite_path, + profile.ovr_comp.path, PathBuf::from("/home/user/opencomposite") ); assert_eq!(profile.prefix, PathBuf::from("/home/user/envisionprefix")); @@ -640,7 +815,12 @@ mod tests { name: "Demo profile".into(), xrservice_path: PathBuf::from("/home/user/monado"), xrservice_type: XRServiceType::Monado, - opencomposite_path: PathBuf::from("/home/user/opencomposite"), + ovr_comp: ProfileOvrCompatibilityModule { + path: PathBuf::from("/home/user/opencomposite"), + repo: None, + branch: None, + mod_type: OvrCompatibilityModuleType::default(), + }, features: ProfileFeatures { libsurvive: ProfileFeature { feature_type: ProfileFeatureType::Libsurvive, diff --git a/src/profiles/lighthouse.rs b/src/profiles/lighthouse.rs index 794ee3e..9ba0af8 100644 --- a/src/profiles/lighthouse.rs +++ b/src/profiles/lighthouse.rs @@ -1,7 +1,10 @@ use crate::{ constants::APP_NAME, paths::{data_monado_path, data_opencomposite_path, get_data_dir}, - profile::{prepare_ld_library_path, LighthouseDriver, Profile, ProfileFeatures, XRServiceType}, + profile::{ + prepare_ld_library_path, LighthouseDriver, Profile, ProfileFeatures, + ProfileOvrCompatibilityModule, XRServiceType, + }, }; use std::collections::HashMap; @@ -18,10 +21,13 @@ pub fn lighthouse_profile() -> Profile { environment.insert("LD_LIBRARY_PATH".into(), prepare_ld_library_path(&prefix)); Profile { uuid: "lighthouse-default".into(), - name: format!("Lighthouse Driver - {name} Default", name = APP_NAME), + name: format!("Lighthouse Driver - {APP_NAME} Default"), xrservice_path: data_monado_path(), xrservice_type: XRServiceType::Monado, - opencomposite_path: data_opencomposite_path(), + ovr_comp: ProfileOvrCompatibilityModule { + path: data_opencomposite_path(), + ..Default::default() + }, features: ProfileFeatures::default(), environment, prefix, diff --git a/src/profiles/openhmd.rs b/src/profiles/openhmd.rs index b045c1e..71ec6f7 100644 --- a/src/profiles/openhmd.rs +++ b/src/profiles/openhmd.rs @@ -3,7 +3,7 @@ use crate::{ paths::{data_monado_path, data_opencomposite_path, data_openhmd_path, get_data_dir}, profile::{ prepare_ld_library_path, LighthouseDriver, Profile, ProfileFeature, ProfileFeatureType, - ProfileFeatures, XRServiceType, + ProfileFeatures, ProfileOvrCompatibilityModule, XRServiceType, }, }; use std::collections::HashMap; @@ -21,10 +21,13 @@ pub fn openhmd_profile() -> Profile { environment.insert("LD_LIBRARY_PATH".into(), prepare_ld_library_path(&prefix)); Profile { uuid: "openhmd-default".into(), - name: format!("OpenHMD - {name} Default", name = APP_NAME), + name: format!("OpenHMD - {APP_NAME} Default"), xrservice_path: data_monado_path(), xrservice_type: XRServiceType::Monado, - opencomposite_path: data_opencomposite_path(), + ovr_comp: ProfileOvrCompatibilityModule { + path: data_opencomposite_path(), + ..Default::default() + }, features: ProfileFeatures { openhmd: ProfileFeature { feature_type: ProfileFeatureType::OpenHmd, diff --git a/src/profiles/simulated.rs b/src/profiles/simulated.rs index 2283caf..c5af2ec 100644 --- a/src/profiles/simulated.rs +++ b/src/profiles/simulated.rs @@ -1,7 +1,7 @@ use crate::{ constants::APP_NAME, paths::{data_monado_path, data_opencomposite_path, get_data_dir}, - profile::{Profile, ProfileFeatures, XRServiceType}, + profile::{Profile, ProfileFeatures, ProfileOvrCompatibilityModule, XRServiceType}, }; use std::collections::HashMap; @@ -22,10 +22,13 @@ pub fn simulated_profile() -> Profile { ); Profile { uuid: "simulated-default".into(), - name: format!("Simulated Driver - {name} Default", name = APP_NAME), + name: format!("Simulated Driver - {APP_NAME} Default"), xrservice_path: data_monado_path(), xrservice_type: XRServiceType::Monado, - opencomposite_path: data_opencomposite_path(), + ovr_comp: ProfileOvrCompatibilityModule { + path: data_opencomposite_path(), + ..Default::default() + }, features: ProfileFeatures::default(), environment, prefix, diff --git a/src/profiles/survive.rs b/src/profiles/survive.rs index 481f624..6349b77 100644 --- a/src/profiles/survive.rs +++ b/src/profiles/survive.rs @@ -3,7 +3,7 @@ use crate::{ paths::{data_libsurvive_path, data_monado_path, data_opencomposite_path, get_data_dir}, profile::{ prepare_ld_library_path, LighthouseDriver, Profile, ProfileFeature, ProfileFeatureType, - ProfileFeatures, XRServiceType, + ProfileFeatures, ProfileOvrCompatibilityModule, XRServiceType, }, }; use std::collections::HashMap; @@ -23,10 +23,13 @@ pub fn survive_profile() -> Profile { environment.insert("LD_LIBRARY_PATH".into(), prepare_ld_library_path(&prefix)); Profile { uuid: "survive-default".into(), - name: format!("Survive - {name} Default", name = APP_NAME), + name: format!("Survive - {APP_NAME} Default"), xrservice_path: data_monado_path(), xrservice_type: XRServiceType::Monado, - opencomposite_path: data_opencomposite_path(), + ovr_comp: ProfileOvrCompatibilityModule { + path: data_opencomposite_path(), + ..Default::default() + }, features: ProfileFeatures { libsurvive: ProfileFeature { feature_type: ProfileFeatureType::Libsurvive, diff --git a/src/profiles/wivrn.rs b/src/profiles/wivrn.rs index dd58da9..b68e15d 100644 --- a/src/profiles/wivrn.rs +++ b/src/profiles/wivrn.rs @@ -1,7 +1,11 @@ use crate::{ constants::APP_NAME, paths::{data_opencomposite_path, data_wivrn_path, get_data_dir}, - profile::{prepare_ld_library_path, Profile, ProfileFeatures, XRServiceType}, + profile::{ + prepare_ld_library_path, Profile, ProfileFeatures, ProfileOvrCompatibilityModule, + XRServiceType, + }, + ui::job_worker::internal_worker::LAUNCH_OPTS_CMD_PLACEHOLDER, }; use std::collections::HashMap; @@ -15,13 +19,19 @@ pub fn wivrn_profile() -> Profile { environment.insert("U_PACING_APP_USE_MIN_FRAME_PERIOD".into(), "1".into()); Profile { uuid: "wivrn-default".into(), - name: format!("WiVRn - {name} Default", name = APP_NAME), + name: format!("WiVRn - {APP_NAME} Default"), xrservice_path: data_wivrn_path(), xrservice_type: XRServiceType::Wivrn, - opencomposite_path: data_opencomposite_path(), + ovr_comp: ProfileOvrCompatibilityModule { + path: data_opencomposite_path(), + ..Default::default() + }, features: ProfileFeatures { ..Default::default() }, + xrservice_launch_options: format!( + "{LAUNCH_OPTS_CMD_PLACEHOLDER} --no-instructions --no-manage-active-runtime" + ), environment, prefix, can_be_built: true, diff --git a/src/profiles/wmr.rs b/src/profiles/wmr.rs index 23e238d..ae99d62 100644 --- a/src/profiles/wmr.rs +++ b/src/profiles/wmr.rs @@ -3,7 +3,7 @@ use crate::{ paths::{data_basalt_path, data_monado_path, data_opencomposite_path, get_data_dir}, profile::{ prepare_ld_library_path, LighthouseDriver, Profile, ProfileFeature, ProfileFeatureType, - ProfileFeatures, XRServiceType, + ProfileFeatures, ProfileOvrCompatibilityModule, XRServiceType, }, }; use std::collections::HashMap; @@ -21,10 +21,13 @@ pub fn wmr_profile() -> Profile { environment.insert("LD_LIBRARY_PATH".into(), prepare_ld_library_path(&prefix)); Profile { uuid: "wmr-default".into(), - name: format!("WMR - {name} Default", name = APP_NAME), + name: format!("WMR - {APP_NAME} Default"), xrservice_path: data_monado_path(), xrservice_type: XRServiceType::Monado, - opencomposite_path: data_opencomposite_path(), + ovr_comp: ProfileOvrCompatibilityModule { + path: data_opencomposite_path(), + ..Default::default() + }, features: ProfileFeatures { basalt: ProfileFeature { feature_type: ProfileFeatureType::Basalt, diff --git a/src/steam_linux_runtime_injector.rs b/src/steam_linux_runtime_injector.rs index 6dad1ec..6e08b5f 100644 --- a/src/steam_linux_runtime_injector.rs +++ b/src/steam_linux_runtime_injector.rs @@ -1,101 +1,99 @@ use crate::{ - paths::{get_backup_dir, get_home_dir}, + paths::get_backup_dir, profile::Profile, - util::file_utils::{copy_file, get_writer}, + util::{ + file_utils::{copy_file, get_writer, mark_as_executable}, + steam_library_folder::SteamLibraryFolder, + }, }; use anyhow::bail; use lazy_static::lazy_static; -use serde::Deserialize; use std::{ - collections::HashMap, fs::read_to_string, io::Write, path::{Path, PathBuf}, }; +use tracing::error; -#[derive(Deserialize)] -struct LibraryFolder { - pub path: String, - pub apps: HashMap, -} +pub const SNIPER_RUNTIME_STEAM_APPID: u32 = 1628350; +pub const SOLDIER_RUNTIME_STEAM_APPID: u32 = 1391110; -pub const PRESSURE_VESSEL_STEAM_APPID: u32 = 1628350; - -fn get_steam_main_dir_path() -> anyhow::Result { - let steam_root: PathBuf = get_home_dir().join(".steam/root"); - - if steam_root.is_symlink() { - Ok(steam_root.read_link()?) - } else if steam_root.is_dir() { - Ok(steam_root) - } else { - bail!( - "Canonical steam root '{}' is not a dir nor a symlink!", - steam_root.to_string_lossy() - ) +fn get_sniper_runtime_entrypoint_path() -> Option { + match SteamLibraryFolder::get_folders() { + Ok(libraryfolders) => libraryfolders + .iter() + .find(|(_, folder)| folder.apps.contains_key(&SNIPER_RUNTIME_STEAM_APPID)) + .map(|(_, folder)| { + PathBuf::from(&folder.path) + .join("steamapps/common/SteamLinuxRuntime_sniper/_v2-entry-point") + }), + Err(e) => { + error!("unable to get sniper runtime entrypoint path: {e}"); + None + } } } -fn parse_steam_libraryfolders_vdf(path: &Path) -> anyhow::Result> { - Ok(keyvalues_serde::from_str(read_to_string(path)?.as_str())?) -} - -fn get_runtime_entrypoint_path() -> Option { - match get_steam_main_dir_path() { - Ok(steam_root) => { - let steam_libraryfolders_path = steam_root.join("steamapps/libraryfolders.vdf"); - - if !steam_libraryfolders_path.is_file() { - eprintln!( - "Steam libraryfolders.vdf does not exist in its canonical location {}", - steam_libraryfolders_path.to_string_lossy() - ); - return None; - } - - let libraryfolders: HashMap = - parse_steam_libraryfolders_vdf(&steam_libraryfolders_path).ok()?; - - libraryfolders - .iter() - .find(|(_, libraryfolder)| { - libraryfolder - .apps - .contains_key(&PRESSURE_VESSEL_STEAM_APPID) - }) - .map(|(_, libraryfolder)| { - PathBuf::from(&libraryfolder.path) - .join("steamapps/common/SteamLinuxRuntime_sniper/_v2-entry-point") - }) - } +fn get_soldier_runtime_entrypoint_path() -> Option { + match SteamLibraryFolder::get_folders() { + Ok(libraryfolders) => libraryfolders + .iter() + .find(|(_, folder)| folder.apps.contains_key(&SOLDIER_RUNTIME_STEAM_APPID)) + .map(|(_, folder)| { + PathBuf::from(&folder.path) + .join("steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point") + }), Err(e) => { - eprintln!("Error getting steam root path: {e}"); + error!("unable to get soldier runtime entrypoint path: {e}"); None } } } lazy_static! { - static ref STEAM_RUNTIME_ENTRYPOINT_PATH: Option = get_runtime_entrypoint_path(); + static ref STEAM_SNIPER_RUNTIME_ENTRYPOINT_PATH: Option = + get_sniper_runtime_entrypoint_path(); + static ref STEAM_SOLDIER_RUNTIME_ENTRYPOINT_PATH: Option = + get_soldier_runtime_entrypoint_path(); } -fn get_backup_runtime_entrypoint_location() -> PathBuf { +fn get_backup_sniper_runtime_entrypoint_location() -> PathBuf { get_backup_dir().join("_v2-entry-point.bak") } -fn backup_runtime_entrypoint(path: &Path) { - copy_file(path, &get_backup_runtime_entrypoint_location()); +fn get_backup_soldier_runtime_entrypoint_location() -> PathBuf { + get_backup_dir().join("_v2-entry-point.soldier.bak") } -pub fn restore_runtime_entrypoint() { - if let Some(path) = STEAM_RUNTIME_ENTRYPOINT_PATH.as_ref() { - let backup = get_backup_runtime_entrypoint_location(); +fn backup_sniper_runtime_entrypoint(path: &Path) { + copy_file(path, &get_backup_sniper_runtime_entrypoint_location()); +} + +fn backup_soldier_runtime_entrypoint(path: &Path) { + copy_file(path, &get_backup_soldier_runtime_entrypoint_location()); +} + +pub fn restore_sniper_runtime_entrypoint() { + if let Some(path) = STEAM_SNIPER_RUNTIME_ENTRYPOINT_PATH.as_ref() { + let backup = get_backup_sniper_runtime_entrypoint_location(); if Path::new(&backup).is_file() { copy_file(&backup, path); + let _ = mark_as_executable(path); } } } +pub fn restore_soldier_runtime_entrypoint() { + if let Some(path) = STEAM_SOLDIER_RUNTIME_ENTRYPOINT_PATH.as_ref() { + let backup = get_backup_soldier_runtime_entrypoint_location(); + if Path::new(&backup).is_file() { + copy_file(&backup, path); + let _ = mark_as_executable(path); + } + } +} + +/// this implementation is identical for both sniper and soldier runtimes fn append_to_runtime_entrypoint(data: &str, path: &Path) -> anyhow::Result<()> { let existing = read_to_string(path)?; let new = existing.replace( @@ -107,10 +105,12 @@ fn append_to_runtime_entrypoint(data: &str, path: &Path) -> anyhow::Result<()> { Ok(()) } -pub fn set_runtime_entrypoint_launch_opts_from_profile(profile: &Profile) -> anyhow::Result<()> { - restore_runtime_entrypoint(); - if let Some(dest) = STEAM_RUNTIME_ENTRYPOINT_PATH.as_ref() { - backup_runtime_entrypoint(dest); +pub fn set_sniper_runtime_entrypoint_launch_opts_from_profile( + profile: &Profile, +) -> anyhow::Result<()> { + restore_sniper_runtime_entrypoint(); + if let Some(dest) = STEAM_SNIPER_RUNTIME_ENTRYPOINT_PATH.as_ref() { + backup_sniper_runtime_entrypoint(dest); append_to_runtime_entrypoint( &profile .get_env_vars() @@ -120,27 +120,31 @@ pub fn set_runtime_entrypoint_launch_opts_from_profile(profile: &Profile) -> any .join("\n"), dest, )?; + mark_as_executable(dest)?; return Ok(()); } - bail!("Could not find valid runtime entrypoint"); + bail!("Could not find valid sniper runtime entrypoint"); } -#[cfg(test)] -mod tests { - use std::path::Path; +pub fn set_soldier_runtime_entrypoint_launch_opts_from_profile( + profile: &Profile, +) -> anyhow::Result<()> { + restore_soldier_runtime_entrypoint(); + if let Some(dest) = STEAM_SOLDIER_RUNTIME_ENTRYPOINT_PATH.as_ref() { + backup_soldier_runtime_entrypoint(dest); + append_to_runtime_entrypoint( + &profile + .get_env_vars() + .iter() + .map(|ev| "export ".to_string() + ev) + .collect::>() + .join("\n"), + dest, + )?; + mark_as_executable(dest)?; - use super::parse_steam_libraryfolders_vdf; - - #[test] - fn deserialize_steam_libraryfolders_vdf() { - let lf = parse_steam_libraryfolders_vdf(Path::new("./test/files/steam_libraryfolders.vdf")) - .unwrap(); - assert_eq!(lf.len(), 1); - let first = lf.get(&0).unwrap(); - assert_eq!(first.path, "/home/gabmus/.local/share/Steam"); - assert_eq!(first.apps.len(), 10); - assert_eq!(first.apps.get(&228980).unwrap(), &29212173); - assert_eq!(first.apps.get(&632360).unwrap(), &0); + return Ok(()); } + bail!("Could not find valid soldier runtime entrypoint"); } diff --git a/src/ui/about_dialog.rs b/src/ui/about_dialog.rs index 4e10574..0e28534 100644 --- a/src/ui/about_dialog.rs +++ b/src/ui/about_dialog.rs @@ -1,7 +1,7 @@ use crate::{ constants::{ - get_artists, get_developers, APP_ID, APP_NAME, BUILD_DATETIME, ISSUES_URL, REPO_URL, - SINGLE_DEVELOPER, VERSION, + get_artists, APP_ID, APP_NAME, BUILD_DATETIME, ISSUES_URL, REPO_URL, SINGLE_DEVELOPER, + VERSION, }, device_prober::PhysicalXRDevice, linux_distro::LinuxDistro, @@ -20,13 +20,20 @@ pub fn create_about_dialog() -> adw::AboutDialog { .website(REPO_URL) .issue_url(ISSUES_URL) .developer_name(SINGLE_DEVELOPER) - .developers(get_developers()) + .developers( + env!("CARGO_PKG_AUTHORS") + .split(':') + .map(|s| s.to_string()) + .collect::>(), + ) .artists(get_artists()) .build() } +const UNKNOWN: &str = "UNKNOWN"; + pub fn populate_debug_info(dialog: &adw::AboutDialog, vkinfo: Option<&VulkanInfo>) { - if dialog.debug_info().len() > 0 { + if !dialog.debug_info().is_empty() { return; } let distro_family = LinuxDistro::get(); @@ -37,10 +44,10 @@ pub fn populate_debug_info(dialog: &adw::AboutDialog, vkinfo: Option<&VulkanInfo format!("Build time: {BUILD_DATETIME}"), format!( "Operating system: {d} ({f})", - d = distro.unwrap_or("unknown".into()), + d = distro.unwrap_or(UNKNOWN.into()), f = distro_family .map(|f| f.to_string()) - .unwrap_or("unknown".into()) + .unwrap_or(UNKNOWN.into()) ), format!( "Kernel: {}", @@ -50,23 +57,29 @@ pub fn populate_debug_info(dialog: &adw::AboutDialog, vkinfo: Option<&VulkanInfo ), format!( "Session type: {}", - env::var("XDG_SESSION_TYPE").unwrap_or("unknown".into()) + env::var("XDG_SESSION_TYPE").unwrap_or(UNKNOWN.into()) ), format!( "Desktop: {}", - env::var("XDG_CURRENT_DESKTOP").unwrap_or("unknown".into()) + env::var("XDG_CURRENT_DESKTOP").unwrap_or(UNKNOWN.into()) + ), + format!( + "CPU: {}", + read_to_string("/proc/cpuinfo") + .ok() + .and_then(|s| { + s.split("\n") + .find(|line| line.starts_with("model name")) + .map(|line| line.split(':').next_back().map(|s| s.trim().to_string())) + }) + .flatten() + .unwrap_or(UNKNOWN.into()) ), format!( "GPUs: {}", vkinfo .map(|i| i.gpu_names.join(", ")) - .unwrap_or("unknown".into()) - ), - format!( - "Monado Vulkan Layers: {}", - vkinfo - .map(|i| i.has_monado_vulkan_layers.to_string()) - .unwrap_or("unknown".into()) + .unwrap_or(UNKNOWN.into()) ), format!("Detected XR Devices: {}", { let devs = PhysicalXRDevice::from_usb(); diff --git a/src/ui/app.rs b/src/ui/app.rs index 1598ecb..a9b9265 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -11,6 +11,7 @@ use super::{ }, libsurvive_setup_window::{LibsurviveSetupMsg, LibsurviveSetupWindow}, main_view::{MainView, MainViewInit, MainViewMsg, MainViewOutMsg}, + plugins::store::{PluginStore, PluginStoreInit, PluginStoreMsg, PluginStoreOutMsg}, util::{copiable_code_snippet, copy_text, open_with_default_handler}, wivrn_conf_editor::{WivrnConfEditor, WivrnConfEditorInit, WivrnConfEditorMsg}, }; @@ -19,14 +20,16 @@ use crate::{ build_basalt::get_build_basalt_jobs, build_libsurvive::get_build_libsurvive_jobs, build_mercury::get_build_mercury_jobs, build_monado::get_build_monado_jobs, build_opencomposite::get_build_opencomposite_jobs, build_openhmd::get_build_openhmd_jobs, - build_wivrn::get_build_wivrn_jobs, + build_vapor::get_build_vapor_jobs, build_wivrn::get_build_wivrn_jobs, + build_xrizer::get_build_xrizer_jobs, }, - config::Config, + config::{Config, PluginConfig}, constants::APP_NAME, depcheck::common::dep_pkexec, file_builders::{ active_runtime_json::{ - set_current_active_runtime_to_profile, set_current_active_runtime_to_steam, + remove_current_active_runtime, restore_active_runtime_backup, + set_current_active_runtime_to_profile, }, openvrpaths_vrpath::{ set_current_openvrpaths_to_profile, set_current_openvrpaths_to_steam, @@ -35,17 +38,23 @@ use crate::{ linux_distro::LinuxDistro, openxr_prober::is_openxr_ready, paths::get_data_dir, - profile::{Profile, XRServiceType}, + profile::{OvrCompatibilityModuleType, Profile, XRServiceType}, stateless_action, steam_linux_runtime_injector::{ - restore_runtime_entrypoint, set_runtime_entrypoint_launch_opts_from_profile, + restore_sniper_runtime_entrypoint, restore_soldier_runtime_entrypoint, + set_sniper_runtime_entrypoint_launch_opts_from_profile, + set_soldier_runtime_entrypoint_launch_opts_from_profile, + }, + termcolor::TermColor, + util::file_utils::{ + setcap_cap_sys_nice_eip, setcap_cap_sys_nice_eip_cmd, verify_cap_sys_nice_eip, }, - util::file_utils::{setcap_cap_sys_nice_eip, setcap_cap_sys_nice_eip_cmd}, vulkaninfo::VulkanInfo, wivrn_dbus, xr_devices::XRDevice, }; use adw::{prelude::*, ResponseAppearance}; +use delicious_adwaita::{theme::Theme, ThemeEngine}; use gtk::glib::{self, clone}; use notify_rust::NotificationHandle; use relm4::{ @@ -53,7 +62,12 @@ use relm4::{ new_action_group, new_stateful_action, new_stateless_action, prelude::*, }; -use std::{collections::VecDeque, fs::remove_file, time::Duration}; +use std::{ + collections::{HashMap, VecDeque}, + fs::remove_file, + time::Duration, +}; +use tracing::error; pub struct App { application: adw::Application, @@ -70,7 +84,7 @@ pub struct App { config: Config, xrservice_worker: Option, - autostart_worker: Option, + plugins_worker: Option, restart_xrservice: bool, build_worker: Option, profiles: Vec, @@ -85,13 +99,16 @@ pub struct App { vkinfo: Option, inhibit_fail_notif: Option, + pluginstore: Option>, + + theme_engine: ThemeEngine, } #[derive(Debug)] pub enum Msg { OnServiceLog(Vec), OnServiceExit(i32), - OnAutostartExit(i32), + OnPluginsExit(i32), OnBuildLog(Vec), OnBuildExit(i32), ClockTicking, @@ -102,7 +119,8 @@ pub enum Msg { StartWithDebug, RestartXRService, ProfileSelected(Profile), - DeleteProfile, + /// bool param: delete files + DeleteProfile(bool), SaveProfile(Profile), RunSetCap, OpenLibsurviveSetup, @@ -116,6 +134,10 @@ pub enum Msg { StartProber, OnProberExit(bool), WivrnCheckPairMode, + OpenPluginStore, + UpdateConfigPlugins(HashMap), + ShowThemeManager, + SaveThemeConfig, NoOp, } @@ -147,7 +169,7 @@ impl App { } { Ok(n) => Some(n), Err(e) => { - eprintln!("Failed to send desktop notification: {e:?}"); + error!("failed to send desktop notification: {e:?}"); None } } @@ -163,57 +185,7 @@ impl App { pub fn start_xrservice(&mut self, sender: AsyncComponentSender, debug: bool) { self.xrservice_ready = false; let prof = self.get_selected_profile(); - if prof.can_start() { - if let Err(e) = set_current_active_runtime_to_profile(&prof) { - alert( - "Failed to start XR Service", - Some(&format!( - "Error setting current active runtime to profile: {e}" - )), - Some(&self.app_win.clone().upcast::()), - ); - return; - } - if let Err(e) = set_current_openvrpaths_to_profile(&prof) { - alert( - "Failed to start XR Service", - Some(&format!( - "Error setting current openvrpaths file to profile: {e}" - )), - Some(&self.app_win.clone().upcast::()), - ); - return; - }; - self.debug_view.sender().emit(DebugViewMsg::ClearLog); - self.xr_devices = vec![]; - remove_file(prof.xrservice_type.ipc_file_path()) - .is_err() - .then(|| println!("Failed to remove xrservice IPC file")); - let worker = JobWorker::xrservice_worker_wrap_from_profile( - &prof, - sender.input_sender(), - |msg| match msg { - JobWorkerOut::Log(rows) => Msg::OnServiceLog(rows), - JobWorkerOut::Exit(code) => Msg::OnServiceExit(code), - }, - debug, - ); - worker.start(); - self.xrservice_worker = Some(worker); - self.main_view - .sender() - .emit(MainViewMsg::XRServiceActiveChanged( - true, - Some(self.get_selected_profile()), - // show launch opts only if setting the runtime entrypoint fails - set_runtime_entrypoint_launch_opts_from_profile(&prof).is_err(), - )); - self.debug_view - .sender() - .emit(DebugViewMsg::XRServiceActiveChanged(true)); - self.set_inhibit_session(true); - sender.input(Msg::StartProber); - } else { + if !prof.can_start() { alert( "Failed to start profile", Some(concat!( @@ -222,32 +194,134 @@ impl App { )), Some(&self.app_win.clone().upcast::()), ); + return; } + if let Err(e) = set_current_active_runtime_to_profile(&prof) { + alert( + "Failed to start XR Service", + Some(&format!( + "Error setting current active runtime to profile: {e}" + )), + Some(&self.app_win.clone().upcast::()), + ); + return; + } + if let Err(e) = set_current_openvrpaths_to_profile(&prof) { + alert( + "Failed to start XR Service", + Some(&format!( + "Error setting current openvrpaths file to profile: {e}" + )), + Some(&self.app_win.clone().upcast::()), + ); + return; + }; + self.debug_view.sender().emit(DebugViewMsg::ClearLog); + self.xr_devices = vec![]; + { + let ipc_file = prof.xrservice_type.ipc_file_path(); + if ipc_file.exists() { + remove_file(ipc_file) + .unwrap_or_else(|e| error!("failed to remove xrservice IPC file: {e}")); + }; + } + let worker = JobWorker::xrservice_worker_wrap_from_profile( + &prof, + sender.input_sender(), + |msg| match msg { + JobWorkerOut::Log(rows) => Msg::OnServiceLog(rows), + JobWorkerOut::Exit(code) => Msg::OnServiceExit(code), + }, + debug, + ); + worker.start(); + self.xrservice_worker = Some(worker); + let set_sniper_launch_opts_res = + set_sniper_runtime_entrypoint_launch_opts_from_profile(&prof); + let set_soldier_launch_opts_res = + set_soldier_runtime_entrypoint_launch_opts_from_profile(&prof); + self.main_view + .sender() + .emit(MainViewMsg::XRServiceActiveChanged( + true, + Some(self.get_selected_profile()), + // show launch opts only if setting the runtime entrypoint fails + set_sniper_launch_opts_res.is_err() || set_soldier_launch_opts_res.is_err(), + )); + self.debug_view + .sender() + .emit(DebugViewMsg::XRServiceActiveChanged(true)); + self.set_inhibit_session(true); + sender.input(Msg::StartProber); } pub fn run_autostart(&mut self, sender: AsyncComponentSender) { let prof = self.get_selected_profile(); - if let Some(autostart_cmd) = &prof.autostart_command { + let plugins_cmd = self + .config + .plugins + .values() + .filter_map(|cp| { + if cp.enabled && cp.plugin.validate() { + if let Err(e) = cp.plugin.mark_as_executable() { + error!( + "failed to mark plugin {} as executable: {e}", + cp.plugin.appid + ); + None + } else if !cp.plugin.plugin_type.launches_directly() { + None + } else { + Some({ + let mut cmd_parts = vec![cp + .plugin + .executable() + .unwrap() + .to_string_lossy() + .to_string()]; + cmd_parts.extend(cp.plugin.args.clone().unwrap_or_default()); + cmd_parts + .iter() + .map(|part| format!("'{part}'")) + .collect::>() + .join(" ") + }) + } + } else { + None + } + }) + .collect::>() + .join(" & "); + if !plugins_cmd.is_empty() { let mut jobs = VecDeque::new(); jobs.push_back(WorkerJob::new_cmd( Some(prof.environment.clone()), "sh".into(), - Some(vec!["-c".into(), autostart_cmd.clone()]), + Some(vec!["-c".into(), plugins_cmd]), )); - let autostart_worker = JobWorker::new(jobs, sender.input_sender(), |msg| match msg { + let plugins_worker = JobWorker::new(jobs, sender.input_sender(), |msg| match msg { JobWorkerOut::Log(rows) => Msg::OnServiceLog(rows), - JobWorkerOut::Exit(code) => Msg::OnAutostartExit(code), + JobWorkerOut::Exit(code) => Msg::OnPluginsExit(code), }); - autostart_worker.start(); - self.autostart_worker = Some(autostart_worker); + plugins_worker.start(); + self.plugins_worker = Some(plugins_worker); } } pub fn restore_openxr_openvr_files(&self) { - restore_runtime_entrypoint(); - if let Err(e) = set_current_active_runtime_to_steam() { + restore_sniper_runtime_entrypoint(); + restore_soldier_runtime_entrypoint(); + if let Err(e) = remove_current_active_runtime() { alert( - "Could not restore Steam active runtime", + "Could not remove profile active runtime", + Some(&format!("{e}")), + Some(&self.app_win.clone().upcast::()), + ); + } + if let Err(e) = restore_active_runtime_backup() { + alert( + "Could not restore previous active runtime", Some(&format!("{e}")), Some(&self.app_win.clone().upcast::()), ); @@ -262,27 +336,17 @@ impl App { } pub fn shutdown_xrservice(&mut self) { - if let Some(worker) = self.autostart_worker.as_ref() { - worker.stop(); + if let Some(w) = self.plugins_worker.as_ref() { + w.stop(); } - self.xrservice_ready = false; if let Some(w) = self.openxr_prober_worker.as_ref() { w.stop(); // this can cause threads to remain hanging... self.openxr_prober_worker = None; } - self.set_inhibit_session(false); - if let Some(worker) = self.xrservice_worker.as_ref() { - worker.stop(); + if let Some(w) = self.xrservice_worker.as_ref() { + w.stop(); } - self.libmonado = None; - self.main_view - .sender() - .emit(MainViewMsg::XRServiceActiveChanged(false, None, false)); - self.debug_view - .sender() - .emit(DebugViewMsg::XRServiceActiveChanged(false)); - self.xr_devices = vec![]; } } @@ -316,7 +380,7 @@ impl AsyncComponent for App { set_content: Some(&adw::NavigationPage::new(model.debug_view.widget(), "Debug View")), set_show_content: false, set_collapsed: !model.config.debug_view_enabled, - } + }, }, connect_close_request[sender] => move |win| { sender.input(Msg::SaveWinSize(win.width(), win.height())); @@ -340,6 +404,27 @@ impl AsyncComponent for App { ) { match message { Msg::NoOp => {} + Msg::ShowThemeManager => { + let dialog = self + .theme_engine + .theme_chooser_dialog(Theme::default_themes().as_ref()); + dialog.set_content_height(2000); + dialog.present(Some(&self.app_win)); + dialog.connect_closed(clone!( + #[strong] + sender, + move |_| { + sender.input(Msg::SaveThemeConfig); + } + )); + } + Msg::SaveThemeConfig => { + let name = self.theme_engine.current_theme_name(); + if self.config.theme_name != name { + self.config.theme_name = name; + self.config.save(); + } + } Msg::OnServiceLog(rows) => { if !rows.is_empty() { self.debug_view @@ -348,6 +433,8 @@ impl AsyncComponent for App { } } Msg::OnServiceExit(code) => { + self.set_inhibit_session(false); + self.xrservice_ready = false; self.restore_openxr_openvr_files(); self.main_view .sender() @@ -355,6 +442,8 @@ impl AsyncComponent for App { self.debug_view .sender() .emit(DebugViewMsg::XRServiceActiveChanged(false)); + self.libmonado = None; + self.xr_devices = vec![]; if code != 0 && code != 15 { // 15 is SIGTERM sender.input(Msg::OnServiceLog(vec![format!( @@ -369,7 +458,7 @@ impl AsyncComponent for App { self.start_xrservice(sender, false); } } - Msg::OnAutostartExit(_) => self.autostart_worker = None, + Msg::OnPluginsExit(_) => self.plugins_worker = None, Msg::ClockTicking => { self.main_view.sender().emit(MainViewMsg::ClockTicking); let xrservice_worker_is_alive = self @@ -409,7 +498,7 @@ impl AsyncComponent for App { .emit(MainViewMsg::SetWivrnSupportsPairing(true)); } Err(e) => { - eprintln!("Error: failed to get wivrn pairing mode: {e:?}"); + error!("failed to get wivrn pairing mode: {e:?}"); self.main_view .sender() .emit(MainViewMsg::SetWivrnSupportsPairing(false)); @@ -466,14 +555,24 @@ impl AsyncComponent for App { jobs.extend(get_build_basalt_jobs(&profile, clean_build)); } if profile.features.mercury_enabled { - jobs.extend(get_build_mercury_jobs(&profile)); + jobs.extend(get_build_mercury_jobs()); } jobs.extend(match profile.xrservice_type { XRServiceType::Monado => get_build_monado_jobs(&profile, clean_build), XRServiceType::Wivrn => get_build_wivrn_jobs(&profile, clean_build), }); } - jobs.extend(get_build_opencomposite_jobs(&profile, clean_build)); + jobs.extend(match profile.ovr_comp.mod_type { + OvrCompatibilityModuleType::Opencomposite => { + get_build_opencomposite_jobs(&profile, clean_build) + } + OvrCompatibilityModuleType::Xrizer => { + get_build_xrizer_jobs(&profile, clean_build) + } + OvrCompatibilityModuleType::Vapor => { + get_build_vapor_jobs(&profile, clean_build) + } + }); let missing_deps = profile.missing_dependencies(); if !(self.skip_depcheck || profile.skip_dependency_check || missing_deps.is_empty()) { @@ -562,6 +661,10 @@ impl AsyncComponent for App { if dep_pkexec().check() { self.setcap_confirm_dialog.present(Some(&self.app_win)); } else { + self.build_window + .sender() + .emit(BuildWindowMsg::UpdateContent(vec![TermColor::Red + .colorize("pkexec not found, cannot set capabilities\n")])); alert_w_widget( "pkexec not found", Some(&format!( @@ -584,7 +687,7 @@ impl AsyncComponent for App { self.build_window .sender() .emit(BuildWindowMsg::UpdateBuildStatus(BuildStatus::Error( - format!("Exit status {}", errcode), + format!("Exit status {errcode}"), ))); } }; @@ -594,9 +697,16 @@ impl AsyncComponent for App { w.stop(); } } - Msg::DeleteProfile => { + Msg::DeleteProfile(delete_files) => { let todel = self.get_selected_profile(); if todel.editable { + if delete_files { + for res in todel.delete_files() { + if let Err(e) = res { + error!("Error deleting profile directory: {e}"); + } + } + } self.config.user_profiles.retain(|p| p.uuid != todel.uuid); self.config.save(); self.profiles = self.config.profiles(); @@ -630,13 +740,51 @@ impl AsyncComponent for App { self.debug_view .sender() .emit(DebugViewMsg::UpdateSelectedProfile(prof.clone())); + self.main_view + .sender() + .emit(MainViewMsg::QueryProfileRebuild); } Msg::RunSetCap => { if !dep_pkexec().check() { - println!("pkexec not found, skipping setcap"); + // there's a precheck ahead of this, this should likely never happen + error!("pkexec not found, skipping setcap"); } else { let profile = self.get_selected_profile(); - setcap_cap_sys_nice_eip(&profile).await; + let setcap_failed_dialog = || { + alert_w_widget( + "Setcap failed to run", + Some("Setting the capabilities automatically failed, you can still try manually using the command below." + ), + Some(&copiable_code_snippet( + &format!("sudo {}", setcap_cap_sys_nice_eip_cmd(&profile).join(" ")) + )), + Some(&self.app_win.clone().upcast()) + ); + }; + if let Err(e) = setcap_cap_sys_nice_eip(&profile).await { + setcap_failed_dialog(); + error!("failed running setcap: {e}"); + self.build_window + .sender() + .emit(BuildWindowMsg::UpdateContent(vec![ + TermColor::Red.colorize("Setting capabilities failed\n") + ])); + } else if !verify_cap_sys_nice_eip(&profile).await { + setcap_failed_dialog(); + error!("setcap succeeded but capabilities were reset"); + self.build_window + .sender() + .emit(BuildWindowMsg::UpdateContent(vec![TermColor::Red + .colorize( + "Setting capabilities succeeded, but capabilities have been reset\n", + )])); + } else { + self.build_window + .sender() + .emit(BuildWindowMsg::UpdateContent(vec![ + TermColor::Green.colorize("Capabilities set correctly\n") + ])); + } } } Msg::ProfileSelected(prof) => { @@ -756,6 +904,21 @@ impl AsyncComponent for App { } } } + Msg::OpenPluginStore => { + let pluginstore = PluginStore::builder() + .launch(PluginStoreInit { + config_plugins: self.config.plugins.clone(), + }) + .forward(sender.input_sender(), move |msg| match msg { + PluginStoreOutMsg::UpdateConfigPlugins(cp) => Msg::UpdateConfigPlugins(cp), + }); + pluginstore.sender().emit(PluginStoreMsg::Present); + self.pluginstore = Some(pluginstore); + } + Msg::UpdateConfigPlugins(cp) => { + self.config.plugins = cp; + self.config.save(); + } } } @@ -857,6 +1020,28 @@ impl AsyncComponent for App { } ) ); + stateless_action!( + actions, + PluginStoreAction, + clone!( + #[strong] + sender, + move |_| { + sender.input(Msg::OpenPluginStore); + } + ) + ); + stateless_action!( + actions, + ThemeManagerAction, + clone!( + #[strong] + sender, + move |_| { + sender.input(Msg::ShowThemeManager); + } + ) + ); // this bypasses the macro because I need the underlying gio action // to enable/disable it in update() let configure_wivrn_action = { @@ -878,7 +1063,7 @@ impl AsyncComponent for App { match VulkanInfo::get() { Ok(info) => Some(info), Err(e) => { - eprintln!("Failed to get Vulkan info: {e:#?}"); + error!("failed to get Vulkan info: {e:#?}"); None } } @@ -893,15 +1078,15 @@ impl AsyncComponent for App { config: config.clone(), selected_profile: selected_profile.clone(), root_win: root.clone().into(), - vkinfo: vkinfo.clone(), }) .forward(sender.input_sender(), |message| match message { MainViewOutMsg::DoStartStopXRService => Msg::DoStartStopXRService, MainViewOutMsg::RestartXRService => Msg::RestartXRService, MainViewOutMsg::ProfileSelected(uuid) => Msg::ProfileSelected(uuid), - MainViewOutMsg::DeleteProfile => Msg::DeleteProfile, + MainViewOutMsg::DeleteProfile(delete_files) => Msg::DeleteProfile(delete_files), MainViewOutMsg::SaveProfile(p) => Msg::SaveProfile(p), MainViewOutMsg::OpenLibsurviveSetup => Msg::OpenLibsurviveSetup, + MainViewOutMsg::BuildProfile(clean) => Msg::BuildProfile(clean), }), vkinfo, debug_view: DebugView::builder() @@ -925,10 +1110,21 @@ impl AsyncComponent for App { .detach(), split_view: None, setcap_confirm_dialog, + theme_engine: ThemeEngine::new_with_theme(&{ + if config.theme_name == "Follow system" { + Theme::default() + } else { + Theme::default_themes() + .into_iter() + .find(|t| t.name == config.theme_name) + .unwrap_or_default() + } + }) + .unwrap(), config, profiles, xrservice_worker: None, - autostart_worker: None, + plugins_worker: None, build_worker: None, xr_devices: vec![], restart_xrservice: false, @@ -939,6 +1135,7 @@ impl AsyncComponent for App { openxr_prober_worker: None, xrservice_ready: false, inhibit_fail_notif: None, + pluginstore: None, }; let widgets = view_output!(); @@ -1011,6 +1208,8 @@ new_stateless_action!(pub BuildProfileCleanAction, AppActionGroup, "buildprofile new_stateless_action!(pub QuitAction, AppActionGroup, "quit"); new_stateful_action!(pub DebugViewToggleAction, AppActionGroup, "debugviewtoggle", (), bool); new_stateless_action!(pub ConfigureWivrnAction, AppActionGroup, "configurewivrn"); +new_stateless_action!(pub PluginStoreAction, AppActionGroup, "store"); +new_stateless_action!(pub ThemeManagerAction, AppActionGroup, "thememanager"); new_stateless_action!(pub DebugOpenDataAction, AppActionGroup, "debugopendata"); new_stateless_action!(pub DebugOpenPrefixAction, AppActionGroup, "debugopenprefix"); diff --git a/src/ui/build_window.rs b/src/ui/build_window.rs index 759af9b..1bc966b 100644 --- a/src/ui/build_window.rs +++ b/src/ui/build_window.rs @@ -1,3 +1,5 @@ +use crate::termcolor::TermColor; + use super::{term_widget::TermWidget, SENDER_IO_ERR_MSG}; use adw::prelude::*; use relm4::prelude::*; @@ -88,43 +90,54 @@ impl SimpleComponent for BuildWindow { gtk::Label { #[track = "model.changed(BuildWindow::build_status())"] set_markup: match &model.build_status { - BuildStatus::Building => "Build in progress...".to_string(), - BuildStatus::Done => "Build done, you can close this window".to_string(), + BuildStatus::Building => String::default(), + BuildStatus::Done => "Build done, you can close this window".into(), BuildStatus::Error(code) => { - format!("Build failed: \"{c}\"", c = code) + format!("Build failed: \"{code}\"") } }.as_str(), + #[track = "model.changed(BuildWindow::build_status())"] + set_visible: match &model.build_status { + BuildStatus::Building => false, + BuildStatus::Done | BuildStatus::Error(_) => true, + }, add_css_class: "title-2", set_wrap: true, set_wrap_mode: gtk::pango::WrapMode::Word, set_justify: gtk::Justification::Center, }, - gtk::Button { - #[track = "model.changed(BuildWindow::build_status())"] - set_visible: matches!(&model.build_status, BuildStatus::Building), - add_css_class: "destructive-action", - add_css_class: "circular", - set_icon_name: "window-close-symbolic", - set_tooltip_text: Some("Cancel build"), - connect_clicked[sender] => move |_| { - sender.output(Self::Output::CancelBuild).expect(SENDER_IO_ERR_MSG); - } - }, }, model.term.container.clone(), }, - add_bottom_bar: bottom_bar = >k::Button { - add_css_class: "pill", + add_bottom_bar: bottom_bar = >k::Box { + set_orientation: gtk::Orientation::Horizontal, set_halign: gtk::Align::Center, - set_label: "Close", - set_margin_all: 12, - #[track = "model.changed(BuildWindow::can_close())"] - set_sensitive: model.can_close, - connect_clicked[win] => move |_| { - - win.close(); + set_hexpand: true, + set_margin_bottom: 24, + set_spacing: 12, + gtk::Button { + add_css_class: "pill", + set_halign: gtk::Align::Center, + set_label: "Close", + #[track = "model.changed(BuildWindow::can_close())"] + set_visible: model.can_close, + connect_clicked[win] => move |_| { + win.close(); + }, }, - } + // this + gtk::Button { + #[track = "model.changed(BuildWindow::build_status())"] + set_visible: matches!(&model.build_status, BuildStatus::Building), + add_css_class: "destructive-action", + add_css_class: "pill", + set_label: "Cancel build", + connect_clicked[sender] => move |_| { + sender.output(Self::Output::CancelBuild).expect(SENDER_IO_ERR_MSG); + } + }, + // ^^^ + }, } } } @@ -153,8 +166,18 @@ impl SimpleComponent for BuildWindow { label.remove_css_class("success"); label.remove_css_class("error"); match status { - BuildStatus::Done => label.add_css_class("success"), - BuildStatus::Error(_) => label.add_css_class("error"), + BuildStatus::Done => { + label.add_css_class("success"); + sender.input(BuildWindowMsg::UpdateContent(vec![ + TermColor::Blue.colorize("Build completed!\n") + ])); + } + BuildStatus::Error(_) => { + label.add_css_class("error"); + sender.input(BuildWindowMsg::UpdateContent(vec![ + TermColor::Blue.colorize("Build failed!\n") + ])); + } _ => {} } if status != BuildStatus::Building { diff --git a/src/ui/cmdline_opts.rs b/src/ui/cmdline_opts.rs index ca4e14d..087ee74 100644 --- a/src/ui/cmdline_opts.rs +++ b/src/ui/cmdline_opts.rs @@ -1,4 +1,7 @@ -use crate::config::Config; +use crate::{ + config::Config, + constants::{APP_NAME, VERSION}, +}; use gtk::{ gio::{ prelude::{ApplicationCommandLineExt, ApplicationExt}, @@ -6,9 +9,11 @@ use gtk::{ }, glib::{self, prelude::IsA}, }; +use tracing::error; #[derive(Debug, Clone)] pub struct CmdLineOpts { + pub version: bool, pub start: bool, pub list_profiles: bool, pub profile_uuid: Option, @@ -17,6 +22,7 @@ pub struct CmdLineOpts { } impl CmdLineOpts { + const OPT_VERSION: (&'static str, char) = ("version", 'v'); const OPT_START: (&'static str, char) = ("start", 'S'); const OPT_LIST_PROFILES: (&'static str, char) = ("list-profiles", 'l'); const OPT_PROFILE: (&'static str, char) = ("profile", 'p'); @@ -24,6 +30,14 @@ impl CmdLineOpts { const OPT_CHECK_DEPS_FOR: (&'static str, char) = ("check-deps-for", 'c'); pub fn init(app: &impl IsA) { + app.add_main_option( + Self::OPT_VERSION.0, + glib::Char::try_from(Self::OPT_VERSION.1).unwrap(), + glib::OptionFlags::IN_MAIN, + glib::OptionArg::None, + "Print the version information", + None, + ); app.add_main_option( Self::OPT_START.0, glib::Char::try_from(Self::OPT_START.1).unwrap(), @@ -68,6 +82,10 @@ impl CmdLineOpts { /// returns an exit code if the application should quit immediately pub fn handle_non_activating_opts(&self) -> Option { + if self.version { + println!("{APP_NAME} {VERSION}"); + return Some(0); + } if self.list_profiles { println!("Available profiles\nUUID: \"name\""); let profiles = Config::get_config().profiles(); @@ -88,7 +106,7 @@ impl CmdLineOpts { } return Some(1); } else { - eprintln!("No profile found for uuid: `{prof_id}`"); + error!("No profile found for uuid: `{prof_id}`"); return Some(404); } } @@ -98,6 +116,7 @@ impl CmdLineOpts { pub fn from_cmdline(cmdline: &ApplicationCommandLine) -> Self { let opts = cmdline.options_dict(); Self { + version: opts.contains(Self::OPT_VERSION.0), start: opts.contains(Self::OPT_START.0), list_profiles: opts.contains(Self::OPT_LIST_PROFILES.0), profile_uuid: opts diff --git a/src/ui/devices_box.rs b/src/ui/devices_box.rs index 78b2abe..64496b1 100644 --- a/src/ui/devices_box.rs +++ b/src/ui/devices_box.rs @@ -61,9 +61,19 @@ impl SimpleComponent for DevicesBox { } if !has_left && dev.roles.contains(&XRDeviceRole::Left) { has_left = true; + if ["Qwerty Left Controller"].contains(&dev.name.as_str()) { + row_model.state = Some(DeviceRowState::Warning); + row_model.subtitle = + Some(format!("No left controller detected ({})", dev.name)); + } } if !has_right && dev.roles.contains(&XRDeviceRole::Right) { has_right = true; + if ["Qwerty Right Controller"].contains(&dev.name.as_str()) { + row_model.state = Some(DeviceRowState::Warning); + row_model.subtitle = + Some(format!("No right controller detected ({})", dev.name)); + } } models.push(row_model); } diff --git a/src/ui/install_wivrn_box.rs b/src/ui/install_wivrn_box.rs index cf63f7c..769e012 100644 --- a/src/ui/install_wivrn_box.rs +++ b/src/ui/install_wivrn_box.rs @@ -8,6 +8,7 @@ use crate::{ use gtk::prelude::*; use relm4::{new_action_group, new_stateless_action, prelude::*}; use std::fs::remove_file; +use tracing::error; const WIVRN_LATEST_RELEASE_APK_URL: &str = "https://github.com/WiVRn/WiVRn/releases/latest/download/WiVRn-standard-release.apk"; @@ -113,7 +114,7 @@ impl AsyncComponent for InstallWivrnBox { add_css_class: "dim-label", set_hexpand: true, set_label: concat!( - "Install the WiVRn APK on your standalong Android headset. ", + "Install the WiVRn APK on your standalone Android headset. ", "You will need to enable Developer Mode on your headset, ", "then press the \"Install WiVRn\" button." ), @@ -172,7 +173,7 @@ impl AsyncComponent for InstallWivrnBox { match get_wivrn_apk_ref(&self.selected_profile) { Err(GetWivrnApkRefErr::NotWivrn) => { - eprintln!("This is not a WiVRn profile, how did you get here?"); + error!("this is not a WiVRn profile, how did you get here?"); } Err(GetWivrnApkRefErr::RepoDirNotFound) => { self.set_install_wivrn_status(InstallWivrnStatus::Done(Some( @@ -180,14 +181,11 @@ impl AsyncComponent for InstallWivrnBox { ))); } Err(GetWivrnApkRefErr::RepoManipulationFailed(giterr)) => { - eprintln!("Error: failed to manipulate WiVRn repo: {giterr}, falling back to latest release APK"); + error!("failed to manipulate WiVRn repo: {giterr}, falling back to latest release APK"); let existing = cache_file_path(WIVRN_LATEST_RELEASE_APK_URL, Some("apk")); if existing.is_file() { if let Err(e) = remove_file(&existing) { - eprintln!( - "Failed to remove file {}: {e}", - existing.to_string_lossy() - ); + error!("failed to remove file {}: {e}", existing.to_string_lossy()); } } sender.input(Self::Input::DoInstall(WIVRN_LATEST_RELEASE_APK_URL.into())); @@ -208,7 +206,7 @@ impl AsyncComponent for InstallWivrnBox { // TODO: we gonna cache or just download async every time? match cache_file(&url, Some("apk")).await { Err(e) => { - eprintln!("Failed to download apk: {e}"); + error!("failed to download apk: {e}"); self.set_install_wivrn_status(InstallWivrnStatus::Done(Some( "Error downloading WiVRn client APK".into(), ))); @@ -236,14 +234,14 @@ impl AsyncComponent for InstallWivrnBox { .into(), )) } else { - eprintln!("Error: ADB failed with code {}.\nstdout:\n{}\n======\nstderr:\n{}", out.exit_code, out.stdout, out.stderr); + error!("ADB failed with code {}.\nstdout:\n{}\n======\nstderr:\n{}", out.exit_code, out.stdout, out.stderr); InstallWivrnStatus::Done(Some( format!("ADB exited with code \"{}\"", out.exit_code) )) } } Err(e) => { - eprintln!("Error: failed to run ADB: {e}"); + error!("failed to run ADB: {e}"); InstallWivrnStatus::Done(Some( "Failed to run ADB".into() )) diff --git a/src/ui/job_worker/internal_worker.rs b/src/ui/job_worker/internal_worker.rs index 25c40a2..437e6a9 100644 --- a/src/ui/job_worker/internal_worker.rs +++ b/src/ui/job_worker/internal_worker.rs @@ -155,7 +155,7 @@ impl Worker for InternalJobWorker { } } -const LAUNCH_OPTS_CMD_PLACEHOLDER: &str = "%command%"; +pub const LAUNCH_OPTS_CMD_PLACEHOLDER: &str = "%command%"; impl InternalJobWorker { pub fn xrservice_worker_from_profile( @@ -193,9 +193,6 @@ impl InternalJobWorker { } else { launch_opts }; - if !launch_opts.contains(" --no-instructions") { - launch_opts.push_str(" --no-instructions"); - } let (command, args) = match launch_opts.is_empty() { false => ( "sh".into(), diff --git a/src/ui/job_worker/mod.rs b/src/ui/job_worker/mod.rs index 4de1c78..c50ab21 100644 --- a/src/ui/job_worker/mod.rs +++ b/src/ui/job_worker/mod.rs @@ -15,6 +15,7 @@ use std::{ thread::{self, sleep}, time::Duration, }; +use tracing::{error, warn}; pub mod internal_worker; pub mod job; @@ -97,7 +98,7 @@ impl JobWorker { self.state.lock().unwrap().stop_requested = true; if let Some(pid) = self.state.lock().unwrap().current_pid { if let Err(e) = kill(pid, SIGTERM) { - eprintln!("Failed to send SIGTERM: {e:#?}"); + error!("Failed to send SIGTERM: {e}"); } let state = self.state.clone(); thread::spawn(move || { @@ -105,9 +106,9 @@ impl JobWorker { if let Ok(s) = state.lock() { if !s.exited { // process is still alive - eprintln!("Process is still alive 2 seconds after SIGTERM, proceeding to send SIGKILL..."); + warn!("process is still alive 2 seconds after SIGTERM, proceeding to send SIGKILL..."); if let Err(e) = kill(pid, SIGKILL) { - eprintln!("Failed to send SIGKILL: {e:#?}"); + error!("failed to send SIGKILL: {e}"); }; } } diff --git a/src/ui/main_view.rs b/src/ui/main_view.rs index 1800a38..8a2c145 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -2,7 +2,7 @@ use super::{ alert::alert, app::{ AboutAction, BuildProfileAction, BuildProfileCleanAction, ConfigureWivrnAction, - DebugViewToggleAction, + DebugViewToggleAction, PluginStoreAction, }, devices_box::{DevicesBox, DevicesBoxMsg}, install_wivrn_box::{InstallWivrnBox, InstallWivrnBoxInit, InstallWivrnBoxMsg}, @@ -12,6 +12,7 @@ use super::{ steamvr_calibration_box::{SteamVrCalibrationBox, SteamVrCalibrationBoxMsg}, util::{limit_dropdown_width, warning_heading}, wivrn_wired_start_box::{WivrnWiredStartBox, WivrnWiredStartBoxInit, WivrnWiredStartBoxMsg}, + SENDER_IO_ERR_MSG, }; use crate::{ config::Config, @@ -20,11 +21,11 @@ use crate::{ paths::{get_data_dir, get_home_dir}, profile::{LighthouseDriver, Profile, XRServiceType}, stateless_action, + ui::app::ThemeManagerAction, util::{ file_utils::{get_writer, mount_has_nosuid}, steamvr_utils::chaperone_info_exists, }, - vulkaninfo::VulkanInfo, wivrn_dbus, xr_devices::XRDevice, }; @@ -36,6 +37,7 @@ use relm4::{ prelude::*, }; use std::{fs::read_to_string, io::Write}; +use tracing::{error, warn}; #[tracker::track] pub struct MainView { @@ -59,6 +61,8 @@ pub struct MainView { #[tracker::do_not_track] profile_delete_confirm_dialog: adw::AlertDialog, #[tracker::do_not_track] + query_profile_rebuild_dialog: adw::AlertDialog, + #[tracker::do_not_track] profile_editor: Option>, #[tracker::do_not_track] steamvr_calibration_box: Controller, @@ -71,8 +75,6 @@ pub struct MainView { #[tracker::do_not_track] profile_export_action: gtk::gio::SimpleAction, xrservice_ready: bool, - #[tracker::do_not_track] - vkinfo: Option, wivrn_pairing_mode: bool, wivrn_pin: Option, wivrn_supports_pairing: bool, @@ -103,6 +105,7 @@ pub enum MainViewMsg { SetWivrnPairingMode(bool), StopWivrnPairingMode, StartWivrnPairingMode, + QueryProfileRebuild, } #[derive(Debug)] @@ -110,16 +113,18 @@ pub enum MainViewOutMsg { DoStartStopXRService, RestartXRService, ProfileSelected(Profile), - DeleteProfile, + /// bool param: delete files + DeleteProfile(bool), SaveProfile(Profile), OpenLibsurviveSetup, + /// params: clean + BuildProfile(bool), } pub struct MainViewInit { pub config: Config, pub selected_profile: Profile, pub root_win: gtk::Window, - pub vkinfo: Option, } impl MainView { @@ -147,6 +152,7 @@ impl AsyncComponent for MainView { menu! { app_menu: { section! { + "Plugin_s" => PluginStoreAction, // value inside action is ignored "_Debug View" => DebugViewToggleAction, "_Build Profile" => BuildProfileAction, @@ -154,6 +160,7 @@ impl AsyncComponent for MainView { "Configure _WiVRn" => ConfigureWivrnAction, }, section! { + "Change _Theme" => ThemeManagerAction, "_About" => AboutAction, }, }, @@ -389,8 +396,8 @@ impl AsyncComponent for MainView { set_visible: match mount_has_nosuid(&model.selected_profile.prefix) { Ok(b) => b, Err(_) => { - eprintln!( - "Warning (nosuid detection): could not get stat on path {}", + warn!( + "nosuid detection: could not get stat on path {}", model.selected_profile.prefix.to_string_lossy()); false }, @@ -445,35 +452,7 @@ impl AsyncComponent for MainView { set_label: concat!( "SteamVR room configuration not found.\n", "To use the SteamVR lighthouse driver, you ", - "will need to run SteamVR and perform the room setup.", - ), - add_css_class: "warning", - set_xalign: 0.0, - set_wrap: true, - set_wrap_mode: gtk::pango::WrapMode::Word, - } - }, - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_hexpand: true, - set_vexpand: false, - set_spacing: 12, - add_css_class: "card", - add_css_class: "padded", - set_visible: model - .vkinfo - .as_ref() - .is_some_and( - |i| i.has_nvidia_gpu && !i.has_monado_vulkan_layers - ), - warning_heading(), - gtk::Label { - set_label: concat!( - "An Nvidia GPU has been detected, but it ", - "seems you don't have the Monado Vulkan Layers ", - "installed on your system.\n\nInstall the ", - "Monado Vulkan Layers or your XR session will ", - "crash." + "will need to run SteamVR Quick Calibration.", ), add_css_class: "warning", set_xalign: 0.0, @@ -627,7 +606,7 @@ impl AsyncComponent for MainView { self.set_wivrn_pin(Some(pin)); } Err(e) => { - eprintln!("Error: failed to get wivrn pairing pin: {e:?}"); + error!("failed to get wivrn pairing pin: {e}"); } }; } else { @@ -637,12 +616,12 @@ impl AsyncComponent for MainView { } Self::Input::StopWivrnPairingMode => { if let Err(e) = wivrn_dbus::disable_pairing().await { - eprintln!("Error: failed to stop wivrn pairing mode: {e:?}"); + error!("failed to stop wivrn pairing mode: {e}"); } } Self::Input::StartWivrnPairingMode => { if let Err(e) = wivrn_dbus::enable_pairing().await { - eprintln!("Error: failed to start wivrn pairing mode: {e:?}"); + error!("failed to start wivrn pairing mode: {e}"); } } Self::Input::StartStopClicked => { @@ -720,6 +699,10 @@ impl AsyncComponent for MainView { } })); } + Self::Input::QueryProfileRebuild => { + self.query_profile_rebuild_dialog + .present(Some(&self.root_win)); + } Self::Input::SetSelectedProfile(index) => { self.profiles_dropdown .as_ref() @@ -757,7 +740,7 @@ impl AsyncComponent for MainView { Self::Input::SaveProfile(prof) => { sender .output(Self::Output::SaveProfile(prof)) - .expect("Sender output failed"); + .expect(SENDER_IO_ERR_MSG); } Self::Input::DuplicateProfile => { if self.selected_profile.can_be_built { @@ -926,8 +909,38 @@ impl AsyncComponent for MainView { ), ); + let query_profile_rebuild_dialog = adw::AlertDialog::builder() + .heading("Do you want to build this profile now?") + .body("This will trigger a clean build") + .build(); + query_profile_rebuild_dialog.add_response("no", "_No"); + query_profile_rebuild_dialog.add_response("yes", "_Yes"); + query_profile_rebuild_dialog.set_response_appearance("yes", ResponseAppearance::Suggested); + + query_profile_rebuild_dialog.connect_response( + None, + clone!( + #[strong] + sender, + move |_, res| { + if res == "yes" { + sender + .output(Self::Output::BuildProfile(true)) + .expect(SENDER_IO_ERR_MSG); + } + } + ), + ); + let profile_delete_confirm_dialog = adw::AlertDialog::builder() .heading("Are you sure you want to delete this profile?") + .extra_child( + >k::CheckButton::builder() + .label("Delete all files and folders associated with profile") + .halign(gtk::Align::Center) + .hexpand(true) + .build(), + ) .build(); profile_delete_confirm_dialog.add_response("no", "_No"); profile_delete_confirm_dialog.add_response("yes", "_Yes"); @@ -939,10 +952,19 @@ impl AsyncComponent for MainView { clone!( #[strong] sender, - move |_, res| { + move |dialog, res| { + let delete_files_checkbox = dialog + .extra_child() + .and_then(|child| child.downcast::().ok()); + let delete_files = delete_files_checkbox + .as_ref() + .is_some_and(|c| c.is_active()); + if let Some(check) = delete_files_checkbox { + check.set_active(false); + } if res == "yes" { sender - .output(Self::Output::DeleteProfile) + .output(Self::Output::DeleteProfile(delete_files)) .expect("Sender output failed"); } } @@ -1060,6 +1082,7 @@ impl AsyncComponent for MainView { selected_profile: init.selected_profile.clone(), profile_not_editable_dialog, profile_delete_confirm_dialog, + query_profile_rebuild_dialog, root_win: init.root_win.clone(), steamvr_calibration_box, openhmd_calibration_box, @@ -1067,7 +1090,6 @@ impl AsyncComponent for MainView { xrservice_ready: false, profile_delete_action, profile_export_action, - vkinfo: init.vkinfo, wivrn_pairing_mode: false, wivrn_supports_pairing: false, wivrn_pin: None, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2a5dd01..f68333c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,6 +13,7 @@ mod libsurvive_setup_window; mod macros; mod main_view; mod openhmd_calibration_box; +pub mod plugins; mod preference_rows; mod profile_editor; mod steam_launch_options_box; diff --git a/src/ui/openhmd_calibration_box.rs b/src/ui/openhmd_calibration_box.rs index c8da5dc..6258ad1 100644 --- a/src/ui/openhmd_calibration_box.rs +++ b/src/ui/openhmd_calibration_box.rs @@ -3,6 +3,7 @@ use relm4::{ gtk::{self, prelude::*}, ComponentParts, ComponentSender, SimpleComponent, }; +use tracing::{debug, error}; #[tracker::track] pub struct OpenHmdCalibrationBox { @@ -59,10 +60,10 @@ impl SimpleComponent for OpenHmdCalibrationBox { let target = XDG.get_config_home().join("openhmd/rift-room-config.json"); if target.is_file() { if let Err(e) = std::fs::remove_file(target) { - eprintln!("Failed to remove openhmd config: {e}"); + error!("Failed to remove openhmd config: {e}"); } } else { - println!("info: trying to delete openhmd calibration config, but file is missing") + debug!("trying to delete openhmd calibration config, but file is missing") } } }, diff --git a/src/ui/plugins/add_custom_plugin_win.rs b/src/ui/plugins/add_custom_plugin_win.rs new file mode 100644 index 0000000..734a4ea --- /dev/null +++ b/src/ui/plugins/add_custom_plugin_win.rs @@ -0,0 +1,199 @@ +use std::path::PathBuf; + +use crate::{ + constants::APP_ID, + ui::{ + preference_rows::{entry_row, file_row}, + SENDER_IO_ERR_MSG, + }, +}; + +use super::Plugin; +use adw::prelude::*; +use gtk::glib::clone; +use relm4::prelude::*; + +#[tracker::track] +pub struct AddCustomPluginWin { + #[tracker::do_not_track] + parent: gtk::Window, + #[tracker::do_not_track] + win: Option, + /// this is true when enough fields are populated, allowing the creation + /// of the plugin object to add + can_add: bool, + #[tracker::do_not_track] + plugin: Plugin, +} + +#[derive(Debug)] +pub enum AddCustomPluginWinMsg { + Present, + Close, + OnNameChange(String), + OnArgsChange(String), + OnExecPathChange(Option), + Add, +} + +#[derive(Debug)] +pub enum AddCustomPluginWinOutMsg { + Add(Plugin), +} + +#[derive(Debug)] +pub struct AddCustomPluginWinInit { + pub parent: gtk::Window, +} + +#[relm4::component(pub)] +impl SimpleComponent for AddCustomPluginWin { + type Init = AddCustomPluginWinInit; + type Input = AddCustomPluginWinMsg; + type Output = AddCustomPluginWinOutMsg; + + view! { + #[name(win)] + adw::Dialog { + set_can_close: true, + #[wrap(Some)] + set_child: inner = &adw::ToolbarView { + set_top_bar_style: adw::ToolbarStyle::Flat, + set_bottom_bar_style: adw::ToolbarStyle::Flat, + set_vexpand: true, + set_hexpand: true, + add_top_bar: top_bar = &adw::HeaderBar { + set_show_end_title_buttons: false, + set_show_start_title_buttons: false, + pack_start: cancel_btn = >k::Button { + set_label: "Cancel", + add_css_class: "destructive-action", + connect_clicked[sender] => move |_| { + sender.input(Self::Input::Close) + }, + }, + pack_end: add_btn = >k::Button { + set_label: "Add", + add_css_class: "suggested-action", + #[track = "model.changed(AddCustomPluginWin::can_add())"] + set_sensitive: model.can_add, + connect_clicked[sender] => move |_| { + sender.input(Self::Input::Add) + }, + }, + #[wrap(Some)] + set_title_widget: title_label = &adw::WindowTitle { + set_title: "Add Custom Plugin", + }, + }, + #[wrap(Some)] + set_content: content = &adw::PreferencesPage { + set_hexpand: true, + set_vexpand: true, + add: grp = &adw::PreferencesGroup { + add: &entry_row( + "Plugin Name", + "", + clone!( + #[strong] sender, + move |row| sender.input( + Self::Input::OnNameChange( + row.text().to_string() + ) + ) + ) + ), + add: &file_row( + "Plugin Executable", + None, + None, + Some(model.parent.clone()), + clone!( + #[strong] sender, + move |path_s| sender.input( + Self::Input::OnExecPathChange(path_s) + ) + ) + ), + add: &entry_row( + "Plugin Arguments", + "", + clone!( + #[strong] sender, + move |row| sender.input( + Self::Input::OnArgsChange( + row.text().to_string() + ) + ) + ) + ), + }, + }, + }, + } + } + + fn update(&mut self, message: Self::Input, sender: ComponentSender) { + self.reset(); + + match message { + Self::Input::Present => self.win.as_ref().unwrap().present(Some(&self.parent)), + Self::Input::Close => { + self.win.as_ref().unwrap().close(); + } + Self::Input::Add => { + if self.plugin.validate() { + sender + .output(Self::Output::Add(self.plugin.clone())) + .expect(SENDER_IO_ERR_MSG); + self.win.as_ref().unwrap().close(); + } + } + Self::Input::OnNameChange(name) => { + self.plugin.appid = if !name.is_empty() { + format!("{APP_ID}.customPlugin.{name}") + } else { + String::default() + }; + self.plugin.name = name; + self.set_can_add(self.plugin.validate()); + } + Self::Input::OnArgsChange(args) => { + let args = args.trim().to_string(); + self.plugin.args = if args.is_empty() { + None + } else { + // it's fine to have them joined + // since they will ultimately be + // passed as a joined string + Some(vec![args]) + } + } + Self::Input::OnExecPathChange(ep) => { + self.plugin.exec_path = ep.map(PathBuf::from); + self.set_can_add(self.plugin.validate()); + } + } + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let mut model = Self { + tracker: 0, + win: None, + parent: init.parent, + can_add: false, + plugin: Plugin { + short_description: Some("Custom Plugin".into()), + ..Default::default() + }, + }; + let widgets = view_output!(); + model.win = Some(widgets.win.clone()); + + ComponentParts { model, widgets } + } +} diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs new file mode 100644 index 0000000..cb18440 --- /dev/null +++ b/src/ui/plugins/mod.rs @@ -0,0 +1,189 @@ +pub mod add_custom_plugin_win; +pub mod store; +mod store_detail; +mod store_row_factory; + +use crate::{ + constants::APP_ID, + downloader::{cache_file_path, download_file_async}, + file_builders::wayvr_dashboard_config::{ + WayVrDashboardConfigFragment, WayVrDashboardConfigFragmentInner, + }, + paths::get_plugins_dir, + util::file_utils::{get_writer, mark_as_executable}, + xdg::XDG, +}; +use anyhow::bail; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs::{create_dir_all, remove_file}, + path::PathBuf, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)] +pub enum PluginType { + #[default] + Executable, + WayVrApp, + WayVrDashboard, +} + +impl PluginType { + pub fn launches_directly(&self) -> bool { + self == &Self::Executable + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)] +pub struct Plugin { + pub appid: String, + pub name: String, + pub author: Option, + pub icon_url: Option, + pub version: Option, + pub short_description: Option, + pub description: Option, + pub homepage_url: Option, + pub screenshots: Vec, + /// either one of exec_url or exec_path must be provided + pub exec_url: Option, + /// either one of exec_url or exec_path must be provided + pub exec_path: Option, + /// options and arguments that should be passed to the plugin executable + pub args: Option>, + pub env_vars: Option>, + /// defined as a list of appids of other plugins + pub dependencies: Option>, + /// defined as a list of appids of other plugins + /// all plugins of type WayVrDashboard should conflict with each other by default + pub conflicts: Option>, + #[serde(default = "PluginType::default")] + pub plugin_type: PluginType, +} + +impl Plugin { + fn wayvr_config_fragment_filename(&self) -> String { + format!("99-{APP_ID}-plugin.{}.yaml", self.appid) + } + + fn wayvr_conf_dir() -> PathBuf { + XDG.get_config_home().join("wlxoverlay/wayvr.conf.d") + } + + pub fn enable(&self) -> anyhow::Result<()> { + match self.plugin_type { + PluginType::Executable => {} + PluginType::WayVrApp => todo!(), + PluginType::WayVrDashboard => { + let wayvr_conf_dir = Self::wayvr_conf_dir(); + if !wayvr_conf_dir.exists() { + create_dir_all(&wayvr_conf_dir)?; + } else if wayvr_conf_dir.is_file() { + bail!("wayvr.conf.d is a file and not a directory") + } + let config_fragment = WayVrDashboardConfigFragment { + dashboard: WayVrDashboardConfigFragmentInner { + exec: self + .executable() + .ok_or(anyhow::Error::msg("executable missing"))? + .to_string_lossy() + .to_string(), + args: self.args.as_ref().map(|args| args.join(" ")), + env: self + .env_vars + .as_ref() + .map(|vars| vars.iter().map(|(k, v)| format!("{k}={v}")).collect()), + }, + }; + let writer = + get_writer(&wayvr_conf_dir.join(self.wayvr_config_fragment_filename()))?; + serde_yaml::to_writer(writer, &config_fragment)?; + } + } + Ok(()) + } + + pub fn disable(&self) -> anyhow::Result<()> { + match self.plugin_type { + PluginType::Executable => {} + PluginType::WayVrApp => todo!(), + PluginType::WayVrDashboard => { + let wayvr_conf_dir = Self::wayvr_conf_dir(); + remove_file(wayvr_conf_dir.join(self.wayvr_config_fragment_filename()))?; + } + }; + Ok(()) + } + + pub fn set_enabled(&self, enabled: bool) -> anyhow::Result<()> { + if enabled { + self.enable() + } else { + self.disable() + } + } + + pub fn executable(&self) -> Option { + if self.exec_path.is_some() { + self.exec_path.clone() + } else { + let canonical = self.canonical_exec_path(); + if canonical.is_file() { + Some(canonical) + } else { + None + } + } + } + + pub fn canonical_exec_path(&self) -> PathBuf { + get_plugins_dir().join(&self.appid) + } + + pub fn is_installed(&self) -> bool { + self.executable().as_ref().is_some_and(|p| p.is_file()) + } + + pub fn mark_as_executable(&self) -> anyhow::Result<()> { + if let Some(p) = self.executable().as_ref() { + mark_as_executable(p) + } else { + bail!("no executable found for plugin") + } + } + + /// validate if the plugin can be displayed correctly and run + pub fn validate(&self) -> bool { + !self.appid.is_empty() + && !self.name.is_empty() + && self.executable().as_ref().is_some_and(|p| p.is_file()) + } +} + +/// urls to manifest json files representing plugins. +/// each manifest should be json and the link should always point to the latest version +const MANIFESTS: [&str;3] = [ + "https://github.com/galister/wlx-overlay-s/raw/refs/heads/meta/com.github.galister.wlx-overlay-s.json", + "https://github.com/StardustXR/telescope/raw/refs/heads/main/envision/org.stardustxr.telescope.json", + "https://github.com/olekolek1000/wayvr-dashboard/raw/refs/heads/meta/dev.oo8.wayvr_dashboard.json", +]; + +pub async fn refresh_plugins() -> anyhow::Result>> { + let mut results = Vec::new(); + for jh in MANIFESTS + .iter() + .map(|url| -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let path = cache_file_path(url, Some("json")); + download_file_async(url, &path).await?; + Ok(serde_json::from_str::( + &tokio::fs::read_to_string(path).await?, + )?) + }) + }) + { + results.push(jh.await?); + } + Ok(results) +} diff --git a/src/ui/plugins/store.rs b/src/ui/plugins/store.rs new file mode 100644 index 0000000..0faf99a --- /dev/null +++ b/src/ui/plugins/store.rs @@ -0,0 +1,552 @@ +use super::{ + add_custom_plugin_win::{ + AddCustomPluginWin, AddCustomPluginWinInit, AddCustomPluginWinMsg, AddCustomPluginWinOutMsg, + }, + refresh_plugins, + store_detail::{StoreDetail, StoreDetailMsg, StoreDetailOutMsg}, + store_row_factory::{StoreRowModel, StoreRowModelInit, StoreRowModelMsg, StoreRowModelOutMsg}, + Plugin, +}; +use crate::{ + config::PluginConfig, + downloader::download_file_async, + ui::{alert::alert, SENDER_IO_ERR_MSG}, +}; +use adw::prelude::*; +use relm4::{factory::AsyncFactoryVecDeque, prelude::*}; +use std::{collections::HashMap, fs::remove_file}; +use tracing::{debug, error}; + +#[tracker::track] +pub struct PluginStore { + #[tracker::do_not_track] + win: Option, + #[tracker::do_not_track] + plugin_rows: Option>, + #[tracker::do_not_track] + details: AsyncController, + #[tracker::do_not_track] + main_stack: Option, + #[tracker::do_not_track] + config_plugins: HashMap, + refreshing: bool, + locked: bool, + plugins: Vec, + #[tracker::do_not_track] + add_custom_plugin_win: Option>, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum PluginStoreSignalSource { + Row, + Detail, +} + +#[derive(Debug)] +pub enum PluginStoreMsg { + Present, + /// sets state and calls DoRefresh + Refresh, + /// called by Refresh + DoRefresh, + Install(Plugin), + InstallDownload(Vec), + Remove(Plugin), + SetEnabled(PluginStoreSignalSource, Plugin, bool), + ShowDetails(usize), + ShowPluginList, + PresentAddCustomPluginWin, + AddCustomPlugin(Plugin), +} + +#[derive(Debug)] +pub struct PluginStoreInit { + pub config_plugins: HashMap, +} + +#[derive(Debug)] +pub enum PluginStoreOutMsg { + UpdateConfigPlugins(HashMap), +} + +impl PluginStore { + fn refresh_plugin_rows(&mut self) { + let mut guard = self.plugin_rows.as_mut().unwrap().guard(); + guard.clear(); + self.plugins.iter().for_each(|plugin| { + guard.push_back(StoreRowModelInit { + plugin: plugin.clone(), + enabled: self + .config_plugins + .get(&plugin.appid) + .is_some_and(|cp| cp.enabled), + needs_update: self + .config_plugins + .get(&plugin.appid) + .is_some_and(|cp| cp.plugin.version != plugin.version), + }); + }); + } + + fn add_plugin_to_config(&mut self, sender: &relm4::AsyncComponentSender, plugin: Plugin) { + self.config_plugins + .insert(plugin.appid.clone(), PluginConfig::from(&plugin)); + sender + .output(PluginStoreOutMsg::UpdateConfigPlugins( + self.config_plugins.clone(), + )) + .expect(SENDER_IO_ERR_MSG); + } +} + +#[relm4::component(pub async)] +impl AsyncComponent for PluginStore { + type Init = PluginStoreInit; + type Input = PluginStoreMsg; + type Output = PluginStoreOutMsg; + type CommandOutput = (); + + view! { + #[name(win)] + adw::Window { + set_title: Some("Plugins"), + #[name(main_stack)] + gtk::Stack { + add_child = &adw::ToolbarView { + set_top_bar_style: adw::ToolbarStyle::Flat, + add_top_bar: headerbar = &adw::HeaderBar { + pack_start: add_custom_plugin_btn = >k::Button { + set_icon_name: "list-add-symbolic", + set_tooltip_text: Some("Add custom plugin"), + #[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"] + set_sensitive: !(model.refreshing || model.locked), + connect_clicked[sender] => move |_| { + sender.input(Self::Input::PresentAddCustomPluginWin) + }, + }, + pack_end: refreshbtn = >k::Button { + set_icon_name: "view-refresh-symbolic", + set_tooltip_text: Some("Refresh"), + #[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"] + set_sensitive: !(model.refreshing || model.locked), + connect_clicked[sender] => move |_| { + sender.input(Self::Input::Refresh); + }, + }, + }, + #[wrap(Some)] + set_content: inner = >k::Box { + set_orientation: gtk::Orientation::Vertical, + set_hexpand: true, + set_vexpand: true, + gtk::Stack { + set_hexpand: true, + set_vexpand: true, + add_child = >k::ScrolledWindow { + set_hscrollbar_policy: gtk::PolicyType::Never, + set_hexpand: true, + set_vexpand: true, + adw::Clamp { + #[name(listbox)] + gtk::ListBox { + #[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"] + set_sensitive: !(model.refreshing || model.locked), + add_css_class: "boxed-list", + set_valign: gtk::Align::Start, + set_margin_all: 12, + set_selection_mode: gtk::SelectionMode::None, + connect_row_activated[sender] => move |_, row| { + sender.input( + Self::Input::ShowDetails( + row.index() as usize + ) + ); + }, + } + } + } -> { + set_name: "pluginlist" + }, + add_child = >k::Spinner { + set_hexpand: true, + set_vexpand: true, + set_width_request: 32, + set_height_request: 32, + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + #[track = "model.changed(PluginStore::refreshing())"] + set_spinning: model.refreshing, + } -> { + set_name: "spinner" + }, + add_child = &adw::StatusPage { + set_hexpand: true, + set_vexpand: true, + set_title: "No Plugins Found", + set_description: Some("Make sure you're connected to the internet and refresh"), + set_icon_name: Some("application-x-addon-symbolic"), + } -> { + set_name: "emptystate" + }, + #[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::plugins())"] + set_visible_child_name: if model.refreshing { + "spinner" + } else if model.plugins.is_empty() { + "emptystate" + } else { + "pluginlist" + }, + }, + } + } -> { + set_name: "listview" + }, + add_child = &adw::Bin { + #[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"] + set_sensitive: !(model.refreshing || model.locked), + set_child: Some(details_view), + } -> { + set_name: "detailsview", + }, + set_visible_child_name: "listview", + } + } + } + + async fn update( + &mut self, + message: Self::Input, + sender: AsyncComponentSender, + _root: &Self::Root, + ) { + self.reset(); + + match message { + Self::Input::Present => { + self.win.as_ref().unwrap().present(); + sender.input(Self::Input::Refresh); + } + Self::Input::AddCustomPlugin(plugin) => { + if self.config_plugins.contains_key(&plugin.appid) { + alert( + "Failed to add custom plugin", + Some("A plugin with the same name already exists"), + Some(&self.win.as_ref().unwrap().clone().upcast::()), + ); + return; + } + self.add_plugin_to_config(&sender, plugin); + sender.input(Self::Input::Refresh); + } + Self::Input::PresentAddCustomPluginWin => { + let add_win = AddCustomPluginWin::builder() + .launch(AddCustomPluginWinInit { + parent: self.win.as_ref().unwrap().clone().upcast(), + }) + .forward(sender.input_sender(), |msg| match msg { + AddCustomPluginWinOutMsg::Add(plugin) => { + Self::Input::AddCustomPlugin(plugin) + } + }); + add_win.sender().emit(AddCustomPluginWinMsg::Present); + self.add_custom_plugin_win = Some(add_win); + } + Self::Input::Refresh => { + self.set_refreshing(true); + sender.input(Self::Input::DoRefresh); + } + Self::Input::DoRefresh => { + let mut plugins = match refresh_plugins().await { + Err(e) => { + error!("failed to refresh plugins: {e}"); + Vec::new() + } + Ok(results) => results + .into_iter() + .filter_map(|res| match res { + Ok(plugin) => Some(plugin), + Err(e) => { + error!("failed to refresh single plugin manifest: {e}"); + None + } + }) + .collect(), + }; + { + let appids_from_web = plugins + .iter() + .map(|p| p.appid.clone()) + .collect::>(); + // add all plugins that are in config but not retrieved + plugins.extend(self.config_plugins.values().filter_map(|cp| { + if appids_from_web.contains(&cp.plugin.appid) { + None + } else { + Some(Plugin::from(cp)) + } + })); + } + self.set_plugins(plugins); + self.refresh_plugin_rows(); + self.set_refreshing(false); + } + Self::Input::Install(plugin) => { + self.set_locked(true); + let mut plugins = vec![plugin]; + for dep in plugins[0].dependencies.clone().unwrap_or_default() { + if let Some(dep_plugin) = self + .plugins + .iter() + .find(|plugin| plugin.appid == dep) + .cloned() + { + plugins.push(dep_plugin); + } else { + error!( + "unable to find dependency for {}: {}", + plugins[0].appid, dep + ); + alert( + "Missing dependencies", + Some(&format!( + "{} depends on unknown plugin {}", + plugins[0].name, dep + )), + Some(&self.win.clone().unwrap().upcast::()), + ); + return; + } + } + sender.input(Self::Input::InstallDownload(plugins)) + } + Self::Input::InstallDownload(plugins) => { + for plugin in plugins { + let mut plugin = plugin.clone(); + match plugin.exec_url.as_ref() { + Some(url) => { + let exec_path = plugin.canonical_exec_path(); + if let Err(e) = download_file_async(url, &exec_path).await { + alert( + "Download failed", + Some(&format!( + "Downloading {} {} failed:\n\n{e}", + plugin.name, + plugin + .version + .as_ref() + .unwrap_or(&"(no version)".to_string()) + )), + Some( + &self.win.as_ref().unwrap().clone().upcast::(), + ), + ); + } else { + plugin.exec_path = Some(exec_path); + if let Err(e) = plugin.enable() { + error!("failed to enable plugin {}: {e}", plugin.appid); + alert( + "Failed to enable plugin", + Some(&e.to_string()), + Some(&self.win.clone().unwrap().upcast::()), + ); + return; + } + self.add_plugin_to_config(&sender, plugin.clone()); + } + } + None => { + alert( + "Download failed", + Some(&format!( + "Downloading {} {} failed:\n\nNo executable url provided for this plugin, this is likely a bug!", + plugin.name, + plugin.version.as_ref().unwrap_or(&"(no version)".to_string())) + ), + Some(&self.win.as_ref().unwrap().clone().upcast::()) + ); + } + }; + self.refresh_plugin_rows(); + self.details + .emit(StoreDetailMsg::Refresh(plugin.appid, true, false)); + } + self.set_locked(false); + } + Self::Input::Remove(plugin) => { + self.set_locked(true); + if let Err(e) = plugin.disable() { + error!("failed to disable plugin {}: {e}", plugin.appid); + alert( + "Failed to disable plugin", + Some(&e.to_string()), + Some(&self.win.clone().unwrap().upcast::()), + ); + return; + } + if let Some(exec) = plugin.executable() { + // delete executable only if it's not a custom plugin + if exec.is_file() && plugin.exec_url.is_some() { + if let Err(e) = remove_file(&exec) { + alert( + "Failed removing plugin", + Some(&format!( + "Could not remove plugin executable {}:\n\n{e}", + exec.to_string_lossy() + )), + Some(&self.win.as_ref().unwrap().clone().upcast::()), + ); + } + } + } + self.config_plugins.remove(&plugin.appid); + self.set_plugins( + self.plugins + .clone() + .into_iter() + .filter(|p| !(p.appid == plugin.appid && p.exec_url.is_none())) + .collect(), + ); + sender + .output(Self::Output::UpdateConfigPlugins( + self.config_plugins.clone(), + )) + .expect(SENDER_IO_ERR_MSG); + self.refresh_plugin_rows(); + self.details + .emit(StoreDetailMsg::Refresh(plugin.appid, false, false)); + self.set_locked(false); + } + Self::Input::SetEnabled(signal_sender, plugin, enabled) => { + if let Some(cp) = self.config_plugins.get_mut(&plugin.appid) { + if let Err(e) = plugin.set_enabled(enabled) { + error!( + "failed to {} plugin {}: {e}", + if enabled { "enable" } else { "disable" }, + plugin.appid + ); + alert( + &format!( + "Failed to {} plugin", + if enabled { "enable" } else { "disable" } + ), + Some(&e.to_string()), + Some(&self.win.clone().unwrap().upcast::()), + ); + return; + } + cp.enabled = enabled; + if signal_sender == PluginStoreSignalSource::Detail { + if let Some(row) = self + .plugin_rows + .as_mut() + .unwrap() + .guard() + .iter() + .find(|row| row.is_some_and(|row| row.plugin.appid == plugin.appid)) + .flatten() + { + row.input_sender.emit(StoreRowModelMsg::Refresh( + enabled, + cp.plugin.version != plugin.version, + )); + } else { + error!("could not find corresponding listbox row") + } + } + if signal_sender == PluginStoreSignalSource::Row { + self.details.emit(StoreDetailMsg::Refresh( + plugin.appid, + enabled, + cp.plugin.version != plugin.version, + )); + } + } else { + debug!( + "failed to set plugin {} enabled: could not find in hashmap", + plugin.appid + ) + } + sender + .output(Self::Output::UpdateConfigPlugins( + self.config_plugins.clone(), + )) + .expect(SENDER_IO_ERR_MSG); + } + // we use index here because it's the listbox not the row that can + // send this signal, so I don't directly have the plugin object + Self::Input::ShowDetails(index) => { + if let Some(plugin) = self.plugins.get(index) { + self.details.sender().emit(StoreDetailMsg::SetPlugin( + plugin.clone(), + self.config_plugins + .get(&plugin.appid) + .is_some_and(|cp| cp.enabled), + self.config_plugins + .get(&plugin.appid) + .is_some_and(|cp| cp.plugin.version != plugin.version), + )); + self.main_stack + .as_ref() + .unwrap() + .set_visible_child_name("detailsview"); + } else { + error!("plugins list index out of range!") + } + } + Self::Input::ShowPluginList => { + self.main_stack + .as_ref() + .unwrap() + .set_visible_child_name("listview"); + } + } + } + + async fn init( + init: Self::Init, + root: Self::Root, + sender: AsyncComponentSender, + ) -> AsyncComponentParts { + let mut model = Self { + tracker: 0, + refreshing: false, + locked: false, + win: None, + plugins: Vec::default(), + plugin_rows: None, + details: StoreDetail::builder() + .launch(()) + .forward(sender.input_sender(), move |msg| match msg { + StoreDetailOutMsg::GoBack => Self::Input::ShowPluginList, + StoreDetailOutMsg::Install(plugin) => Self::Input::Install(plugin), + StoreDetailOutMsg::Remove(plugin) => Self::Input::Remove(plugin), + StoreDetailOutMsg::SetEnabled(plugin, enabled) => { + Self::Input::SetEnabled(PluginStoreSignalSource::Detail, plugin, enabled) + } + }), + config_plugins: init.config_plugins, + main_stack: None, + add_custom_plugin_win: None, + }; + + let details_view = model.details.widget(); + + let widgets = view_output!(); + + model.win = Some(widgets.win.clone()); + model.plugin_rows = Some( + AsyncFactoryVecDeque::builder() + .launch(widgets.listbox.clone()) + .forward(sender.input_sender(), move |msg| match msg { + StoreRowModelOutMsg::Install(appid) => Self::Input::Install(appid), + StoreRowModelOutMsg::Remove(appid) => Self::Input::Remove(appid), + StoreRowModelOutMsg::SetEnabled(plugin, enabled) => { + Self::Input::SetEnabled(PluginStoreSignalSource::Row, plugin, enabled) + } + }), + ); + model.main_stack = Some(widgets.main_stack.clone()); + + AsyncComponentParts { model, widgets } + } +} diff --git a/src/ui/plugins/store_detail.rs b/src/ui/plugins/store_detail.rs new file mode 100644 index 0000000..4bd9d9f --- /dev/null +++ b/src/ui/plugins/store_detail.rs @@ -0,0 +1,373 @@ +use super::Plugin; +use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG}; +use adw::prelude::*; +use relm4::prelude::*; +use tracing::{error, warn}; + +#[tracker::track] +pub struct StoreDetail { + plugin: Option, + enabled: bool, + #[tracker::do_not_track] + carousel: Option, + #[tracker::do_not_track] + icon: Option, + needs_update: bool, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum StoreDetailMsg { + SetPlugin(Plugin, bool, bool), + SetIcon, + SetScreenshots, + Refresh(String, bool, bool), + Install, + Remove, + SetEnabled(bool), + OpenHomepage, +} + +#[derive(Debug)] +pub enum StoreDetailOutMsg { + Install(Plugin), + Remove(Plugin), + GoBack, + SetEnabled(Plugin, bool), +} + +#[relm4::component(pub async)] +impl AsyncComponent for StoreDetail { + type Init = (); + type Input = StoreDetailMsg; + type Output = StoreDetailOutMsg; + type CommandOutput = (); + + view! { + adw::ToolbarView { + set_top_bar_style: adw::ToolbarStyle::Flat, + add_top_bar: headerbar = &adw::HeaderBar { + #[wrap(Some)] + set_title_widget: title = &adw::WindowTitle { + #[track = "model.changed(Self::plugin())"] + set_title: model + .plugin + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or_default(), + }, + pack_start: backbtn = >k::Button { + set_icon_name: "go-previous-symbolic", + set_tooltip_text: Some("Back"), + connect_clicked[sender] => move |_| { + sender.output(Self::Output::GoBack).expect(SENDER_IO_ERR_MSG); + } + }, + }, + #[wrap(Some)] + set_content: inner = >k::ScrolledWindow { + set_hscrollbar_policy: gtk::PolicyType::Never, + set_hexpand: true, + set_vexpand: true, + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_hexpand: true, + set_vexpand: true, + set_margin_top: 12, + set_margin_bottom: 48, + set_margin_start: 12, + set_margin_end: 12, + set_spacing: 24, + adw::Clamp { // icon, name, buttons + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_hexpand: true, + set_vexpand: false, + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_hexpand: true, + #[name(icon)] + gtk::Image { + set_icon_name: Some("application-x-addon-symbolic"), + set_margin_end: 12, + set_pixel_size: 96, + }, + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_hexpand: true, + set_valign: gtk::Align::Center, + set_spacing: 6, + gtk::Label { + add_css_class: "title-2", + set_xalign: 0.0, + #[track = "model.changed(Self::plugin())"] + set_text: model + .plugin + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or_default(), + set_ellipsize: gtk::pango::EllipsizeMode::None, + set_wrap: true, + }, + gtk::Label { + add_css_class: "dim-label", + set_xalign: 0.0, + #[track = "model.changed(Self::plugin())"] + set_visible: model + .plugin + .as_ref() + .is_some_and(|p| p.author.is_some()), + #[track = "model.changed(Self::plugin())"] + set_text: model + .plugin + .as_ref() + .and_then( + |p| p.author.as_deref() + ) + .unwrap_or_default(), + set_ellipsize: gtk::pango::EllipsizeMode::None, + set_wrap: true, + }, + }, + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_halign: gtk::Align::Center, + set_valign: gtk::Align::Center, + set_spacing: 6, + gtk::Button { + #[track = "model.changed(Self::plugin())"] + set_visible: !model + .plugin + .as_ref() + .is_some_and(|p| p.is_installed()), + set_label: "Install", + add_css_class: "suggested-action", + connect_clicked[sender] => move |_| { + sender.input(Self::Input::Install); + } + }, + gtk::Button { + #[track = "model.changed(Self::plugin())"] + set_visible: model + .plugin + .as_ref() + .is_some_and(|p| p.is_installed()), + set_label: "Remove", + add_css_class: "destructive-action", + connect_clicked[sender] => move |_| { + sender.input(Self::Input::Remove); + } + }, + gtk::Button { + #[track = "model.changed(Self::plugin()) || model.changed(Self::needs_update())"] + set_visible: model + .plugin + .as_ref() + .is_some_and(|p| p.is_installed()) && model.needs_update, + add_css_class: "suggested-action", + set_label: "Update", + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + connect_clicked[sender] => move |_| { + sender.input(Self::Input::Install); + } + }, + gtk::Switch { + #[track = "model.changed(Self::plugin())"] + set_visible: model.plugin.as_ref() + .is_some_and(|p| p.is_installed()), + #[track = "model.changed(Self::enabled())"] + set_active: model.enabled, + set_tooltip_text: Some("Plugin enabled"), + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + connect_state_set[sender] => move |_, state| { + sender.input(Self::Input::SetEnabled(state)); + gtk::glib::Propagation::Proceed + } + }, + gtk::Button { + #[track = "model.changed(Self::plugin())"] + set_visible: model.plugin.as_ref() + .is_some_and(|p| p.homepage_url.is_some()), + set_label: "Homepage", + connect_clicked[sender] => move |_| { + sender.input(Self::Input::OpenHomepage); + } + } + } + } + }, + }, + gtk::Box { // screenshots + set_orientation: gtk::Orientation::Vertical, + set_spacing: 12, + #[name(carousel)] + adw::Carousel { + set_allow_mouse_drag: true, + set_allow_scroll_wheel: false, + set_spacing: 24, + }, + adw::CarouselIndicatorDots { + set_carousel: Some(&carousel), + }, + }, + adw::Clamp { // description + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + gtk::Label { + set_xalign: 0.0, + #[track = "model.changed(Self::plugin())"] + set_text: model + .plugin + .as_ref() + .and_then(|p| p + .description + .as_deref() + ).unwrap_or(""), + set_ellipsize: gtk::pango::EllipsizeMode::None, + set_wrap: true, + set_justify: gtk::Justification::Fill, + }, + }, + }, + } + } + } + } + + async fn update( + &mut self, + message: Self::Input, + sender: AsyncComponentSender, + _root: &Self::Root, + ) { + self.reset(); + + match message { + Self::Input::OpenHomepage => { + if let Some(plugin) = self.plugin.as_ref() { + if let Some(homepage) = plugin.homepage_url.as_ref() { + if let Err(e) = gtk::gio::AppInfo::launch_default_for_uri( + homepage, + gtk::gio::AppLaunchContext::NONE, + ) { + error!("opening uri {homepage}: {e}"); + }; + } + } + } + Self::Input::SetPlugin(p, enabled, needs_update) => { + self.set_plugin(Some(p)); + self.set_enabled(enabled); + self.set_needs_update(needs_update); + sender.input(Self::Input::SetIcon); + sender.input(Self::Input::SetScreenshots); + } + Self::Input::SetIcon => { + if let Some(plugin) = self.plugin.as_ref() { + if let Some(url) = plugin.icon_url.as_ref() { + match cache_file(url, None).await { + Ok(dest) => { + self.icon.as_ref().unwrap().set_from_file(Some(dest)); + } + Err(e) => { + warn!("Failed downloading icon '{url}': {e}"); + } + }; + } else { + self.icon + .as_ref() + .unwrap() + .set_icon_name(Some("application-x-addon-symbolic")); + } + } + } + Self::Input::SetScreenshots => { + let carousel = self.carousel.as_ref().unwrap().clone(); + while let Some(child) = carousel.first_child() { + carousel.remove(&child); + } + if let Some(plugin) = self.plugin.as_ref() { + carousel + .parent() + .unwrap() + .set_visible(!plugin.screenshots.is_empty()); + for url in plugin.screenshots.iter() { + match cache_file(url, None).await { + Ok(dest) => { + let pic = gtk::Picture::builder() + .height_request(300) + .css_classes(["card"]) + .overflow(gtk::Overflow::Hidden) + .valign(gtk::Align::Center) + .build(); + pic.set_filename(Some(dest)); + let clamp = adw::Clamp::builder().child(&pic).build(); + carousel.append(&clamp); + } + Err(e) => { + warn!("failed downloading screenshot '{url}': {e}"); + } + }; + } + } + } + Self::Input::Refresh(appid, enabled, needs_update) => { + if self.plugin.as_ref().is_some_and(|p| p.appid == appid) { + self.mark_all_changed(); + self.set_enabled(enabled); + self.set_needs_update(needs_update); + } + } + Self::Input::Install => { + if let Some(plugin) = self.plugin.as_ref() { + sender + .output(Self::Output::Install(plugin.clone())) + .expect(SENDER_IO_ERR_MSG); + } + } + Self::Input::Remove => { + if let Some(plugin) = self.plugin.as_ref() { + sender + .output(Self::Output::Remove(plugin.clone())) + .expect(SENDER_IO_ERR_MSG); + if plugin.exec_url.is_none() { + sender + .output(Self::Output::GoBack) + .expect(SENDER_IO_ERR_MSG); + } + } + } + Self::Input::SetEnabled(enabled) => { + self.set_enabled(enabled); + if let Some(plugin) = self.plugin.as_ref() { + sender + .output(Self::Output::SetEnabled(plugin.clone(), enabled)) + .expect(SENDER_IO_ERR_MSG); + } + } + } + } + + async fn init( + _init: Self::Init, + root: Self::Root, + sender: AsyncComponentSender, + ) -> AsyncComponentParts { + let mut model = Self { + tracker: 0, + plugin: None, + enabled: false, + carousel: None, + icon: None, + needs_update: false, + }; + let widgets = view_output!(); + + model.carousel = Some(widgets.carousel.clone()); + model.icon = Some(widgets.icon.clone()); + + AsyncComponentParts { model, widgets } + } +} diff --git a/src/ui/plugins/store_row_factory.rs b/src/ui/plugins/store_row_factory.rs new file mode 100644 index 0000000..72f9e02 --- /dev/null +++ b/src/ui/plugins/store_row_factory.rs @@ -0,0 +1,225 @@ +use super::Plugin; +use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG}; +use gtk::prelude::*; +use relm4::{ + factory::AsyncFactoryComponent, prelude::DynamicIndex, AsyncFactorySender, RelmWidgetExt, +}; +use tracing::error; + +#[derive(Debug)] +#[tracker::track] +pub struct StoreRowModel { + #[no_eq] + pub plugin: Plugin, + #[tracker::do_not_track] + icon: Option, + #[tracker::do_not_track] + pub input_sender: relm4::Sender, + pub enabled: bool, + pub needs_update: bool, +} + +#[derive(Debug)] +pub struct StoreRowModelInit { + pub plugin: Plugin, + pub enabled: bool, + pub needs_update: bool, +} + +#[derive(Debug)] +pub enum StoreRowModelMsg { + LoadIcon, + /// params: enabled, needs_update + Refresh(bool, bool), + SetEnabled(bool), +} + +#[derive(Debug)] +pub enum StoreRowModelOutMsg { + Install(Plugin), + Remove(Plugin), + SetEnabled(Plugin, bool), +} + +#[relm4::factory(async pub)] +impl AsyncFactoryComponent for StoreRowModel { + type Init = StoreRowModelInit; + type Input = StoreRowModelMsg; + type Output = StoreRowModelOutMsg; + type CommandOutput = (); + type ParentWidget = gtk::ListBox; + + view! { + root = gtk::ListBoxRow { + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_hexpand: true, + set_vexpand: false, + set_spacing: 12, + set_margin_all: 12, + #[name(icon)] + gtk::Image { + set_icon_name: Some("application-x-addon-symbolic"), + set_icon_size: gtk::IconSize::Large, + }, + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 6, + set_hexpand: true, + set_vexpand: true, + gtk::Label { + add_css_class: "title-3", + set_hexpand: true, + set_xalign: 0.0, + set_text: &self.plugin.name, + set_ellipsize: gtk::pango::EllipsizeMode::None, + set_wrap: true, + }, + gtk::Label { + add_css_class: "dim-label", + set_hexpand: true, + set_xalign: 0.0, + set_text: self.plugin.short_description + .as_deref() + .unwrap_or(""), + set_ellipsize: gtk::pango::EllipsizeMode::None, + set_wrap: true, + }, + }, + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 6, + set_vexpand: true, + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + gtk::Button { + #[track = "self.changed(StoreRowModel::plugin())"] + set_visible: !self.plugin.is_installed(), + set_icon_name: "folder-download-symbolic", + add_css_class: "suggested-action", + set_tooltip_text: Some("Install"), + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + connect_clicked[sender, plugin] => move |_| { + sender + .output(Self::Output::Install( + plugin.clone(), + )) + .expect(SENDER_IO_ERR_MSG); + } + }, + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 6, + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + gtk::Button { + #[track = "self.changed(StoreRowModel::plugin())"] + set_visible: self.plugin.is_installed(), + set_icon_name: "user-trash-symbolic", + add_css_class: "destructive-action", + set_tooltip_text: Some("Remove"), + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + connect_clicked[sender, plugin] => move |_| { + sender + .output(Self::Output::Remove( + plugin.clone(), + )) + .expect(SENDER_IO_ERR_MSG); + } + }, + gtk::Button { + #[track = "self.changed(StoreRowModel::plugin()) || self.changed(StoreRowModel::needs_update())"] + set_visible: self.plugin.is_installed() && self.needs_update, + set_icon_name: "view-refresh-symbolic", + add_css_class: "suggested-action", + set_tooltip_text: Some("Update"), + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + connect_clicked[sender, plugin] => move |_| { + sender + .output(Self::Output::Install( + plugin.clone(), + )) + .expect(SENDER_IO_ERR_MSG); + } + }, + }, + gtk::Switch { + #[track = "self.changed(StoreRowModel::plugin())"] + set_visible: self.plugin.is_installed(), + #[track = "self.changed(StoreRowModel::enabled())"] + set_active: self.enabled, + set_valign: gtk::Align::Center, + set_halign: gtk::Align::Center, + set_tooltip_text: Some("Plugin enabled"), + connect_state_set[sender] => move |_, state| { + sender.input(Self::Input::SetEnabled(state)); + gtk::glib::Propagation::Proceed + } + }, + }, + } + } + } + + async fn update(&mut self, message: Self::Input, sender: AsyncFactorySender) { + self.reset(); + + match message { + Self::Input::LoadIcon => { + if let Some(url) = self.plugin.icon_url.as_ref() { + match cache_file(url, None).await { + Ok(dest) => { + self.icon.as_ref().unwrap().set_from_file(Some(dest)); + } + Err(e) => { + error!("failed downloading icon '{url}': {e}"); + } + }; + } + } + Self::Input::SetEnabled(state) => { + self.set_enabled(state); + sender + .output(Self::Output::SetEnabled(self.plugin.clone(), state)) + .expect(SENDER_IO_ERR_MSG); + } + Self::Input::Refresh(enabled, needs_update) => { + self.mark_all_changed(); + self.set_enabled(enabled); + self.set_needs_update(needs_update); + } + } + } + + async fn init_model( + init: Self::Init, + _index: &DynamicIndex, + sender: AsyncFactorySender, + ) -> Self { + Self { + tracker: 0, + plugin: init.plugin, + enabled: init.enabled, + icon: None, + input_sender: sender.input_sender().clone(), + needs_update: init.needs_update, + } + } + + fn init_widgets( + &mut self, + _index: &DynamicIndex, + root: Self::Root, + _returned_widget: &::ReturnedWidget, + sender: AsyncFactorySender, + ) -> Self::Widgets { + let plugin = self.plugin.clone(); // for use in a signal handler + let widgets = view_output!(); + self.icon = Some(widgets.icon.clone()); + sender.input(Self::Input::LoadIcon); + widgets + } +} diff --git a/src/ui/preference_rows.rs b/src/ui/preference_rows.rs index 5159ec9..bace44c 100644 --- a/src/ui/preference_rows.rs +++ b/src/ui/preference_rows.rs @@ -156,13 +156,12 @@ pub fn spin_row( row } -pub fn path_row) + 'static + Clone>( +fn filedialog_row_base) + 'static + Clone>( title: &str, description: Option<&str>, value: Option, - root_win: Option, cb: F, -) -> adw::ActionRow { +) -> (adw::ActionRow, gtk::Label) { let row = adw::ActionRow::builder() .title(title) .subtitle_lines(0) @@ -174,14 +173,14 @@ pub fn path_row) + 'static + Clone>( row.set_subtitle(d); } - let path_label = >k::Label::builder() + let path_label = gtk::Label::builder() .label(match value.as_ref() { None => "(None)", Some(p) => p.as_str(), }) .wrap(true) .build(); - row.add_suffix(path_label); + row.add_suffix(&path_label); let clear_btn = gtk::Button::builder() .icon_name("edit-clear-symbolic") @@ -200,9 +199,63 @@ pub fn path_row) + 'static + Clone>( cb(None) } )); + (row, path_label) +} + +pub fn file_row) + 'static + Clone>( + title: &str, + description: Option<&str>, + value: Option, + root_win: Option, + cb: F, +) -> adw::ActionRow { + let (row, path_label) = filedialog_row_base(title, description, value, cb.clone()); + let filedialog = gtk::FileDialog::builder() .modal(true) - .title(format!("Select Path for {}", title)) + .title(format!("Select {title}")) + .build(); + + row.connect_activated(clone!( + #[weak] + path_label, + move |_| { + filedialog.open( + root_win.as_ref(), + gio::Cancellable::NONE, + clone!( + #[weak] + path_label, + #[strong] + cb, + move |res| { + if let Ok(file) = res { + if let Some(path) = file.path() { + let path_s = path.to_string_lossy().to_string(); + path_label.set_text(&path_s); + cb(Some(path_s)) + } + } + } + ), + ) + } + )); + + row +} + +pub fn path_row) + 'static + Clone>( + title: &str, + description: Option<&str>, + value: Option, + root_win: Option, + cb: F, +) -> adw::ActionRow { + let (row, path_label) = filedialog_row_base(title, description, value, cb.clone()); + let filedialog = gtk::FileDialog::builder() + .modal(true) + .title(format!("Select Path for {title}")) .build(); row.connect_activated(clone!( @@ -220,8 +273,8 @@ pub fn path_row) + 'static + Clone>( move |res| { if let Ok(file) = res { if let Some(path) = file.path() { - let path_s = path.to_str().unwrap().to_string(); - path_label.set_text(path_s.as_str()); + let path_s = path.to_string_lossy().to_string(); + path_label.set_text(&path_s); cb(Some(path_s)) } } diff --git a/src/ui/profile_editor.rs b/src/ui/profile_editor.rs index 348df30..99f7b7b 100644 --- a/src/ui/profile_editor.rs +++ b/src/ui/profile_editor.rs @@ -6,12 +6,13 @@ use super::{ }; use crate::{ env_var_descriptions::ENV_VAR_DESCRIPTIONS_AS_PARAGRAPH, - profile::{LighthouseDriver, Profile, XRServiceType}, + profile::{LighthouseDriver, OvrCompatibilityModuleType, Profile, XRServiceType}, }; use adw::prelude::*; use gtk::glib::{self, clone}; use relm4::{factory::AsyncFactoryVecDeque, prelude::*}; use std::{cell::RefCell, path::PathBuf, rc::Rc}; +use tracing::warn; #[tracker::track] pub struct ProfileEditor { @@ -129,14 +130,6 @@ impl SimpleComponent for ProfileEditor { prof.borrow_mut().prefix = n_path.unwrap_or_default().into(); }), ), - add: &entry_row("Autostart Command", - model.profile.borrow().autostart_command.as_ref().unwrap_or(&String::default()), - clone!(#[strong] prof, move |row| { - let txt = row.text().trim().to_string(); - prof.borrow_mut().autostart_command = - if txt.is_empty() {None} else {Some(txt)}; - }) - ), add: &switch_row("Dependency Check", Some("Warning: disabling dependency checks may result in build failures"), !model.profile.borrow().skip_dependency_check, @@ -216,31 +209,43 @@ impl SimpleComponent for ProfileEditor { ), }, add: model.xrservice_cmake_flags_rows.widget(), - add: opencompgrp = &adw::PreferencesGroup { - set_title: "OpenComposite", - set_description: Some("OpenVR driver built on top of OpenXR"), + add: ovr_comp_grp = &adw::PreferencesGroup { + set_title: "OpenVR Compatibility", + set_description: Some("OpenVR compatibility module, translates between OpenXR and OpenVR to run legacy OpenVR apps"), + add: &combo_row( + "OpenVR Module Type", + None, + model.profile.borrow().ovr_comp.mod_type.to_string().as_str(), + OvrCompatibilityModuleType::iter() + .map(OvrCompatibilityModuleType::to_string) + .collect::>(), + clone!(#[strong] prof, move |row| { + prof.borrow_mut().ovr_comp.mod_type = + OvrCompatibilityModuleType::from(row.selected()); + }), + ), add: &path_row( - "OpenComposite Path", None, - Some(model.profile.borrow().opencomposite_path.clone().to_string_lossy().to_string()), + "OpenVR Module Path", None, + Some(model.profile.borrow().ovr_comp.path.clone().to_string_lossy().to_string()), Some(init.root_win.clone()), clone!(#[strong] prof, move |n_path| { - prof.borrow_mut().opencomposite_path = n_path.unwrap_or_default().into(); + prof.borrow_mut().ovr_comp.path = n_path.unwrap_or_default().into(); }) ), add: &entry_row( - "OpenComposite Repo", - model.profile.borrow().opencomposite_repo.clone().unwrap_or_default().as_str(), + "OpenVR Compatibility Repo", + model.profile.borrow().ovr_comp.repo.clone().unwrap_or_default().as_str(), clone!(#[strong] prof, move |row| { let n_val = row.text().to_string(); - prof.borrow_mut().opencomposite_repo = (!n_val.is_empty()).then_some(n_val); + prof.borrow_mut().ovr_comp.repo = (!n_val.is_empty()).then_some(n_val); }) ), add: &entry_row( - "OpenComposite Branch", - model.profile.borrow().opencomposite_branch.clone().unwrap_or_default().as_str(), + "OpenVR Compatibility Branch", + model.profile.borrow().ovr_comp.branch.clone().unwrap_or_default().as_str(), clone!(#[strong] prof, move |row| { let n_val = row.text().to_string(); - prof.borrow_mut().opencomposite_branch = (!n_val.is_empty()).then_some(n_val); + prof.borrow_mut().ovr_comp.branch = (!n_val.is_empty()).then_some(n_val); }) ), }, @@ -499,14 +504,14 @@ impl SimpleComponent for ProfileEditor { .halign(gtk::Align::End) .build(); - add_btn.connect_clicked(clone!( + let on_add = clone!( #[strong] sender, #[weak] name_entry, #[weak] popover, - move |_| { + move || { let key_gstr = name_entry.text(); let key = key_gstr.trim(); if !key.is_empty() { @@ -515,7 +520,13 @@ impl SimpleComponent for ProfileEditor { sender.input($event(key.to_string())); } } + ); + name_entry.connect_activate(clone!( + #[strong] + on_add, + move |_| on_add() )); + add_btn.connect_clicked(move |_| on_add()); btn }}; } @@ -528,17 +539,30 @@ impl SimpleComponent for ProfileEditor { let profile = Rc::new(RefCell::new(init.profile)); let prof = profile.clone(); + let env_var_prefs_group = { + let pg = adw::PreferencesGroup::builder() + .title("Environment Variables") + .description(ENV_VAR_DESCRIPTIONS_AS_PARAGRAPH.as_str()) + .header_suffix(&add_env_var_btn) + .build(); + if let Some(desc) = pg + .first_child() + .and_then(|c| c.first_child()) + .and_then(|c| c.first_child()) + .and_then(|c| c.last_child()) + .and_downcast::() + { + desc.set_selectable(true); + } else { + warn!("failed to make env var preference group description selectable, please open a bug report"); + } + pg + }; let mut model = Self { profile, win: None, env_rows: AsyncFactoryVecDeque::builder() - .launch( - adw::PreferencesGroup::builder() - .title("Environment Variables") - .description(ENV_VAR_DESCRIPTIONS_AS_PARAGRAPH.as_str()) - .header_suffix(&add_env_var_btn) - .build(), - ) + .launch(env_var_prefs_group) .forward(sender.input_sender(), |msg| match msg { EnvVarModelOutMsg::Changed(name, value) => { ProfileEditorMsg::EnvVarChanged(name, value) diff --git a/src/ui/steam_launch_options_box.rs b/src/ui/steam_launch_options_box.rs index 6956bfe..28991b0 100644 --- a/src/ui/steam_launch_options_box.rs +++ b/src/ui/steam_launch_options_box.rs @@ -45,8 +45,7 @@ impl SimpleComponent for SteamLaunchOptionsBox { add_css_class: "dim-label", set_hexpand: true, set_label: format!( - "Set this string in the launch options of Steam games, so that they can pick up the {app} runtime correctly", - app = APP_NAME) + "Set this string in the launch options of Steam games, so that they can pick up the {APP_NAME} runtime correctly") .as_str(), set_xalign: 0.0, set_wrap: true, diff --git a/src/ui/steamvr_calibration_box.rs b/src/ui/steamvr_calibration_box.rs index ff08b83..beae630 100644 --- a/src/ui/steamvr_calibration_box.rs +++ b/src/ui/steamvr_calibration_box.rs @@ -10,10 +10,10 @@ use relm4::{ }; use std::{ collections::{HashMap, VecDeque}, - path::Path, thread::sleep, time::Duration, }; +use tracing::error; #[tracker::track] pub struct SteamVrCalibrationBox { @@ -144,55 +144,59 @@ impl SimpleComponent for SteamVrCalibrationBox { } Self::Input::RunCalibration => { self.set_calibration_result(None); - let steamvr_bin_dir = get_steamvr_bin_dir_path().to_string_lossy().to_string(); - if !Path::new(&steamvr_bin_dir).is_dir() { - self.set_calibration_success(false); - self.set_calibration_result(Some("SteamVR not found".into())); - return; - } - let mut env: HashMap = HashMap::new(); - env.insert("LD_LIBRARY_PATH".into(), steamvr_bin_dir.clone()); - let vrcmd = format!("{steamvr_bin_dir}/vrcmd"); - let server_worker = { - let mut jobs: VecDeque = VecDeque::new(); - jobs.push_back(WorkerJob::new_cmd( - Some(env.clone()), - vrcmd.clone(), - Some(vec!["--pollposes".into()]), - )); - JobWorker::new(jobs, sender.input_sender(), |msg| match msg { - JobWorkerOut::Log(_) => Self::Input::NoOp, - JobWorkerOut::Exit(code) => Self::Input::OnServerWorkerExit(code), - }) - }; - let cal_worker = { - let mut jobs: VecDeque = VecDeque::new(); - jobs.push_back(WorkerJob::new_func(Box::new(move || { - sleep(Duration::from_secs(2)); - FuncWorkerOut { - success: true, - out: vec![], - } - }))); - jobs.push_back(WorkerJob::new_cmd( - Some(env), - vrcmd, - Some(vec!["--resetroomsetup".into()]), - )); - JobWorker::new(jobs, sender.input_sender(), |msg| match msg { - JobWorkerOut::Log(_) => Self::Input::NoOp, - JobWorkerOut::Exit(code) => Self::Input::OnCalWorkerExit(code), - }) - }; + match get_steamvr_bin_dir_path() { + Err(e) => { + error!("could not get SteamVR bin dir: {e}"); + self.set_calibration_success(false); + self.set_calibration_result(Some("SteamVR not found".into())); + } + Ok(bin_dir_p) => { + let steamvr_bin_dir = bin_dir_p.to_string_lossy().to_string(); + let mut env: HashMap = HashMap::new(); + env.insert("LD_LIBRARY_PATH".into(), steamvr_bin_dir.clone()); + let vrcmd = format!("{steamvr_bin_dir}/vrcmd"); + let server_worker = { + let mut jobs: VecDeque = VecDeque::new(); + jobs.push_back(WorkerJob::new_cmd( + Some(env.clone()), + vrcmd.clone(), + Some(vec!["--pollposes".into()]), + )); + JobWorker::new(jobs, sender.input_sender(), |msg| match msg { + JobWorkerOut::Log(_) => Self::Input::NoOp, + JobWorkerOut::Exit(code) => Self::Input::OnServerWorkerExit(code), + }) + }; + let cal_worker = { + let mut jobs: VecDeque = VecDeque::new(); + jobs.push_back(WorkerJob::new_func(Box::new(move || { + sleep(Duration::from_secs(2)); + FuncWorkerOut { + success: true, + out: vec![], + } + }))); + jobs.push_back(WorkerJob::new_cmd( + Some(env), + vrcmd, + Some(vec!["--resetroomsetup".into()]), + )); + JobWorker::new(jobs, sender.input_sender(), |msg| match msg { + JobWorkerOut::Log(_) => Self::Input::NoOp, + JobWorkerOut::Exit(code) => Self::Input::OnCalWorkerExit(code), + }) + }; - server_worker.start(); - cal_worker.start(); - self.server_worker = Some(server_worker); - self.calibration_worker = Some(cal_worker); + server_worker.start(); + cal_worker.start(); + self.server_worker = Some(server_worker); + self.calibration_worker = Some(cal_worker); + } + }; } Self::Input::OnServerWorkerExit(code) => { if code != 0 { - eprintln!("Calibration exited with code {code}"); + error!("calibration exited with code {code}"); } self.calibration_running = false; } diff --git a/src/ui/util.rs b/src/ui/util.rs index 16383c8..aa6c95f 100644 --- a/src/ui/util.rs +++ b/src/ui/util.rs @@ -1,4 +1,5 @@ use gtk::{gdk, gio, glib::clone, prelude::*}; +use tracing::{error, warn}; pub fn limit_dropdown_width(dd: >k::DropDown) { let mut dd_child = dd @@ -46,14 +47,14 @@ pub fn warning_heading() -> gtk::Box { pub fn open_with_default_handler(uri: &str) { if let Err(e) = gio::AppInfo::launch_default_for_uri(uri, gio::AppLaunchContext::NONE) { - eprintln!("Error opening uri {}: {}", uri, e) + error!("opening uri {uri}: {e}") }; } pub fn copy_text(txt: &str) { match gdk::Display::default() { None => { - eprintln!("Warning: could not get default gdk display") + warn!("could not get default gdk display") } Some(d) => { d.clipboard().set_text(txt); diff --git a/src/ui/wivrn_conf_editor.rs b/src/ui/wivrn_conf_editor.rs index 6b02d4b..1c6d6d4 100644 --- a/src/ui/wivrn_conf_editor.rs +++ b/src/ui/wivrn_conf_editor.rs @@ -21,6 +21,7 @@ use crate::{ use adw::prelude::*; use gtk::glib::clone; use relm4::{factory::AsyncFactoryVecDeque, prelude::*}; +use tracing::error; #[tracker::track] pub struct WivrnConfEditor { @@ -255,7 +256,7 @@ impl SimpleComponent for WivrnConfEditor { if let Some(idx) = idx_opt { self.encoder_models.as_mut().unwrap().guard().remove(idx); } else { - eprintln!("Couldn't find encoder model with id {id}"); + error!("couldn't find encoder model with id {id}"); } } } diff --git a/src/ui/wivrn_wired_start_box.rs b/src/ui/wivrn_wired_start_box.rs index 76b11e4..b121c5f 100644 --- a/src/ui/wivrn_wired_start_box.rs +++ b/src/ui/wivrn_wired_start_box.rs @@ -7,6 +7,7 @@ use crate::{ }; use gtk::prelude::*; use relm4::prelude::*; +use tracing::error; #[derive(PartialEq, Eq, Debug, Clone)] pub enum StartClientStatus { @@ -111,7 +112,7 @@ impl AsyncComponent for WivrnWiredStartBox { Self::Input::UpdateSelectedProfile(p) => self.set_selected_profile(p), Self::Input::StartWivrnClient => { if !dep_adb().check() { - alert("ADB is not installed", Some(&format!("Please install ADB on your computer to start the WiVRn client from {}.", APP_NAME)), Some(&self.root_win)); + alert("ADB is not installed", Some(&format!("Please install ADB on your computer to start the WiVRn client from {APP_NAME}.")), Some(&self.root_win)); return; } self.set_start_client_status(StartClientStatus::InProgress); @@ -153,14 +154,14 @@ impl AsyncComponent for WivrnWiredStartBox { .into(), )) } else { - eprintln!("Error: ADB failed with code {}.\nstdout:\n{}\n======\nstderr:\n{}", out.exit_code, out.stdout, out.stderr); + error!("ADB failed with code {}.\nstdout:\n{}\n======\nstderr:\n{}", out.exit_code, out.stdout, out.stderr); StartClientStatus::Done(Some( format!("ADB exited with code \"{}\"", out.exit_code) )) } }, Err(e) => { - eprintln!("Error: failed to run ADB: {e}"); + error!("failed to run ADB: {e}"); StartClientStatus::Done(Some( "Failed to run ADB".into() )) diff --git a/src/util/file_utils.rs b/src/util/file_utils.rs index e05a7ac..04efb13 100644 --- a/src/util/file_utils.rs +++ b/src/util/file_utils.rs @@ -1,4 +1,4 @@ -use crate::{async_process::async_process, profile::Profile}; +use crate::{async_process::async_process, depcheck::common::dep_getcap_setcap, profile::Profile}; use anyhow::bail; use nix::{ errno::Errno, @@ -7,8 +7,10 @@ use nix::{ use std::{ fs::{self, copy, create_dir_all, remove_dir_all, File, OpenOptions}, io::{BufReader, BufWriter}, + os::unix::fs::PermissionsExt, path::Path, }; +use tracing::{debug, error}; pub fn get_writer(path: &Path) -> anyhow::Result> { if let Some(parent) = path.parent() { @@ -36,7 +38,7 @@ pub fn get_reader(path: &Path) -> Option> { } match File::open(path) { Err(e) => { - eprintln!("Error opening {}: {}", path.to_string_lossy(), e); + error!("Error opening {}: {}", path.to_string_lossy(), e); None } Ok(fd) => Some(BufReader::new(fd)), @@ -48,7 +50,7 @@ pub fn deserialize_file(path: &Path) -> Option None, Some(reader) => match serde_json::from_reader(reader) { Err(e) => { - eprintln!("Failed to deserialize {}: {}", path.to_string_lossy(), e); + error!("Failed to deserialize {}: {}", path.to_string_lossy(), e); None } Ok(res) => Some(res), @@ -56,21 +58,50 @@ pub fn deserialize_file(path: &Path) -> Option Result<(), std::io::Error> { +pub fn set_file_readonly(path: &Path, readonly: bool) -> anyhow::Result<()> { + if path.is_symlink() { + bail!( + "path {} is a symlink, trying to change its write permission will only change the original file", + path.to_string_lossy() + ); + } if !path.is_file() { - eprintln!("WARN: trying to set readonly on a file that does not exist"); + debug!( + "trying to set readonly on a file that does not exist: {}", + path.to_string_lossy() + ); return Ok(()); } let mut perms = fs::metadata(path) .expect("Could not get metadata for file") .permissions(); perms.set_readonly(readonly); - fs::set_permissions(path, perms) + Ok(fs::set_permissions(path, perms)?) +} + +pub fn setcap_executable() -> Option { + if dep_getcap_setcap().check() { + Some("setcap".into()) + } else if Path::new("/sbin/setcap").try_exists().unwrap_or_default() { + Some("/sbin/setcap".into()) + } else { + None + } +} + +pub fn getcap_executable() -> Option { + if dep_getcap_setcap().check() { + Some("getcap".into()) + } else if Path::new("/sbin/getcap").try_exists().unwrap_or_default() { + Some("/sbin/getcap".into()) + } else { + None + } } pub fn setcap_cap_sys_nice_eip_cmd(profile: &Profile) -> Vec { vec![ - "setcap".into(), + setcap_executable().unwrap_or("setcap".into()), "CAP_SYS_NICE=eip".into(), profile .prefix @@ -80,16 +111,42 @@ pub fn setcap_cap_sys_nice_eip_cmd(profile: &Profile) -> Vec { ] } -pub async fn setcap_cap_sys_nice_eip(profile: &Profile) { - if let Err(e) = async_process("pkexec", Some(&setcap_cap_sys_nice_eip_cmd(profile)), None).await - { - eprintln!("Error: failed running setcap: {e}"); +pub async fn verify_cap_sys_nice_eip(profile: &Profile) -> bool { + let xrservice_binary = profile.xrservice_binary().to_string_lossy().to_string(); + if let Some(getcap_exec) = getcap_executable() { + match async_process(&getcap_exec, Some(&[&xrservice_binary]), None).await { + Err(e) => { + error!("failed to run `getcap {xrservice_binary}`: {e:?}"); + false + } + Ok(out) => { + debug!("getcap {xrservice_binary} stdout: {}", out.stdout); + debug!("getcap {xrservice_binary} stderr: {}", out.stderr); + if out.exit_code != 0 { + error!( + "command `getcap {xrservice_binary}` failed with status code {}", + out.exit_code + ); + false + } else { + out.stdout.to_lowercase().contains("cap_sys_nice=eip") + } + } + } + } else { + error!("getcap executable does not exist"); + false } } +pub async fn setcap_cap_sys_nice_eip(profile: &Profile) -> anyhow::Result<()> { + async_process("pkexec", Some(&setcap_cap_sys_nice_eip_cmd(profile)), None).await?; + Ok(()) +} + pub fn rm_rf(path: &Path) { if remove_dir_all(path).is_err() { - eprintln!("Failed to remove path {}", path.to_string_lossy()); + error!("failed to remove path {}", path.to_string_lossy()); } } @@ -100,11 +157,13 @@ pub fn copy_file(source: &Path, dest: &Path) { .unwrap_or_else(|_| panic!("Failed to create dir {}", parent.to_str().unwrap())); } } - set_file_readonly(dest, false) - .unwrap_or_else(|_| panic!("Failed to set file {} as rw", dest.to_string_lossy())); - copy(source, dest).unwrap_or_else(|_| { + if !dest.is_symlink() { + set_file_readonly(dest, false) + .unwrap_or_else(|_| panic!("Failed to set file {} as rw", dest.to_string_lossy())); + } + copy(source, dest).unwrap_or_else(|e| { panic!( - "Failed to copy {} to {}", + "Failed to copy {} to {}: {e}", source.to_string_lossy(), dest.to_string_lossy() ) @@ -118,6 +177,17 @@ pub fn mount_has_nosuid(path: &Path) -> Result { } } +pub fn mark_as_executable(path: &Path) -> anyhow::Result<()> { + if !path.is_file() { + bail!("Path '{}' is not a file", path.to_string_lossy()) + } else { + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(perms.mode() | 0o111); + fs::set_permissions(path, perms)?; + Ok(()) + } +} + #[cfg(test)] mod tests { use super::mount_has_nosuid; diff --git a/src/util/mod.rs b/src/util/mod.rs index 10902df..0aded2a 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,4 @@ pub mod file_utils; pub mod hash; +pub mod steam_library_folder; pub mod steamvr_utils; diff --git a/src/util/steam_library_folder.rs b/src/util/steam_library_folder.rs new file mode 100644 index 0000000..75af1e2 --- /dev/null +++ b/src/util/steam_library_folder.rs @@ -0,0 +1,69 @@ +use crate::paths::get_home_dir; +use anyhow::bail; +use serde::Deserialize; +use std::{ + collections::HashMap, + fs::read_to_string, + path::{Path, PathBuf}, +}; + +#[derive(Deserialize)] +pub struct SteamLibraryFolder { + pub path: String, + pub apps: HashMap, +} + +fn get_steam_main_dir_path() -> anyhow::Result { + let steam_root: PathBuf = get_home_dir().join(".steam/root").canonicalize()?; + + if steam_root.is_dir() { + Ok(steam_root) + } else { + bail!( + "Canonical steam root '{}' is not a dir nor a symlink!", + steam_root.to_string_lossy() + ) + } +} + +impl SteamLibraryFolder { + pub fn get_folders() -> anyhow::Result> { + let libraryfolders_path = get_steam_main_dir_path()? + .join("steamapps/libraryfolders.vdf") + .canonicalize()?; + if !libraryfolders_path.is_file() { + bail!( + "Steam libraryfolders.vdf does not exist in its canonical location {}", + libraryfolders_path.to_string_lossy() + ); + } + Self::get_folders_from_path(&libraryfolders_path) + } + + /// Do not use this: use get_folders() instead as it always uses Steam's + /// canonical root path. This is intended to be directly used only for + /// unit tests + pub fn get_folders_from_path(p: &Path) -> anyhow::Result> { + Ok(keyvalues_serde::from_str(read_to_string(p)?.as_str())?) + } +} + +#[cfg(test)] +mod tests { + use super::SteamLibraryFolder; + use std::path::Path; + + #[test] + fn deserialize_steam_libraryfolders_vdf() { + let lf = SteamLibraryFolder::get_folders_from_path(Path::new( + "./test/files/steam_libraryfolders.vdf", + )) + .unwrap(); + assert_eq!(lf.len(), 1); + let first = lf.get(&0).unwrap(); + assert_eq!(first.path, "/home/gabmus/.local/share/Steam"); + assert_eq!(first.apps.len(), 10); + assert_eq!(first.apps.get(&228980).unwrap(), &29212173); + assert_eq!(first.apps.get(&632360).unwrap(), &0); + } +} diff --git a/src/vulkaninfo.rs b/src/vulkaninfo.rs index a8f8110..6d76a92 100644 --- a/src/vulkaninfo.rs +++ b/src/vulkaninfo.rs @@ -5,12 +5,10 @@ use ash::{ #[derive(Debug, Clone)] pub struct VulkanInfo { - pub has_nvidia_gpu: bool, - pub has_monado_vulkan_layers: bool, pub gpu_names: Vec, } -const NVIDIA_VENDOR_ID: u32 = 0x10de; +// const NVIDIA_VENDOR_ID: u32 = 0x10de; impl VulkanInfo { /// # Safety @@ -25,40 +23,19 @@ impl VulkanInfo { None, ) }?; - let mut has_nvidia_gpu = false; - let mut has_monado_vulkan_layers = false; let gpu_names = unsafe { instance.enumerate_physical_devices() }? .into_iter() .filter_map(|d| { - let props = unsafe { instance.get_physical_device_properties(d) }; - if props.vendor_id == NVIDIA_VENDOR_ID { - has_nvidia_gpu = true; - } - if !has_monado_vulkan_layers { - has_monado_vulkan_layers = - unsafe { instance.enumerate_device_layer_properties(d) } - .ok() - .map(|layerprops| { - layerprops.iter().any(|lp| { - lp.layer_name_as_c_str().is_ok_and(|name| { - name.to_string_lossy() - == "VK_LAYER_MND_enable_timeline_semaphore" - }) - }) - }) - == Some(true); - } - props - .device_name_as_c_str() - .ok() - .map(|cs| cs.to_string_lossy().to_string()) + Some( + unsafe { instance.get_physical_device_properties(d) } + .device_name_as_c_str() + .ok()? + .to_string_lossy() + .to_string(), + ) }) .collect(); unsafe { instance.destroy_instance(None) }; - Ok(Self { - gpu_names, - has_nvidia_gpu, - has_monado_vulkan_layers, - }) + Ok(Self { gpu_names }) } } diff --git a/src/xr_devices.rs b/src/xr_devices.rs index ddd2b35..846759f 100644 --- a/src/xr_devices.rs +++ b/src/xr_devices.rs @@ -1,5 +1,6 @@ use libmonado::{self, BatteryStatus, DeviceRole}; use std::{collections::HashMap, fmt::Display, slice::Iter}; +use tracing::error; #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] pub enum XRDeviceRole { @@ -280,8 +281,8 @@ impl XRDevice { if let Some(target) = devs.get_mut(&index) { target.roles.push(role.into()); } else { - eprintln!( - "Could not find device index {index} for role {}", + error!( + "could not find device index {index} for role {}", XRDeviceRole::from(role) ) } diff --git a/test/files/profile.json b/test/files/profile.json index cb69c6f..ab167a3 100644 --- a/test/files/profile.json +++ b/test/files/profile.json @@ -5,9 +5,7 @@ "xrservice_path": "/home/user/monado", "xrservice_repo": null, "xrservice_branch": null, - "opencomposite_path": "/home/user/opencomposite", - "opencomposite_repo": null, - "opencomposite_branch": null, + "ovr_comp": { "mod_type": "Opencomposite", "path": "/home/user/opencomposite", "repo": null, "branch": null }, "features": { "libsurvive": { "feature_type": "Libsurvive", @@ -34,4 +32,4 @@ "can_be_built": true, "editable": true, "pull_on_build": true -} \ No newline at end of file +} diff --git a/test/files/wivrn_config2.json b/test/files/wivrn_config2.json index 84676be..7bd87ec 100644 --- a/test/files/wivrn_config2.json +++ b/test/files/wivrn_config2.json @@ -12,5 +12,5 @@ } ], "application": ["foobar", "baz"], - "tcp_only": true + "tcp-only": true }