From d8ca8cf961674bd0e957c69e2bf592018f9b3217 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 29 Nov 2024 18:16:02 +0100 Subject: [PATCH 001/103] fix: remove wivrn pairing mode timer --- src/wivrn_dbus/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/wivrn_dbus/mod.rs b/src/wivrn_dbus/mod.rs index 786776b..dfa610c 100644 --- a/src/wivrn_dbus/mod.rs +++ b/src/wivrn_dbus/mod.rs @@ -11,9 +11,6 @@ #[allow(non_snake_case)] mod internal; -/// timeout for dbus methods in seconds -const TIMEOUT: i32 = 10; - async fn proxy<'a>() -> zbus::Result> { let connection = zbus::Connection::session().await?; let proxy = internal::ServerProxy::new(&connection).await?; @@ -25,7 +22,7 @@ pub async fn is_pairing_mode() -> zbus::Result { } pub async fn enable_pairing() -> zbus::Result { - proxy().await?.enable_pairing(TIMEOUT).await + proxy().await?.enable_pairing(0).await } pub async fn disable_pairing() -> zbus::Result<()> { From f1e8a010c8d0ff53ffac6bc9db89ca19d8165e3d Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 29 Nov 2024 18:16:44 +0100 Subject: [PATCH 002/103] chore: update version to 1.1.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/org.gabmus.envision.metainfo.xml.in.in | 8 ++++++++ meson.build | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2bfdd5..a7e323f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,7 +554,7 @@ dependencies = [ [[package]] name = "envision" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index 3ae3d2f..ca66f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "envision" -version = "1.1.0" +version = "1.1.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 9dceb07..1c57821 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -18,6 +18,14 @@ @REPO_URL@/issues + + +

Fixes

+
    +
  • remove wivrn pairing mode timer
  • +
+
+

What's new

diff --git a/meson.build b/meson.build index 78c35eb..d10129a 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'envision', 'rust', - version: '1.1.0', # version number row + version: '1.1.1', # version number row meson_version: '>= 0.59', license: 'AGPL-3.0', ) From 2217f84ff4bf3141557ae848b6ff7b3e6eed62ae Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 29 Nov 2024 19:37:16 +0100 Subject: [PATCH 003/103] fix: use let err instead of match in restore xr files func --- src/main.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2185480..db1d7fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,18 +50,16 @@ fn restore_steam_xr_files() { 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) = set_current_active_runtime_to_steam() { + eprintln!("Warning: 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() { + eprintln!("Warning: failed to restore openvrpaths to steam: {e}"); + } } } restore_runtime_entrypoint(); From 3f846b26e08df9b1f4a3006b74e3af7714c3e493 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 29 Nov 2024 19:40:16 +0100 Subject: [PATCH 004/103] fix: negative logic and early return in start xrservice func --- src/ui/app.rs | 102 +++++++++++++++++++++++++------------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 1598ecb..e097067 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -163,57 +163,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,7 +172,57 @@ 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![]; + 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); } pub fn run_autostart(&mut self, sender: AsyncComponentSender) { From 448b97469e4325bb894ac88a15ff4ba48ea90866 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 1 Dec 2024 08:27:37 +0100 Subject: [PATCH 005/103] fix: add openssl-devel dep for wivrn --- src/depcheck/wivrn_deps.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/depcheck/wivrn_deps.rs b/src/depcheck/wivrn_deps.rs index ee430cb..48994af 100644 --- a/src/depcheck/wivrn_deps.rs +++ b/src/depcheck/wivrn_deps.rs @@ -235,6 +235,18 @@ fn wivrn_deps() -> Vec { (LinuxDistro::Suse, "glib2-devel".into()), ]), }, + Dependency { + name: "openssl-dev".into(), + dep_type: DepType::Executable, + 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()), + ]), + }, ] } From 592709ab560ffdb8a597330e73bfe608e8a937ad Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 1 Dec 2024 11:02:56 +0100 Subject: [PATCH 006/103] fix: openssl dep is an include --- src/depcheck/wivrn_deps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/depcheck/wivrn_deps.rs b/src/depcheck/wivrn_deps.rs index 48994af..bacc62d 100644 --- a/src/depcheck/wivrn_deps.rs +++ b/src/depcheck/wivrn_deps.rs @@ -237,7 +237,7 @@ fn wivrn_deps() -> Vec { }, Dependency { name: "openssl-dev".into(), - dep_type: DepType::Executable, + dep_type: DepType::Include, filename: "openssl/ssl3.h".into(), packages: HashMap::from([ (LinuxDistro::Arch, "openssl".into()), From c78b844b60fe02fd9a2defa417a5d57dde60c4c9 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 1 Dec 2024 13:01:58 +0100 Subject: [PATCH 007/103] fix: add libnotify-dev dependency for wivrn --- src/depcheck/wivrn_deps.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/depcheck/wivrn_deps.rs b/src/depcheck/wivrn_deps.rs index bacc62d..38eb8b2 100644 --- a/src/depcheck/wivrn_deps.rs +++ b/src/depcheck/wivrn_deps.rs @@ -247,6 +247,18 @@ fn wivrn_deps() -> Vec { (LinuxDistro::Suse, "openssl-devel".into()), ]), }, + Dependency { + name: "libnotify-dev".into(), + dep_type: DepType::Include, + filename: "openssl/ssl3.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()), + ]), + }, ] } From 61f13dbd8fed8aa2013c73211934bbdd3fd30d31 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Mon, 2 Dec 2024 15:11:38 +0100 Subject: [PATCH 008/103] feat: proper logging framework --- Cargo.lock | 105 ++++++++++++++++++++++++++-- Cargo.toml | 2 + README.md | 4 ++ src/main.rs | 15 +++- src/steam_linux_runtime_injector.rs | 5 +- src/ui/app.rs | 19 +++-- src/ui/cmdline_opts.rs | 3 +- src/ui/install_wivrn_box.rs | 15 ++-- src/ui/job_worker/mod.rs | 7 +- src/ui/main_view.rs | 11 +-- src/ui/openhmd_calibration_box.rs | 5 +- src/ui/steamvr_calibration_box.rs | 3 +- src/ui/util.rs | 5 +- src/ui/wivrn_conf_editor.rs | 3 +- src/ui/wivrn_wired_start_box.rs | 5 +- src/util/file_utils.rs | 18 +++-- src/xr_devices.rs | 5 +- 17 files changed, 182 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7e323f..6cee2da 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" @@ -576,6 +576,8 @@ dependencies = [ "serde_json", "sha2", "tokio", + "tracing", + "tracing-subscriber", "tracker", "uuid", "vte4", @@ -1698,6 +1700,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 +1831,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 +1966,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 +2207,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 +2228,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" @@ -2525,6 +2567,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 +2764,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" @@ -2862,6 +2923,36 @@ 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-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", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2964,6 +3055,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..c268912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,5 @@ 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"] } +tracing = "0.1.41" 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/src/main.rs b/src/main.rs index db1d7fb..2c94efd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use file_builders::{ openvrpaths_vrpath::{get_current_openvrpaths, set_current_openvrpaths_to_steam}, }; use gettextrs::LocaleCategory; +use tracing::warn; use relm4::{ adw, gtk::{self, gdk, gio, glib, prelude::*}, @@ -12,6 +13,7 @@ use relm4::{ }; use std::env; use steam_linux_runtime_injector::restore_runtime_entrypoint; +use tracing_subscriber::{filter::LevelFilter, EnvFilter}; use ui::{ app::{App, AppInit, Msg}, cmdline_opts::CmdLineOpts, @@ -51,14 +53,14 @@ fn restore_steam_xr_files() { if let Some(ar) = active_runtime { if !file_builders::active_runtime_json::is_steam(&ar) { if let Err(e) = set_current_active_runtime_to_steam() { - eprintln!("Warning: failed to restore active runtime to steam: {e}"); + warn!("failed to restore active runtime to steam: {e}"); } } } if let Some(ovrp) = openvrpaths { if !file_builders::openvrpaths_vrpath::is_steam(&ovrp) { if let Err(e) = set_current_openvrpaths_to_steam() { - eprintln!("Warning: failed to restore openvrpaths to steam: {e}"); + warn!("failed to restore openvrpaths to steam: {e}"); } } } @@ -71,6 +73,15 @@ fn main() -> Result<()> { } restore_steam_xr_files(); + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ) + .pretty() + .init(); + // Prepare i18n gettextrs::setlocale(LocaleCategory::LcAll, ""); gettextrs::bindtextdomain(GETTEXT_PACKAGE, LOCALE_DIR).expect("Unable to bind the text domain"); diff --git a/src/steam_linux_runtime_injector.rs b/src/steam_linux_runtime_injector.rs index 6dad1ec..098be3e 100644 --- a/src/steam_linux_runtime_injector.rs +++ b/src/steam_linux_runtime_injector.rs @@ -5,6 +5,7 @@ use crate::{ }; use anyhow::bail; use lazy_static::lazy_static; +use tracing::error; use serde::Deserialize; use std::{ collections::HashMap, @@ -46,7 +47,7 @@ fn get_runtime_entrypoint_path() -> Option { let steam_libraryfolders_path = steam_root.join("steamapps/libraryfolders.vdf"); if !steam_libraryfolders_path.is_file() { - eprintln!( + error!( "Steam libraryfolders.vdf does not exist in its canonical location {}", steam_libraryfolders_path.to_string_lossy() ); @@ -69,7 +70,7 @@ fn get_runtime_entrypoint_path() -> Option { }) } Err(e) => { - eprintln!("Error getting steam root path: {e}"); + error!("Error getting steam root path: {e}"); None } } diff --git a/src/ui/app.rs b/src/ui/app.rs index e097067..35db15c 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -47,6 +47,7 @@ use crate::{ }; use adw::{prelude::*, ResponseAppearance}; use gtk::glib::{self, clone}; +use tracing::error; use notify_rust::NotificationHandle; use relm4::{ actions::{AccelsPlus, ActionGroupName, RelmAction, RelmActionGroup}, @@ -147,7 +148,7 @@ impl App { } { Ok(n) => Some(n), Err(e) => { - eprintln!("Failed to send desktop notification: {e:?}"); + error!("failed to send desktop notification: {e:?}"); None } } @@ -196,9 +197,13 @@ impl App { }; 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 ipc_file = prof.xrservice_type.ipc_file_path(); + if ipc_file.is_file() { + 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(), @@ -409,7 +414,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)); @@ -633,7 +638,7 @@ impl AsyncComponent for App { } Msg::RunSetCap => { if !dep_pkexec().check() { - println!("pkexec not found, skipping setcap"); + error!("pkexec not found, skipping setcap"); } else { let profile = self.get_selected_profile(); setcap_cap_sys_nice_eip(&profile).await; @@ -878,7 +883,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 } } diff --git a/src/ui/cmdline_opts.rs b/src/ui/cmdline_opts.rs index ca4e14d..48458e0 100644 --- a/src/ui/cmdline_opts.rs +++ b/src/ui/cmdline_opts.rs @@ -6,6 +6,7 @@ use gtk::{ }, glib::{self, prelude::IsA}, }; +use tracing::error; #[derive(Debug, Clone)] pub struct CmdLineOpts { @@ -88,7 +89,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); } } diff --git a/src/ui/install_wivrn_box.rs b/src/ui/install_wivrn_box.rs index cf63f7c..31459c7 100644 --- a/src/ui/install_wivrn_box.rs +++ b/src/ui/install_wivrn_box.rs @@ -6,6 +6,7 @@ use crate::{ profile::{Profile, XRServiceType}, }; use gtk::prelude::*; +use tracing::error; use relm4::{new_action_group, new_stateless_action, prelude::*}; use std::fs::remove_file; @@ -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,12 +181,12 @@ 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}", + error!( + "failed to remove file {}: {e}", existing.to_string_lossy() ); } @@ -208,7 +209,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 +237,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/mod.rs b/src/ui/job_worker/mod.rs index 4de1c78..4915ed1 100644 --- a/src/ui/job_worker/mod.rs +++ b/src/ui/job_worker/mod.rs @@ -4,6 +4,7 @@ use self::{ state::JobWorkerState, }; use crate::profile::Profile; +use tracing::{error, warn}; use nix::sys::signal::{ kill, Signal::{SIGKILL, SIGTERM}, @@ -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..460d937 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -30,6 +30,7 @@ use crate::{ }; use adw::{prelude::*, ResponseAppearance}; use gtk::glib::clone; +use tracing::{error, warn}; use relm4::{ actions::{ActionGroupName, RelmAction, RelmActionGroup}, new_action_group, new_stateless_action, @@ -389,8 +390,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 }, @@ -627,7 +628,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 +638,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 => { diff --git a/src/ui/openhmd_calibration_box.rs b/src/ui/openhmd_calibration_box.rs index c8da5dc..fbd44e9 100644 --- a/src/ui/openhmd_calibration_box.rs +++ b/src/ui/openhmd_calibration_box.rs @@ -1,4 +1,5 @@ use crate::{constants::APP_NAME, xdg::XDG}; +use tracing::{debug, error}; use relm4::{ gtk::{self, prelude::*}, ComponentParts, ComponentSender, SimpleComponent, @@ -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/steamvr_calibration_box.rs b/src/ui/steamvr_calibration_box.rs index ff08b83..4d7c250 100644 --- a/src/ui/steamvr_calibration_box.rs +++ b/src/ui/steamvr_calibration_box.rs @@ -4,6 +4,7 @@ use super::job_worker::{ JobWorker, }; use crate::paths::get_steamvr_bin_dir_path; +use tracing::error; use relm4::{ gtk::{self, prelude::*}, ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent, @@ -192,7 +193,7 @@ impl SimpleComponent for SteamVrCalibrationBox { } 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..edf5d52 100644 --- a/src/ui/wivrn_conf_editor.rs +++ b/src/ui/wivrn_conf_editor.rs @@ -20,6 +20,7 @@ use crate::{ }; use adw::prelude::*; use gtk::glib::clone; +use tracing::error; use relm4::{factory::AsyncFactoryVecDeque, prelude::*}; #[tracker::track] @@ -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..1918b4f 100644 --- a/src/ui/wivrn_wired_start_box.rs +++ b/src/ui/wivrn_wired_start_box.rs @@ -6,6 +6,7 @@ use crate::{ profile::{Profile, XRServiceType}, }; use gtk::prelude::*; +use tracing::error; use relm4::prelude::*; #[derive(PartialEq, Eq, Debug, Clone)] @@ -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..f802fac 100644 --- a/src/util/file_utils.rs +++ b/src/util/file_utils.rs @@ -1,5 +1,6 @@ use crate::{async_process::async_process, profile::Profile}; use anyhow::bail; +use tracing::{debug, error}; use nix::{ errno::Errno, sys::statvfs::{statvfs, FsFlags}, @@ -36,7 +37,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 +49,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), @@ -58,7 +59,10 @@ pub fn deserialize_file(path: &Path) -> Option Result<(), std::io::Error> { 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) @@ -83,13 +87,13 @@ 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}"); + error!("failed running setcap: {e}"); } } 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()); } } @@ -102,9 +106,9 @@ pub fn copy_file(source: &Path, dest: &Path) { } 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(|_| { + 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() ) diff --git a/src/xr_devices.rs b/src/xr_devices.rs index ddd2b35..c93df02 100644 --- a/src/xr_devices.rs +++ b/src/xr_devices.rs @@ -1,4 +1,5 @@ use libmonado::{self, BatteryStatus, DeviceRole}; +use tracing::error; use std::{collections::HashMap, fmt::Display, slice::Iter}; #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] @@ -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) ) } From a9fa4f8cf487b2eb5adb1b45c07bce0e7ff874c5 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Mon, 2 Dec 2024 18:25:00 +0100 Subject: [PATCH 009/103] feat: move steam library folders parser to own module; function to find steam openxr json; format --- src/file_builders/active_runtime_json.rs | 23 +++++- src/main.rs | 2 +- src/steam_linux_runtime_injector.rs | 92 ++++-------------------- src/ui/app.rs | 2 +- src/ui/install_wivrn_box.rs | 7 +- src/ui/job_worker/mod.rs | 2 +- src/ui/main_view.rs | 2 +- src/ui/openhmd_calibration_box.rs | 2 +- src/ui/steamvr_calibration_box.rs | 2 +- src/ui/wivrn_conf_editor.rs | 2 +- src/ui/wivrn_wired_start_box.rs | 2 +- src/util/file_utils.rs | 2 +- src/util/mod.rs | 1 + src/util/steam_library_folder.rs | 69 ++++++++++++++++++ src/xr_devices.rs | 2 +- 15 files changed, 119 insertions(+), 93 deletions(-) create mode 100644 src/util/steam_library_folder.rs diff --git a/src/file_builders/active_runtime_json.rs b/src/file_builders/active_runtime_json.rs index 3f5e73f..f3461ae 100644 --- a/src/file_builders/active_runtime_json.rs +++ b/src/file_builders/active_runtime_json.rs @@ -1,7 +1,10 @@ use crate::{ paths::{get_backup_dir, SYSTEM_PREFIX}, profile::Profile, - util::file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly}, + util::{ + file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly}, + steam_library_folder::SteamLibraryFolder, + }, xdg::XDG, }; use serde::{Deserialize, Serialize}; @@ -9,6 +12,7 @@ use std::{ fs::remove_file, path::{Path, PathBuf}, }; +use tracing::error; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActiveRuntimeInnerRuntime { @@ -38,6 +42,23 @@ pub fn is_steam(active_runtime: &ActiveRuntime) -> bool { matches!(active_runtime.runtime.valve_runtime_is_steamvr, Some(true)) } +pub const STEAMVR_STEAM_APPID: u32 = 250820; + +pub fn find_steam_openxr_json() -> Option { + match SteamLibraryFolder::get_folders() { + Ok(libraryfolders) => libraryfolders + .iter() + .find(|(_, folder)| folder.apps.contains_key(&STEAMVR_STEAM_APPID)) + .map(|(_, folder)| { + PathBuf::from(&folder.path).join("steamapps/common/SteamVR/steamxr_linux64.json") + }), + Err(e) => { + error!("unable to find steam openxr json: unable to load steam libraryfolders: {e}"); + None + } + } +} + fn get_backup_steam_active_runtime_path() -> PathBuf { get_backup_dir().join("active_runtime.json.steam.bak") } diff --git a/src/main.rs b/src/main.rs index 2c94efd..a13a2ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,6 @@ use file_builders::{ openvrpaths_vrpath::{get_current_openvrpaths, set_current_openvrpaths_to_steam}, }; use gettextrs::LocaleCategory; -use tracing::warn; use relm4::{ adw, gtk::{self, gdk, gio, glib, prelude::*}, @@ -13,6 +12,7 @@ use relm4::{ }; use std::env; use steam_linux_runtime_injector::restore_runtime_entrypoint; +use tracing::warn; use tracing_subscriber::{filter::LevelFilter, EnvFilter}; use ui::{ app::{App, AppInit, Msg}, diff --git a/src/steam_linux_runtime_injector.rs b/src/steam_linux_runtime_injector.rs index 098be3e..97f50c5 100644 --- a/src/steam_linux_runtime_injector.rs +++ b/src/steam_linux_runtime_injector.rs @@ -1,76 +1,33 @@ 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}, + steam_library_folder::SteamLibraryFolder, + }, }; use anyhow::bail; use lazy_static::lazy_static; -use tracing::error; -use serde::Deserialize; use std::{ - collections::HashMap, fs::read_to_string, io::Write, path::{Path, PathBuf}, }; - -#[derive(Deserialize)] -struct LibraryFolder { - pub path: String, - pub apps: HashMap, -} +use tracing::error; 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 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() { - error!( - "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") - }) - } + match SteamLibraryFolder::get_folders() { + Ok(libraryfolders) => libraryfolders + .iter() + .find(|(_, folder)| folder.apps.contains_key(&PRESSURE_VESSEL_STEAM_APPID)) + .map(|(_, folder)| { + PathBuf::from(&folder.path) + .join("steamapps/common/SteamLinuxRuntime_sniper/_v2-entry-point") + }), Err(e) => { - error!("Error getting steam root path: {e}"); + error!("unable to get runtime entrypoint path: {e}"); None } } @@ -126,22 +83,3 @@ pub fn set_runtime_entrypoint_launch_opts_from_profile(profile: &Profile) -> any } bail!("Could not find valid runtime entrypoint"); } - -#[cfg(test)] -mod tests { - use std::path::Path; - - 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); - } -} diff --git a/src/ui/app.rs b/src/ui/app.rs index 35db15c..34c5549 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -47,7 +47,6 @@ use crate::{ }; use adw::{prelude::*, ResponseAppearance}; use gtk::glib::{self, clone}; -use tracing::error; use notify_rust::NotificationHandle; use relm4::{ actions::{AccelsPlus, ActionGroupName, RelmAction, RelmActionGroup}, @@ -55,6 +54,7 @@ use relm4::{ prelude::*, }; use std::{collections::VecDeque, fs::remove_file, time::Duration}; +use tracing::error; pub struct App { application: adw::Application, diff --git a/src/ui/install_wivrn_box.rs b/src/ui/install_wivrn_box.rs index 31459c7..e8abc36 100644 --- a/src/ui/install_wivrn_box.rs +++ b/src/ui/install_wivrn_box.rs @@ -6,9 +6,9 @@ use crate::{ profile::{Profile, XRServiceType}, }; use gtk::prelude::*; -use tracing::error; 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"; @@ -185,10 +185,7 @@ impl AsyncComponent for InstallWivrnBox { let existing = cache_file_path(WIVRN_LATEST_RELEASE_APK_URL, Some("apk")); if existing.is_file() { if let Err(e) = remove_file(&existing) { - error!( - "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())); diff --git a/src/ui/job_worker/mod.rs b/src/ui/job_worker/mod.rs index 4915ed1..c50ab21 100644 --- a/src/ui/job_worker/mod.rs +++ b/src/ui/job_worker/mod.rs @@ -4,7 +4,6 @@ use self::{ state::JobWorkerState, }; use crate::profile::Profile; -use tracing::{error, warn}; use nix::sys::signal::{ kill, Signal::{SIGKILL, SIGTERM}, @@ -16,6 +15,7 @@ use std::{ thread::{self, sleep}, time::Duration, }; +use tracing::{error, warn}; pub mod internal_worker; pub mod job; diff --git a/src/ui/main_view.rs b/src/ui/main_view.rs index 460d937..28ec87a 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -30,13 +30,13 @@ use crate::{ }; use adw::{prelude::*, ResponseAppearance}; use gtk::glib::clone; -use tracing::{error, warn}; use relm4::{ actions::{ActionGroupName, RelmAction, RelmActionGroup}, new_action_group, new_stateless_action, prelude::*, }; use std::{fs::read_to_string, io::Write}; +use tracing::{error, warn}; #[tracker::track] pub struct MainView { diff --git a/src/ui/openhmd_calibration_box.rs b/src/ui/openhmd_calibration_box.rs index fbd44e9..6258ad1 100644 --- a/src/ui/openhmd_calibration_box.rs +++ b/src/ui/openhmd_calibration_box.rs @@ -1,9 +1,9 @@ use crate::{constants::APP_NAME, xdg::XDG}; -use tracing::{debug, error}; use relm4::{ gtk::{self, prelude::*}, ComponentParts, ComponentSender, SimpleComponent, }; +use tracing::{debug, error}; #[tracker::track] pub struct OpenHmdCalibrationBox { diff --git a/src/ui/steamvr_calibration_box.rs b/src/ui/steamvr_calibration_box.rs index 4d7c250..f2f9c7d 100644 --- a/src/ui/steamvr_calibration_box.rs +++ b/src/ui/steamvr_calibration_box.rs @@ -4,7 +4,6 @@ use super::job_worker::{ JobWorker, }; use crate::paths::get_steamvr_bin_dir_path; -use tracing::error; use relm4::{ gtk::{self, prelude::*}, ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent, @@ -15,6 +14,7 @@ use std::{ thread::sleep, time::Duration, }; +use tracing::error; #[tracker::track] pub struct SteamVrCalibrationBox { diff --git a/src/ui/wivrn_conf_editor.rs b/src/ui/wivrn_conf_editor.rs index edf5d52..1c6d6d4 100644 --- a/src/ui/wivrn_conf_editor.rs +++ b/src/ui/wivrn_conf_editor.rs @@ -20,8 +20,8 @@ use crate::{ }; use adw::prelude::*; use gtk::glib::clone; -use tracing::error; use relm4::{factory::AsyncFactoryVecDeque, prelude::*}; +use tracing::error; #[tracker::track] pub struct WivrnConfEditor { diff --git a/src/ui/wivrn_wired_start_box.rs b/src/ui/wivrn_wired_start_box.rs index 1918b4f..503bcac 100644 --- a/src/ui/wivrn_wired_start_box.rs +++ b/src/ui/wivrn_wired_start_box.rs @@ -6,8 +6,8 @@ use crate::{ profile::{Profile, XRServiceType}, }; use gtk::prelude::*; -use tracing::error; use relm4::prelude::*; +use tracing::error; #[derive(PartialEq, Eq, Debug, Clone)] pub enum StartClientStatus { diff --git a/src/util/file_utils.rs b/src/util/file_utils.rs index f802fac..04ef1bc 100644 --- a/src/util/file_utils.rs +++ b/src/util/file_utils.rs @@ -1,6 +1,5 @@ use crate::{async_process::async_process, profile::Profile}; use anyhow::bail; -use tracing::{debug, error}; use nix::{ errno::Errno, sys::statvfs::{statvfs, FsFlags}, @@ -10,6 +9,7 @@ use std::{ io::{BufReader, BufWriter}, path::Path, }; +use tracing::{debug, error}; pub fn get_writer(path: &Path) -> anyhow::Result> { if let Some(parent) = path.parent() { 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..253ed12 --- /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"); + + 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() + ) + } +} + +impl SteamLibraryFolder { + pub fn get_folders() -> anyhow::Result> { + let libraryfolders_path = get_steam_main_dir_path()?.join("steamapps/libraryfolders.vdf"); + 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/xr_devices.rs b/src/xr_devices.rs index c93df02..846759f 100644 --- a/src/xr_devices.rs +++ b/src/xr_devices.rs @@ -1,6 +1,6 @@ use libmonado::{self, BatteryStatus, DeviceRole}; -use tracing::error; use std::{collections::HashMap, fmt::Display, slice::Iter}; +use tracing::error; #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] pub enum XRDeviceRole { From 4ea0ce53b0073b688c403e52f5c86591d0f9665d Mon Sep 17 00:00:00 2001 From: GabMus Date: Wed, 4 Dec 2024 19:14:14 +0000 Subject: [PATCH 010/103] feat: prefer symlinks over generating files for openxr active runtime json file --- src/file_builders/active_runtime_json.rs | 142 ++++++++++------------- src/main.rs | 11 +- src/profile.rs | 13 +++ src/ui/app.rs | 14 ++- src/util/file_utils.rs | 16 ++- 5 files changed, 102 insertions(+), 94 deletions(-) diff --git a/src/file_builders/active_runtime_json.rs b/src/file_builders/active_runtime_json.rs index f3461ae..3cc7138 100644 --- a/src/file_builders/active_runtime_json.rs +++ b/src/file_builders/active_runtime_json.rs @@ -1,18 +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}, - steam_library_folder::SteamLibraryFolder, - }, + 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::{remove_file, rename}, + os::unix::fs::symlink, path::{Path, PathBuf}, }; -use tracing::error; +use tracing::{info, warn}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActiveRuntimeInnerRuntime { @@ -38,46 +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)) -} - -pub const STEAMVR_STEAM_APPID: u32 = 250820; - -pub fn find_steam_openxr_json() -> Option { - match SteamLibraryFolder::get_folders() { - Ok(libraryfolders) => libraryfolders - .iter() - .find(|(_, folder)| folder.apps.contains_key(&STEAMVR_STEAM_APPID)) - .map(|(_, folder)| { - PathBuf::from(&folder.path).join("steamapps/common/SteamVR/steamxr_linux64.json") - }), - Err(e) => { - error!("unable to find steam openxr json: unable to load steam libraryfolders: {e}"); - None - } - } -} - -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) } @@ -99,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!( @@ -158,18 +94,66 @@ 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 { + info!("no active_runtime.json file to backup") + } + + let profile_openxr_json = profile.openxr_json_path(); + if profile_openxr_json.is_file() { + 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() { + info!("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 { + info!("{ACTIVE_RUNTIME_BAK} does not exist, nothing to restore"); + } + Ok(()) } diff --git a/src/main.rs b/src/main.rs index a13a2ce..4453911 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ 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; @@ -48,14 +48,9 @@ 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) { - if let Err(e) = set_current_active_runtime_to_steam() { - warn!("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) { diff --git a/src/profile.rs b/src/profile.rs index a0a3903..e199980 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -47,6 +47,13 @@ impl XRServiceType { } } + 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", + } + } + pub fn ipc_file_path(&self) -> PathBuf { XDG.get_runtime_directory() .expect("XDG runtime directory is not available") @@ -586,6 +593,12 @@ impl Profile { 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 { diff --git a/src/ui/app.rs b/src/ui/app.rs index 34c5549..54f3d31 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -26,7 +26,8 @@ use crate::{ 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, @@ -250,9 +251,16 @@ impl App { pub fn restore_openxr_openvr_files(&self) { restore_runtime_entrypoint(); - if let Err(e) = set_current_active_runtime_to_steam() { + 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::()), ); diff --git a/src/util/file_utils.rs b/src/util/file_utils.rs index 04ef1bc..59f8dad 100644 --- a/src/util/file_utils.rs +++ b/src/util/file_utils.rs @@ -57,7 +57,13 @@ 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() { debug!( "trying to set readonly on a file that does not exist: {}", @@ -69,7 +75,7 @@ pub fn set_file_readonly(path: &Path, readonly: bool) -> Result<(), std::io::Err .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_cap_sys_nice_eip_cmd(profile: &Profile) -> Vec { @@ -104,8 +110,10 @@ 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())); + 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 {}: {e}", From e685cf757ddb92cf3a7c48f4b0758a5c127c8c5d Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 4 Dec 2024 20:32:36 +0100 Subject: [PATCH 011/103] feat!: enable support for different openvr compatibility modules other than opencomposite --- src/builders/build_opencomposite.rs | 12 +-- src/config.rs | 36 +++++++- src/file_builders/openvrpaths_vrpath.rs | 2 +- src/profile.rs | 114 ++++++++++++++++++++++-- src/profiles/lighthouse.rs | 10 ++- src/profiles/openhmd.rs | 7 +- src/profiles/simulated.rs | 7 +- src/profiles/survive.rs | 7 +- src/profiles/wivrn.rs | 10 ++- src/profiles/wmr.rs | 7 +- src/ui/app.rs | 8 +- src/ui/profile_editor.rs | 38 +++++--- test/files/profile.json | 6 +- 13 files changed, 219 insertions(+), 45 deletions(-) diff --git a/src/builders/build_opencomposite.rs b/src/builders/build_opencomposite.rs index 631b69f..cca52e7 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,14 @@ 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 build_dir = profile.ovr_comp.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 cmake = Cmake { env: None, vars: Some(cmake_vars), - source_dir: profile.opencomposite_path.clone(), + 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/config.rs b/src/config.rs index 1ee2430..a35dd93 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,10 +65,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/file_builders/openvrpaths_vrpath.rs b/src/file_builders/openvrpaths_vrpath.rs index 1108717..773902c 100644 --- a/src/file_builders/openvrpaths_vrpath.rs +++ b/src/file_builders/openvrpaths_vrpath.rs @@ -98,7 +98,7 @@ 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.path.join("build")], version: 1, } } diff --git a/src/profile.rs b/src/profile.rs index e199980..bf81c5e 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -17,6 +17,7 @@ use std::{ io::BufReader, path::{Path, PathBuf}, slice::Iter, + str::FromStr, }; use uuid::Uuid; @@ -258,6 +259,78 @@ impl Display for LighthouseDriver { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum OvrCompatibilityModuleType { + #[default] + Opencomposite, +} + +impl Display for OvrCompatibilityModuleType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Opencomposite => "OpenComposite", + }) + } +} + +impl OvrCompatibilityModuleType { + pub fn iter() -> Iter<'static, Self> { + [Self::Opencomposite].iter() + } +} + +impl FromStr for OvrCompatibilityModuleType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().trim() { + "opencomposite" => Ok(Self::Opencomposite), + _ => Err(format!("no match for ovr compatibility module `{s}`")), + } + } +} + +impl From for OvrCompatibilityModuleType { + fn from(value: u32) -> Self { + match value { + 0 => Self::Opencomposite, + _ => 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 { + 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()), + } + } +} + +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, @@ -268,9 +341,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 @@ -295,6 +374,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); @@ -330,12 +410,13 @@ 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(), @@ -347,6 +428,10 @@ impl Default for Profile { } impl Profile { + fn default_prefix_path(uuid: &str) -> PathBuf { + get_data_dir().join("prefixes").join(uuid) + } + pub fn xr_runtime_json_env_var(&self) -> String { format!( "XR_RUNTIME_JSON=\"{prefix}/share/openxr/1/openxr_{runtime}.json\"", @@ -424,8 +509,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(), @@ -465,7 +550,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( @@ -612,7 +706,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; @@ -622,7 +719,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")); @@ -653,7 +750,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..97f3a1f 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; @@ -21,7 +24,10 @@ pub fn lighthouse_profile() -> Profile { name: format!("Lighthouse Driver - {name} Default", name = APP_NAME), 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..8709725 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; @@ -24,7 +24,10 @@ pub fn openhmd_profile() -> Profile { name: format!("OpenHMD - {name} Default", name = APP_NAME), 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..a39a66b 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; @@ -25,7 +25,10 @@ pub fn simulated_profile() -> Profile { name: format!("Simulated Driver - {name} Default", name = APP_NAME), 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..d3e8ff6 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; @@ -26,7 +26,10 @@ pub fn survive_profile() -> Profile { name: format!("Survive - {name} Default", name = APP_NAME), 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..a6e4f83 100644 --- a/src/profiles/wivrn.rs +++ b/src/profiles/wivrn.rs @@ -1,7 +1,10 @@ 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, + }, }; use std::collections::HashMap; @@ -18,7 +21,10 @@ pub fn wivrn_profile() -> Profile { name: format!("WiVRn - {name} Default", name = APP_NAME), 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() }, diff --git a/src/profiles/wmr.rs b/src/profiles/wmr.rs index 23e238d..c332f6a 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; @@ -24,7 +24,10 @@ pub fn wmr_profile() -> Profile { name: format!("WMR - {name} Default", name = APP_NAME), 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/ui/app.rs b/src/ui/app.rs index 54f3d31..ca00f4b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -36,7 +36,7 @@ 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, @@ -486,7 +486,11 @@ impl AsyncComponent for App { 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) + } + }); let missing_deps = profile.missing_dependencies(); if !(self.skip_depcheck || profile.skip_dependency_check || missing_deps.is_empty()) { diff --git a/src/ui/profile_editor.rs b/src/ui/profile_editor.rs index 348df30..de8affa 100644 --- a/src/ui/profile_editor.rs +++ b/src/ui/profile_editor.rs @@ -6,7 +6,7 @@ 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}; @@ -216,31 +216,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); }) ), }, 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 +} From 4905c8fed12d0514ca06cd20e49dcdf270b33010 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 5 Dec 2024 07:09:00 +0100 Subject: [PATCH 012/103] fix: create openxr config dir when starting profile --- src/file_builders/active_runtime_json.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/file_builders/active_runtime_json.rs b/src/file_builders/active_runtime_json.rs index 3cc7138..8804a4d 100644 --- a/src/file_builders/active_runtime_json.rs +++ b/src/file_builders/active_runtime_json.rs @@ -7,7 +7,7 @@ use crate::{ use anyhow::bail; use serde::{Deserialize, Serialize}; use std::{ - fs::{remove_file, rename}, + fs::{create_dir_all, remove_file, rename}, os::unix::fs::symlink, path::{Path, PathBuf}, }; @@ -112,6 +112,7 @@ pub fn set_current_active_runtime_to_profile(profile: &Profile) -> anyhow::Resul 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"); From 92cd8f6a946bbf7f500ff77390f9c3c5dbc57f66 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 5 Dec 2024 07:50:26 +0100 Subject: [PATCH 013/103] feat: try to find libmonado and openxr shared objects by reading openxr config --- src/profile.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/profile.rs b/src/profile.rs index bf81c5e..3a2dfe0 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -4,8 +4,9 @@ use crate::{ 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, }, + 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; @@ -645,21 +646,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 { From 9a4ef01ed91ab6084f90a1f8d174712a565e6dab Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 5 Dec 2024 07:54:42 +0100 Subject: [PATCH 014/103] feat: make left and right qwerty controllers appear as no controller detected --- src/ui/devices_box.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) 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); } From 7f05d696c4d3bee15b61f01ccc254d8eeb4c2a9d Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 1 Dec 2024 10:51:22 +0100 Subject: [PATCH 015/103] fix: update wivrn libmonado path to wirvn/libmonado_wivrn.so --- src/profile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/profile.rs b/src/profile.rs index 3a2dfe0..d67a7ac 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -45,7 +45,7 @@ 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", } } From 4f80aed3c2af7d61bc402d5b91067e88ef7946c7 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 8 Dec 2024 11:34:10 +0100 Subject: [PATCH 016/103] feat: disable wivrnctl; refactor cmake vars in wivrn builder --- src/builders/build_wivrn.rs | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) 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(), }; From ce5f486596aa742342e5b87d38d1f6547ab61512 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 8 Dec 2024 11:44:51 +0100 Subject: [PATCH 017/103] feat: refactor builders cmake vars and env to use inner blocks --- src/builders/build_basalt.rs | 51 ++++++++++++++---------- src/builders/build_libsurvive.rs | 36 ++++++++++------- src/builders/build_monado.rs | 62 ++++++++++++++++------------- src/builders/build_opencomposite.rs | 14 +++++-- 4 files changed, 96 insertions(+), 67 deletions(-) diff --git a/src/builders/build_basalt.rs b/src/builders/build_basalt.rs index e67e081..10d6029 100644 --- a/src/builders/build_basalt.rs +++ b/src/builders/build_basalt.rs @@ -35,28 +35,39 @@ 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 [ + ("CMAKE_BUILD_PARALLEL_LEVEL", "2"), + ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), + ("BUILD_TESTS", "off"), + ] { + cmake_env.insert(k.to_string(), v.to_string()); + } + 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_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 cca52e7..ce6e2da 100644 --- a/src/builders/build_opencomposite.rs +++ b/src/builders/build_opencomposite.rs @@ -36,12 +36,18 @@ 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.ovr_comp.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 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"), + ] { + cmake_vars.insert(k.to_string(), v.to_string()); + } + cmake_vars + }), source_dir: profile.ovr_comp.path.clone(), build_dir: build_dir.clone(), }; From 68d7757aa431c7e29b5ea4ed76b19666de951ef2 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 8 Dec 2024 12:02:51 +0100 Subject: [PATCH 018/103] feat: add metadata to Cargo.toml; get developers from Cargo.toml authors; rectify SPDX id for license as AGPL-3.0-or-later --- Cargo.toml | 9 +++++++++ data/org.gabmus.envision.metainfo.xml.in.in | 2 +- meson.build | 2 +- src/constants.rs.in | 4 ---- src/ui/about_dialog.rs | 9 +++++++-- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c268912..014904f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,15 @@ name = "envision" version = "1.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 diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 1c57821..8a5f900 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -2,7 +2,7 @@ @APP_ID@ CC0 - AGPL-3.0 + AGPL-3.0-or-later @PRETTY_NAME@ GUI for Monado diff --git a/meson.build b/meson.build index d10129a..6eff010 100644 --- a/meson.build +++ b/meson.build @@ -3,7 +3,7 @@ project( 'rust', version: '1.1.1', # version number row meson_version: '>= 0.59', - license: 'AGPL-3.0', + license: 'AGPL-3.0-or-later', ) i18n = import('i18n') 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/ui/about_dialog.rs b/src/ui/about_dialog.rs index 4e10574..dd9a840 100644 --- a/src/ui/about_dialog.rs +++ b/src/ui/about_dialog.rs @@ -1,6 +1,6 @@ use crate::{ constants::{ - get_artists, get_developers, APP_ID, APP_NAME, BUILD_DATETIME, ISSUES_URL, REPO_URL, + get_artists, APP_ID, APP_NAME, BUILD_DATETIME, ISSUES_URL, REPO_URL, SINGLE_DEVELOPER, VERSION, }, device_prober::PhysicalXRDevice, @@ -20,7 +20,12 @@ 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() } From 46df6d36e592e1a2a020747b269ee59e0ef1c01e Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 8 Dec 2024 12:15:00 +0100 Subject: [PATCH 019/103] fix: build profile can be specified manually --- meson.build | 29 +++++++++++++++++++++-------- meson_options.txt | 1 + 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/meson.build b/meson.build index 6eff010..ac20bf0 100644 --- a/meson.build +++ b/meson.build @@ -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', From 380f800fa8569c98c4a8e52f58212b4f1550aa57 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 8 Dec 2024 15:18:36 +0100 Subject: [PATCH 020/103] chore: format --- src/ui/about_dialog.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/about_dialog.rs b/src/ui/about_dialog.rs index dd9a840..593e0f1 100644 --- a/src/ui/about_dialog.rs +++ b/src/ui/about_dialog.rs @@ -1,7 +1,7 @@ use crate::{ constants::{ - get_artists, 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, From 9711c257a69309b171f7a1df68a8a18fa17621c1 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Mon, 9 Dec 2024 18:06:51 +0100 Subject: [PATCH 021/103] chore: update version to 2.0.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/org.gabmus.envision.metainfo.xml.in.in | 34 +++++++++++++++++++++ meson.build | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cee2da..944770b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,7 +554,7 @@ dependencies = [ [[package]] name = "envision" -version = "1.1.1" +version = "2.0.0" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index 014904f..196473a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "envision" -version = "1.1.1" +version = "2.0.0" edition = "2021" authors = [ "Gabriele Musco ", diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 8a5f900..3fcbf11 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -18,6 +18,40 @@ @REPO_URL@/issues + + +

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/meson.build b/meson.build index ac20bf0..57fd7a0 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'envision', 'rust', - version: '1.1.1', # version number row + version: '2.0.0', # version number row meson_version: '>= 0.59', license: 'AGPL-3.0-or-later', ) From b61f2d963fcf1824adac8661e37173f8303aced4 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 11 Dec 2024 07:46:08 +0100 Subject: [PATCH 022/103] fix: add screenshots to appdata --- data/org.gabmus.envision.metainfo.xml.in.in | 22 +++++++++++++++----- data/screenshots/01.png | Bin 0 -> 36245 bytes data/screenshots/02.png | Bin 0 -> 66865 bytes data/screenshots/03.png | Bin 0 -> 33822 bytes data/screenshots/04.png | Bin 0 -> 97261 bytes 5 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 data/screenshots/01.png create mode 100644 data/screenshots/02.png create mode 100644 data/screenshots/03.png create mode 100644 data/screenshots/04.png diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 3fcbf11..0981352 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -4,16 +4,28 @@ CC0 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 diff --git a/data/screenshots/01.png b/data/screenshots/01.png new file mode 100644 index 0000000000000000000000000000000000000000..7591b4ba7e168a964bd09817d029c0834d1322a0 GIT binary patch literal 36245 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 literal 0 HcmV?d00001 diff --git a/data/screenshots/02.png b/data/screenshots/02.png new file mode 100644 index 0000000000000000000000000000000000000000..4074f242fe264853758e686aab56cf741e2f1ac3 GIT binary patch literal 66865 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 literal 0 HcmV?d00001 diff --git a/data/screenshots/03.png b/data/screenshots/03.png new file mode 100644 index 0000000000000000000000000000000000000000..d29fe4707914d47f0078c57001cd672c39e745dd GIT binary patch literal 33822 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 z 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


wX%y;cq@LFmzHM0%j* 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 literal 0 HcmV?d00001 diff --git a/data/screenshots/04.png b/data/screenshots/04.png new file mode 100644 index 0000000000000000000000000000000000000000..e51e5d0f6ec10dbca4cf732af5528951be3c95c8 GIT binary patch literal 97261 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%0A`fV|O9l7k? ztAPmXEETay8;KRZa;ldVRE@Lh(p&=~H+z8^vwpw3nVF1!sIX%VXX^`#SqWIS7&v90 zBt}gI{TCq8DN-x~lo(9iH2{;bH(Q>HO28(pF1|CCRbF)eCiXVXpo}u?_>I z)2v!{b^G;Kzq_r}n0cc`rDH&+HW)`Kw)uE>QD-?$2H{a4j2*yLloVk+-5*p>`@H~b zMlE~!_SS2pV`TC6Y-3jDjY3H@n=8_4RIrdh_X089_+-A+?0xsEt(j8QX}3ZVZ6_)~ zCBOvqVz@a(o2{1Y<$!WglZA}OL|3j+nE+(<%OZo$3UFvoZbt!%zkK~wQE}gWkLQPM zD9`;Fnd_6a>JSA$MOFFBf~crmV*eDveE5ug69D_|aBasDcIyds9GaGR{eGEHkFst) zx|nQT0fKqvHs;i(4M{*bXbzCHHY?DL8y)@+Gld^B-UE{dAU*YiiP8Ti&*Jao9eR)h zii)p$$n|>dFV7GA7tx&}<#V~}o#vb)=8dD@uG#A!Tr28B*Dvf09G3LpB<)yO?!EmD z*J4~-vnKC&>FKT8UERhKN5CHG0nB}vVEyO4Nimix-Qr^lCg3b{3tnWPa6DN}I5->F z+K;EMJ9&PXJ>b-=W{Ci}u6}Qxgt2a`>SAM=tsN_A5kfCV0CJ>uwT;eZVviergUu z60~TPmX%de4y=EQzS#b0+d4Yf)|Jiu2u$dH0c<7vF@q>lX3pEamYr{|{MW#@YS(rK`A7CS&;*yE|4Wg}%!7-Q3o=!li&Q~1PTaoK5U-r$I$So3YNF6^r8 z_nX(T-b8~H%K4J#0A(i&Y-Oa`AQw|#D?qJSQWmY4@&2Q^>$+!n;w{;{rKo5WUrM|6 zbkRQd4r(e4c+5Wn&hm(u zUI&N5ICfTspi3fIZ36pam4TzRWNJ*vNjrXmMA5Fx!%8z&41JKSl#~ z)J8c^{8iJmgU&1ZWS+C;gDUVQK!>ST#z+ptl4~-*g7BiUp>wGKDLr97f`BmT=3W;% zcL_qoD8oa|dL1zRk^6(fWyK@Q(Z#q>DzGItqw%H%-w^*UK-&8T$n};A6tZ~tsxT#i zO&8%{0%^??*!;Lk2atx4 zWK@*uQ%Zbmq>y*!GUXTS%E&>ru2(oye9CuXC;~b-r}svvETJb}8(+8!fFp_nAXXqu z9q--!etET8OSOSSeo)B{z&CmJy0rXZk%Qfvu~HWm8f|qyD8obBXhzWpNKF@c`%@Gz zGTt@NVp%HE(Fbx4^)K;?3#|n{-$^Co(iYJn-oNF$NK!6%u;a@%d(gQyswOYLu60`4 zF*ODkrvgsty395bd1Ys@R%1g7pe~Efkx|}D_iT*K3ObGBux{SQTNMk(2LkT_z@734 zK0_z0`rqi!CU|WQViEFrRJfh2>I0L)2*Jm=W^kkvb>S6AZGveN+1MaWK&q+8(2foI zxQA>5$RErfu8$b5roFnm6gzs>&3S590FK7Se!&0d{)y->rjCJI(q|_!Guw^+FAN}w zUi*Sss-N@7lg-Q(LGe@Z=U>CzMS8|q>B^u_B4N-e2eq+#UJbN%Jd4d(%NWi z+g2)h9IRSHVsNg6EC1gu_38@DU)gK)g23Jgx^PsA#W&vyAgI`?yKmaC4~%gi1+s^c z&PX&lLJaql`HIw2DDkV20g*hpUBW86uQ5lh8=OKySttm)Nw$QA;^= zI`wk?IE{jmQ(j0)!a%jBI}B=BXd+ZWDi!mTx+d1;nxvsRoGu`FE|J9Gt~&>_S&>S@ z-*A1Q7bK_83F^YQM?2`o{SJv~MQSHvad6!(Ir|7+0UC<008cSoAv;K;Sm6t{LL=YbW0uoMCLCtL3~5#sB$*&<)eiQjRV@0D=`9GD)992=r79m1^Gu@EJRhAq9?wS@p4b*#J@d^`He@N* zgA-8;QUcEU@XO3a#qQ}P(+7+kdDwfCjCWH@r#WLu`HB!gVog|?HO5Nx%k!0-W3*Ab z79q0n7ndm-0r(13lkR=0+)@2sh|3d2GaE#KDwWxroW5}+YNw4KI0uNyZfg60H2K7f zOy45l@l5I+40q9WPpf!&dNniqWGiz;2t66QDE}Oq1$}pyXWIFsX1?SX721oz(=K5P z0$uo}StQVOE=g7cSM^rkqpGFelg&=5nxT_JA*UKJs>unU^AlY z9~VwP)7h|t(fD6aghXa@xlLOf0AmYYVZUYVP<%l+%iz)&tAuPmsp*idzOHQ4;(AAG zK$}^9y#h}h=S#f~9H_HiC(e>5y-zqA2?whW=J-M!=i_Uha(DoNNx&Gw>{d~1%}v9D z_$14<46dd7{Z|8P+YU8;^U4cV^)B?YlZ^^TI`h69hKHMctN~lbIkMff(oXV+crvm2 zGDU42k&OV^#;V@S4c=&bRQ@z0Icdy?t3BuN@WD*sj~kM6PGod!DL>BZ2I{m{kNEx9{I8ZKoX!Z6cK>_j1p(5n@bI3o*>cTU zFRc4Ir{6tF_yDgF9{%_PqB;oO8YKYAm6Y&Nb^eyk#7BYTUSlb1{YlFFD;5YQ{9ob2 z{}C_Ll!t~MdiX*V3OG3LGni*2ARXo*b$5aKx)Ff#7dpHU^nO!P5GJIR%~!A(0UY1l z4wjldu6VGN3pf$*opjf@5hw|Q{ti@=!0QymJ`tCYkhh&N02BbJNu5GBMTF3lZpnfPg2s>a0+%G->ZQa+Lqs|KC(-YFt2=?;;qm#m!VL9;t$rxlw3QidO zz&diwT?sa^wSP=aUl(X72UaCKUS%`~7AxMDN0Lg(>HgCC-8I@Rwx2MRDUeZ$c>tgl zinKRmD(OxMyqvndedJN|K4V*YR0CW#`hYAdZ-gmrzu$xiq9F(a5NV)UefWDb4-Y&D z(MJz4lOG5bg=YFFsUiu0T**R&X$kuFUsA))WW8GqA7#1!T~YYR*LSuhQIg^~>`tRU zYNx%J;1MVxGhl)NP_f%(0}JTD^b(WsIit=(s5ZcjKceyz@HG7ZBv}MmS-`pgnHHB< z;f?x0COVQCmA^Sh73A*Yrb7z3d^q}87&v+3UABg000~4Pkio12DyK$2A=qouwLK%fzzh=K}I7;?DWqPjhn#fS@VEuMFP5q1p;(VB*Wpn(@ZI8C#u1foIN zr~9kfLrSsFsPVv%2Z4%=t<&GVCiRPc&uxH03&1~eZzeE5{Q`Sdzs)vRPgf>0Q)mE4 z{uPKq0lpBz{ZU&$L_!j2z_TMjIn`#C^S;pQKwsgqnJJ7?|8qKw#9w-<$A++WnNyhDuvYM_Lr7J-_My~Z|3 znr`hoM2C?Q2KXM{UmX*UF|huq0p){=>Fa_4pfq#~K&|IMIvMQ$vp^?{-Cf53pS3HP4zB7Ea2?k>2>yp!Pn^V z_UwGYH1>mm=go?z9Dvbg?W6f70nLc^cbnwaY<5%0WOpZ^EYMwuot<4@pQtT)QP*2I zP^iDNYv)7i&QzfshsU87AwYIESWSuweBLqupgSt@`@_#cL$Db9IV^yKx>5hFm!*=g zvC!iM^0mk=ZC7);3{bhb<)X*SEz@n+Kv{;YD><%Dn~>U+?l+*EWDxMhLLmOPCL8ca zm4HV;JU+z{9mJYY3;lMlFgs zqm0(5(xXpiP)F8o0UVzS>#rUTs|ZkW*r0|0iT9xf>uED!OJaB;ho$4~6^?GLJ7K{5 zM9<=m{|QhV02&+FuRDeS#rduX8o^?@-ZGYG#<@S5aNqao6+eV9p^;Cc0DGRK0QvvB zckM+@9JwaPN^heH1-54-{e^=zqW89dP;+)B>Jt!iAL!;5FK}EN&Ia z$LBAX6&E?tHtwe7xZdwgFIcCJ%b``;l|J0n@4R`%tu84@pAmbULe7VtN@H&8?f+Eg zPh8mj$soZ-F?lf-Bk6&0wUn&WX)8gEbXM6Ti-&G}##>3U(MJRCP>cR&aa-K>Q)t~z z@~=LI!JML%0+(Q0T*~|T6lPtuLQLir0&HQ7@Lii>kp?~LP{_a7p!*6GaH4q#L5Y;6nvPqo~5iYa6DYl*XnO)|NM-f!gmXzsjkbXs<% zJL;TvI#om9r}#d$O5gmtk-0c`2c7$icWYFOtM6&={KD~Sf94h)Gw8^1z|i7Xh9{M! z!AlUiaZ|kbcH11SQmpkB7m43g7wS|C5FEU0dJQWuYsF z$LyDQWNTy7uNk}Ax$bUHtDi8Z8k!&Z_;$)X#IBBRaa#r7QH$})c+$k_ zHg4$CdRY=YC5RfKU9gJskdBN@By8~OUSHP7XGv3Q1&It}u9vN29~7)rxj%f6 zi5zIkmB@k18MUPrAiVd&HOD%+sBv>e|P_W zu{oNd>q25sdV1Wt(LvUs%Xw_KwYKCi?T=3L{7q6JLCf4yAW<_>AN$r#{ljLz6>Thi zi*0>QU8C*hv#_@=8saxD_&l5tu?Idy_l2g9K^y%{Pm*jS?Ue{!=2hITF=YEL`XBkC z!6ecX;qoYpk;2LNeR43a|HU)t>93_UG;k=KPTrlyHJ+?Gs#)mJuc>9-p9Mv+a%MDqt*~Ix?X$0UrUuui^FaiikLupj4CQn$r<~>u$F3F#mnfT zzp*!(Z;L){<=V2x;ZCl#yE%q*TjXZZlggiCdC_=sqm{CDY$7vmdUms|@+eM1G%h(KJr%TqRrl{vBijUe}@7T zvU-mY9bOf?$KeDNlF#T-;5ClgOQ`6kQaXr|qE*RW^d33sm3_v;SXu6B6uTSlGn;)K zjc?fTz+kQO?Pj#LpP^#wkacuzY;(PP`uh%R>D_y6rT*|Veunjv=P|Uq&+}nC{mzHa zZa82%&zarEh9>{CF%_Yrj0UR>oE%M-7A;CP z(1m6T7VNzB=t8M;IjJoC+p39V{0i}Bz8J`|=&3yAQ@~S)_*y!dFGx7$TMNfLgvS1% z^{6VSrxZBkV9caIJ!4J9`{0ti;%8kryull6luIuizC)|YYYPrsW)_UrL4{h zjM`0aP8~Fh#vSsl4$;@ayBjl!uSFznX#TK*^c$K=G7oVC{xjVqLw|lckF+~+ z<-Nr>4CaNq5^mxM6+`=9mAH6%^?iF^Bch9x=?bid(P16k>$DVYrq)+|o80%c`_}s~ z+O#`Su`v`&qhq0IdwC(>6R zrVNYWN=jFkUuIASvDn>e4mu}EyVPArd|Rw08&j^J*X}WH>OS2xh`nar6?e$|WcTpW zoUF)G_V&eL_vWrg1ersLGHj}sRBoYkx_Hq7gAU(ay|Y^!r|_rMNUe(NZgbig)$U_X zfOj_gm?Z>J%@mpAF2v?Z4Jf~L+Hc%FY>~O|)Z}zp4tbw&m)y%Ch=^M4vJsMyZTdrb zTb;ifG2!Z$)K0bbg*^S|oSu4`c;4{heHOv{>enUL+V%Y}-WWVyDKFGFR2DyMGF8pf z%BEFi zl=U{Rp-?xSAD$2fPbuZY=t=IZlBkR@z3sspPRP2Q%c#4W%a?}a7Hjzf8hKIk!Oi+& zu`lb*d1hZHDtKIWOGkG$X4h+)h?GQ6%S{~e)GRPn>iQyQ7Ef~P3l-{iekr%WVLFrO zy+)W9glTw zz5Z2Y$Y!4qZ^_HNUaWR)D##AsVNR={{S`^J&xc&-@KBuNK=$4y!?s{rVJGg+VMh>s ztdG)EurD@3)ZNiKzE6$8RgE9H`Gp6jsy%#s2$rwFuwaWAk`P83FU3ILWTeVF!WI^=ehTe6?klA6o zK`c;eC>@ll>V#5xbulk^zgz8h8?^bY+ZfVS$QH>`WYjm;5?iGFCFO^%a-K#~(XS^) z>;j!I^(j1%f(#VfbcL-TMx>i@FQi*^C$C{7{1FNlK7jM96zJ zuQ?u4DV0|Ba?{6FdP+{Sg?;uEJYa?~{1M(zSo~Bgb)Fitp!jJtl3)M=w{n5)nVEUL z8ppdz&3d#fbzXYK*=D+?9X^`Rj42MTh=|?N{mH`fsnrkEcA+e9Bb*P=<{5NHN?1*J z(-CIpoh@@@zTKGQ`siGmTe>!rc8}IB*I~HE?~Co&!?AQm;EC5 zKNx3p-goPUj0jTw%*A&s4Sk<>;*H|&s3ES*d`i;Hn8fX@MX@Ws``$EyaLHz4$cJd&j33Geef^LN-We25~A{>3+g&F>R1zN$Jabf%f=&scY1;P7*7@a1>^I5nL= zJW#R5wl7jYMDq6dLnZ?*CQ3u|Mt*8CT-zNwPjH0}{p!cqGS6x-<`BL_ai#8b%4cuR z+sX7aCsIYa(lkT0*zHC!YHph}v_Yy&1g(Om9j8zP!aPz$Mijk`eSzAUQA=;Ql_awS zwNbf%A9^RE7dC@SpTXE7q*7CNQFM|>g_#efUW8mApj?pod0-^d?9wDJ(fAaZeL1)H z2Qb8D{5Z?WJPkzrM>(p8&aCo$g1w=^*GZ=QUDS@-tycf)xxsawL!LP=tE6&89} z#0g=;_WsMICN7z|iS#J3d6O#T)sGMcRk0lFv?g0Yu1T|3r`(N)5|NJ;^SdV|S=1A{ zF7bkJcn$c6u9{UBiH1gX3&x{Lq{ZWMO8V5r4{7fi-TKfK$(l!Mzn@u5X)9URP?N~v zNxA0IBeQKKa3<%?@3A=4O2&nsG7FH^Yryb5sR?~S?cAg%h69ls(yB{uwlI#1G-*U~ z%y=*`DTI7N(&ZYAJ8;!JVDL!lI`?hmU?G3)Es9L4R~%Gxc#w%0_`V@*w6EXcs?w}P zw^zEjGS|?wNcVYeNV_TqKR+wwz^|xgfm}Gc>uyQGGQl?Bv7sMtsOJ5QZp}?EvOV`L zXN+O;t&z6Y`_>T|iBm%&U1z2KX76&j-c1I^u^DvBe!eU{XBqh9GXBk>#Qcjx*if#* zP12H4RX&$3Jl0txI3MI}Jprj=GL_Y8xiyXd(8r|XZmMtzpyPO=%?Mh0N~%Y0x!Xr4 zHl~`djBr%{KXS)9YnYpIfNFydKN9H8d!a~n=JMS-U>e~ z9vr}2_R02V;!RW*5z<>apt;V}6)4l|!FlXrFYg5?C;k3xO&nCvBbD#i6N6e=HN-0nJI2&(rPWm?A8h9ck0rklLGWrTHNnn z0vP`bi+tQ)HB%x0EF}PB4Lxo-&V#0gl zsNQ@iPW@KD^GOF!2)}1AH>R=)`9)1!y&LJOc{0(Fu&1PWxvpaAP-8P?N&rItzzf8skZa>D=1tgoywyIGCSS?>^WAJ{~ki>b~Zw!9qb8T-x8mI-V;-- zR{2-B@zqxG<-gwuE*SoI{0o4h*<8;792TU#(T)Pa36vX;rjZvyL??XdbgG>+GdT@l zL~;OqX3gpcMbA4x(FfMf%zh~343JG@#AFd=Vc?YzQiqOFC@c>}l@p}mN@N~` zrbR-4_vm@%U)&w)3qbX_Z2-T7XJmMn;DPuSW5i$){D6TZcK@H6(1tSUFQpVDP*6}{ z4RVm|xH&o1LU+@l@FF_pHSp;4fZ&wEP7BBL36%-m=#u_;7GZRZIv@&7kAVU}`bhsG zh5%zTc^wEa(xO`upidrCRW4Q?IZgz6lMg_Dv<5_t5rnKJ-E}sb*|KSTjQp6>SjeBQ zP}6{v(cBK|Zaf1n^Z8EPRA`_3KaugTfPoHC{?fzUS*wpV@3?fRU%mR_z`9fIjz+CR zOG|4a$($_xLUs)h0*z2$*$RRA4~&uCFQDA2*G->j{?rpiLGuba;*Nm571D(^{{O|P zrh?Lew%GNrwz!}${&>lckG9j!Ssf5k`gma}od$n%UOQeRlJfite5<}0a&HtE&`k`b zMhu6ctQAc)V66~7#*fTH`3)ZfY9GLzh=>3mG%APupJ|M(xDfZQ|FJ1*m}o-KnIC#U zX?QYdkQv}>FzrBQC?#T80*C$Et-r7pAXIdT(a@;}z(cbJVp=);U_dr5#RnSXS%o0| z6GW%~>iA%9ebZ!8isU@-$5}pRHpvNK%&mnyBm#QoO=7of5XTEOsOqzkl%H zs?Da{%9<6(XZfL#@$rmzkO!%QTe9RDWroJ5TlW4FUv_S8FHjba_pm7hGif*GlTuLz z*>n6^j`uep3-jW5zQO|iuJ?!}Aq1WQ(x}hh$i0hRiek`U3ojjrp^uAZ&`{Pw0@TgK zCm`d{Q%qH@c0AU{i{`S~2=RSVRLy-!7F4x}Ax~aj^evDPzn1Oe)4T?qr8UBN#xP*v z)Ao60p}7iolNdlV`l3N{%Z!=#${a9`#H-6&rkf>rXk6`n|MEg`54=|`^9>FbQ_<9a z*8AdQl~S$Ox+#`_748q-Q(wT}w$RPFbBlPjhai!C{22<1^mAu>&mYVqT#R4Pe|I=0 z;REgWt5K%GE_SyOH4LsXuT?I1q_;Aujw?@Tb($OhT6jT(-CswpaT{Pf%Ap2Sg>(T} zTJ6T#Mv%8-?7d>7Z6F4w(DCN<27~@2_M!FN|Ae2^vrZ>#kvHeN(Bv;^Cj3-!)b}4p znf`UQx^A9rkGaud5@H}?5N};I0^SfPKAYTMe@v$N>%pyqGF*QX%x>H8jrf2`l4`yM zq~=+JP%bGDaR>+!X;P;(>?%?ubi(M3+hG#K#lRt`_SN;IhnlgE6-?Pmq^oR4;(&LZ zV6*GeaBBPk7SEc({aU>?zxRi2%^JYX%NiWUbh`|)Zy=my^T9xPjmdi!p;>QBYj#Oi zxoEgilf3ZNYZrv4N#uP@AwFz1s4|`WcuH`vd(IAG_?&=qXTm)R%r$Dwu65gn?3J2E z%|I38`+qu8EywWE-QhUH%Zpixpx6D5M?z`L+@JZkHf{ZUjx%+JfL18z`Jfm4W^lO% z!V?TH|7WVn9FLbRk4iOQ6{4H3w^O8U{gN{Q{L**{+zutsD4>VCQ(5DY%x}C;n@C9goKboTviz3gEsWc;c0PN)?|zs8yRK6mCIz z6kwjE1FitbVaMjRLh7Wpiwxi6oefm31l(I6>}Ul$o~}j%vgWnSD_f|u29P90Z-N2+ zrUe3i(&?TxA6;-7Rc`}MZu}OcHfnE=<%Jh|@?Nif-|@a|1zg^^14spsgevrv)I30x zAH;AMXxyX}UmLJ}c1}O>O%I?q>RUCwhZL*Io7b&BKPRrA`tr`GU#=A&o^QbX(r)+9 zhf?jP@oI5|W1t;{E<<=OZPnH?VNQsL)zQ~$*q}RmNhv?YP1lDlP!{Bl9P++ zV%O53M3)aHA5D|XrSjznrGM`XxB`+=!Syr1F+~Ey!2FqMh#Une5JJwT!*@Oi&@TaP z0%ZLca$0Z@rFv;3$?Bo?ix#99Y{>6#|o z$fV~hcfFf`*ApI|BVJ(icwfq;)94u4VydU72QZ=QRy$C3h(PJ@>J&iG4iBbqbEfjN z$Ww(U9$y1>s#!V>y1-QJo!=GNPy2oKeNL$JoT#Yy zg|NoC7R>?&H}@n}CK|^Y!8Piuw|z@hVw8K5O9hv5jZ`D$!PLXR0G910;(q8UTRgZH^CfL2wJOja{<{yVbRHFm*uvpB8}C4c|A)kAT$L3r9brkkjTTy33l7sT?7^5(fbA*)4f+b1qRlW1Z>iTj==u-vBYSfvl`|rr6x=TVXS&pwNB6^yymja6aDICUe5`eb#)j{YOF^s zto;3kSL?)#>sOkK&2Fc1Gv1L%J?zU1;l${*4gu3c4BZUgst=$_IO%1_Nh>^eEjzzs zi4p%j^Y^T8v_pv4!`ig4^kGYh&5#i)3>Uwihg2A$MGF5uQa>uA30Y(kq%jFtZuR4@ zeU;}!j{FLNg$?&jfUuai7>%^$J2+)fy6Gc zdWtwn{PbV*a`FO?67B;GwoQ0@q6A$~5U!tUqCZG*T=tEs-INodb#P%n>5T$vNd0}heJ1`%(Yo}v6x~JnPn9Kcrnx>sto$|iU zJhyw5X7Qd@sH7Nw!ZLo`Fd}z6W#K^vS#UzwPuACugb7i&-C{H;fm7-)A-X= zexbsd-k4+fVlDAeKBeh|55iP-Vo~<{<(VBKudNr#6Z^|CT>+-|g%CEY$bg%_6i=Zt zc>(!g8!@z$pwIsE^LyH<_7m7N(`jfZT9WE zVAQXK)vZN^dqb+q7`lhUd|f3II_6TTkcF1>YTe_4ODkidr&vnh?h!(`?v@RS8)9{o6m4Y&kv9CT3ih^@du2uR9;kg}dA zpUdHhhhTt@Ia@2Sha`CDkNY9k^Ah^Z;26+pC6s^^`hO{6r~CSXJs_yye|EOM!x@s~ zc^9)ujkWsUF-6fZ#hBlO2fj3s{0RO2Rvt&?m4v|0dFBavEPQ{ji#%Go{YV$_Rp}FV zp4;17{GS})V4{dT?~eD{-$BrzCjow55wY;ZKP~@ddM6sl!OmXe{aglIVz=k#*QqJ> zLki?#0e7E43_|kVGmnX3kZI=()bS^pjDJs85<}lnPQHlrxEFdXyQS&C&-wbcSSSa4 z5RiNH>oLyo)2HFBkt|RxORgOpd;~RZE?BY!UG6}lx@OqK)N^9u;z>}*SLTD2Ow+Is z)ZJ&3Iv(q_sfjkwTP@g4mYtl7;YV*JnU5gZ@f3W`AFt1O<#MQvaX3lW;y{*e41~o9 z42T2qEIvR4Ck0CDOwgzg`=JwUfj9&s;r3^;Jc1My6t-tuBgAP*%zAKf#!A4O&T_x_ zcuyvi!WDCS0bC7=z)@lhT(4&RaxP(iZ_Ks7BM*U~6}s}APfBt!iuz0NcqoxlQ~ZV$ zTiM1-b<&z|54fE{wMRov?g?UrhEVEDB0q!4Zf#{H^PEt%#oaB;K7AOJik@J@n_9{q z5T(n(lFy?2L%oB72&_lFG5w$-B;!5pe|NT5IaGnv3G&mstU-gS&8-9OM@~%)gGMad z*BKEY+Z(Aq1(!1l6h7S*Y^Hxg_%Rdo#>+N{|2~HkOz;c^*ijn_$n>C3!uV(}I_SAm zS9c`UHb}_H0J+3tz@+d6#JvFdKfiG}u*-x-MMZ6`bOgViCE#=A_&Qe`DNG6ir3Fbz zNzXv6Y`o`2Za~uzn!4EJ;;>*X1z=Flvs?hRjRVCL8ypCl;x|)eMnpq~ zT2TPnV|)p$4so-d{y?G5{-r$t+sv$$SP_lTWDpxV@+dqab6lU{vig7*PDoEIwV)cQ z4%-$CWE2pZmH*)S>C?-coE$3*c&$=DplVaphMDHOKT~uDod?Ld6N`?B5CG}OpI(HN z+iokckwfEnI5)8Aa>*fCAeYdrU9OR39L${HX$d`0l43%injHLxk01ZISOBXi0uKr< zf)+j`xf>N=qJ4-kzMeFPdcId+nEbrO)#Zujw0TXPW@~3Rad3HhYQTg+%J*|~8-S@+UfVN03Uc)p&M2np>P-1=<*{ha~P+s?jtR6p>n zTS@eQPkJUN<1VRS4aah2{_lJJuL;M_-hiWj7iN{*Ea(@x1BFJpRzg#Hz>X zaV2-`FhA;I){U0maNn4$Lej&S@nhl8_T9nW9kyD+i=WQF-{F$k#MFnXifi$!3CD$( z#GQ6@0rDJ1y{xDB^b92Q2nk(|)=4GgaBG&PAg?S_@^bX<=` zKi1dUY<{e_-zSVq_x0>I-eJ*@DHKSn+0;V_S%bct?hw+BC;$6YC`d6oB2F9PPL0rB#=&nF1lq0* zy*Qi8^N_Domp$QI1kT@^QF*RrV>YGy%l8wGnfyxmx43n;zMnkU@=*|!jK$ao_cKj? zXlI*J*U0t9JUhEdmRj4^i5wDC==*j^AdCANjIAjQM@$e$_pMJ1gcv3EeQrMKrFRZ` zSZH#oGL+VF*=KbI`5>I^?EsmtGNg`l$k1tV*VKbc6W^9J==2w72&~s_g~KRve7N46 zCh%5BBy&5%hQ^aQZvsd>9Dwp|6)&Fjywj75e`|>B} zMyBsbf6%+tz7Vsu0VxGM&wV+9PSv==kR zK@BfHvZ%;@3B?EhMmAUk{Mmw$lcy?E$l}>c0SASNpuyJ0$ejX7FCA=4=gh!1y6|SC zNQG&^8r7O9ohSE|-1%>XW)y-qWxPB8o(3j34NvL4OADP$be(gSD%ZYWK~-4;LZK`>?tQxd!g0-RpSiFLUx&$*3YyW2g~eDaq}hX zL=Wk0$&0YT@y1IIJ{9Ov&kaBZCjBUO4MB8a! z_h@G12(&HzRtDwey?mec2mAZ8LqgCbB_z@Twim^~(eyQgt#vhGv7+=`sdTU8?8Y@t zyBj)-TP*<}#M<(5YYsYCVY(L796uw{vhcUZ4hV?yGZoSG*lN6@C4` z`!3jx*?ts(Nvz2msDN{z%ENTf=~VKD;{OiO0fyTe%W?VS)|8)x^$|1w0Oc2oe&G&$lj6gvBL7NHhZ!`4WiVW|LnC2#6nF4dV!4=oILe8C)no#Dsv2^s1 zwCsoMCq=K<%58=8$T|7^GJjv<{Ui_#%66&QQf|-ielCoKk_^%$<$#T$XKd`d$z+KP z5WtU-N=Zp&0lD}HNc}IVDFxf3%(ww^Vh<=oZH*;Ae#`}XD`l2&Sb%M<3n?cz*CZU1h%H1@Q}d^Fu}%x0c+0zY z?~FmTg8DQOt4Xx5wnk4@naKp5`6j45o*xGPy)B$p?<-`>up{#v;(0D+^Vy8}UG}rv zt773xh-XF2wP6t$#3Fy!MN^9sU!^qq6h;I$I0yz)AtU?-n@FjCcmy#%1;BG|yVCyj zX7TZ%3*}6Tsi|G^7DO6KgU@pSyrl@x$uFRYg_ahJuHRD}Xx_h%wm)1L=ye1F_$`oI zkO`7Eyu+PbT#A}w;Rc~r`Y(XA+Y^)dO&6eEN9^+;%H$(25$c9NC~ms$l7un1}&Yq>-W%?%w`>&){Gf z``@uzERKfhB(a}|XgwvOY~-huW@BrWRX^02RAhRf#g`}-jf;n|B=oaZ*LecT*Te418c?I zOzU)vAlR2=6sDwSroO8EW9M5%EK1O+bakI8R{aH9vZMHcY*>Yu} z+Q!HkmSJnMH=&68$IaKCOvvmg;kJe;GNtLswJ(oY455ReAve>9S{onUr*OXsRqvJ^~&UOKD=4_#Wv?N z(?9!Hj80dvIX|;+ac$OLrp&wz-?4QJ2CL8vFZR-acCv&RrzyBU5|}fe670?63OBb1MA)mHYZ1&f_9`Mdz5}ja(B#f2Kff^OhA*p zBpRkr;7c~P*b?m~Nf77xIm;6+1eK4E&v_HYy*NowRU$q^qHer@*&3$`d z(qg!d{xMk8=J&}cTfY5a0@vmo9xUDRmQ~Mj)ens`#Vsk$8C1S!G7=9f(phM6NVu@nv%W!oho|JIeiJSW&Nt@%y;Zj2i{fo@6_arZ%Y8SCAL-F?gL|jiw|5&6 z+n=LN#Lu6sIp>Dr8s>U@2p7O2c|MWoMYWhl%wwOQL zRZKp)d&Nk9vN0%{IW#u=JVHg_mC49w9;L6-!J$Z3Axit73@h}0mAZa48QHW!DV*sy z!x151)U=Dfs!dwPRjvCFb_z$&p4-6wusYdnXeu{1aC*nJJZNWaX`NAkR)EjEHLYc}10OIb#t;o^t2 zt-apc%+loHRLny-%ogi9Hm)h>u-f`t1tVuVL&D=&fUAAf)g?zjLK*`wGN-K1fggN8 zJrvx{%*^CyhvOvv^P}(D1C|0`;Avg=e$_ZIfBx>Fs4<%s0=;BToBug__=Z3{5{~A< zY+hQl1AsA-4=GyK|2DFMBrC^q#3P^N*I&&*>-<$TxhpBu*f{vP>Fup!vF-G`j|hgoGN62&||9BS-+w#5}}lOnGYaOfaYoYDj5kM9a>JjDDk{tZ>TJdoaPlF>I!$uV48)LF z_sM?>u{&jQV#503Y?Vy4AxFm&r%^?|yZ{5`bIs<4XbGu1=&nN#M77d~Z%DQm_K`2_ z1A^-XC@_VnMxzdGwj?-s#C}>Eed%kM;pR}6whW30h%cg^9)`2<=~CyHrc@cWf!C(% z_VQZ$?k$HS=m=9N=?Vaz_aItcx!ZI+Ul4?OKP3wf3|wx+pb`sNGQn5^J-S}U3n#LN zhg*x1@pKSz%YXD)ejaw6Zsavz(T2K-;1yh7kg?80E9hZZ3M|Zn)h%$6p>pX!zh(4PMkeNIPwE@`hwpDr8~>(Y;~!6 zw_{{#aY(cYP4vY@STqh<-|;E=w);=E)(n?nub%Gjz!h zwWjhP9ht$D+7oF2S1~iqxz!V7@>IQUu8}5R-JViRDxMqvm*0v)ut9u0co7h6r&oh!kC7(TZlUJl#4F6m2#tRI`9jOf zDzP>nej&rIKGCjB&f%dg9;qb0h}4Mqg3}4R1}nHN*>NRLupzNBPh)Lu&rbiH9M?fk zUZ|$fmoXm&q|2-m-(pMZqP_e)2f(qC5`-`kmoFhosf8Rh|dUk z`(N;xy$Xlxjnsbzy*U&P5@G%;At@wEUl*XwhX39^$xr(Ee0!_oCG`s$PfCyLEku;! zldXit5jOqfw#y$YO4c;Ro!$M?dHo>}<>b=TDe1`14sxn(AH`FqZ6o^=q&H2O=AHM(>Dv*D;T4A^apOOwBq2`O>OCH43<@)OCP9}#;LulSFLw>s z(<^uI>?x1co;Jj{=lBGr@V63a1v*}P26p#=<@J`c`B2`T0YFv~%o+s(0^xqsMr30f zDq=5qmWAgZ-LEZcgxT=H%*l3pv50*{@EuKFZ0gT`gRCMj_;gKXSUb;^fz7D>_in3n zyr^J5_FCN$N}<`a1n6ym{&Z`!uV%#iXd-zjB@Uk}VyZ;avF{HmF#t?75>IgLDUC)` zl}rh~X4MN71p!t>#pEdtaiT%blK?xeMqyHSP6HFc7$e@TyVUic9_yRLq~1KZ^2^h9 zQ_CMbcYiTQ;*bfKOCIfI)D|%^gMIg#bczA zF^~kx>D4l{{Y|*v9GgC}M+P%4$uS891BdA^vqi4DdInCXzO25Giwu>^Jwh+|$Ud~6 z>qg&cDI^oZN~cRMj!@{x^Cy>V8bzZY+qWK)@1sfhU;m41zlV~}%Z*yPAL96F>cw>Q zYOl+Bl~1dwcv}mwCU>FtnZp{At#Dwk2EEnK$4{M=p7z##>S(EZ-rV`Dl#myLDY*OH zV}%0@8s#-Q8cm=TJEy;}9_%&4!|XXKHHlj^PMOey^6iDHs{Bbf16ju-Bz`{4-JjT0=mt#PHw1fsUPb&|~~}zmSDe zDg2-$&VN5AX#en$06(JuHE2q_gpmT?AvaKaWyBq!2_`kGO_(=q=0iujEGI{iT0 zj%Jttn*E~)(DW&&s}our92`KRRhyhyf$TCIG~1#e<1QV@W#plP8<9nXjN1QQoODD? zjHpV9W=52R{JzV->wB&korp|Z)B8v!BGPC)#NV(ty-TZ*m&*+1mK;|2zWpV3!s-V zdWF_I31mhC74+?0U1OfGPp;x?YS_WP69IesKW^*tst*a63?G49W(z26mP~K1u7&|2 z54l7o=5vj&x7`*3bOeQtj*bZcyFt)EZ+6HQH5wX-^&bygEz@i3?&uJu9|P(%8`qBm z1i87nqadY}7BEJ4cXwA;fS|F#lhidQEsPQ0Cs=532nX1B$7Q`F2*P!f?y9@8V-(lAo=O&L|R{1SY(ZWJw9lt!gw`Kq{wpT{oF4e_ueVHW0G)XPhv!y5{Lr?Mhy7(vp{E8F78=Ry)$K0+F|Xb=n5oF=T+PRbxQlnsn!_ zu)M!D@-!fe93KHvrRo=q&y={Imcs%PQg|9pm_XaI?(5A9^m!HMJP`WunK~8#&zp?p zq639*2*^8%;~0k-d}cjgXBD^Q-Wg<8t^CpP^+n zPj@{GkgSyws`e)Y^r4;Q^qv=cZyKxL=@XQ{tDxc|;4a7nr0{15Rly&yB!zuIE4L@6FmK|w*4!Vl*Jgj=pjk`{;Q8xo*Y!QndY zy^C8r0O5(*Kym4{;PL48m3wOzFs)UajaMtdcTG0{5mKe_$3gsd3do6xhui|ya!R|c zVK0?GKLo!nf}88aWxa+>gq8kA{dbuTv>(5GDoK@!*D0_fLgPj)oBHV$Ng#q%3$tJD znx9?)AVQqG-h@(gqH5#I(Ev;%e{NL6-0tsMfTZ9c6 zq(Te5aiAS9`c!>RtcY6Tf9nC#4wN>D!om}qv%;A@TQ$0lICb|cMX}y2@9k-Z2fPiS z8SB=e$x%ZrW#uaUu-SAV#?=*LWBZus@)c!wK3AHYlN16I#}DRC3I{NYcFO`H$4L~o z=nvS5W7KBW?{@~O@z*YF(1(898YRkNF4qn3{UwG3NhvAE$D7#Mg4 z$kAQ562Lh+A4by18dJZ08+BW->xY4${FOC8JOk+Q#X>PmZV(J{Rthi!$FM{@$1tiBMTKa)(G?Xd>-tRG0f3J8Ns}OpqzI zUZ8n&$AYwSV=-6T8bpaJ-yM{Uj9(| zdkk=sE{;5^ZfvU;d%KHGA*-+eA!ZS zT3kBHleypWlvt8z%9A5VrulLia76O9&#oy#aLc#qTA#bAj4{2e5Xtug^`#?(yyh3! zz*Tj`H|mQiv$&>~7N>|H{;Ne%?S5g60rWNdVFW!|Wc0F}{_0eNZ|eA{>23{YXvV-Wj>(CnPw5*rSu8fYV*Do*vd>H9aeCWz zCfp;y=AyMu)Jqht)qW*RE6QCxDDHg`p^8m`WV!7}?;tH5qV!dFo%kAd35E3e^G>Tv zFz9oV*ey0BU$eT2TYn&Ut&1z5FV4=+UUd6S4qx-h>LYL+SdB{1IOHqHRT|0cJ9W>d z*Z!p9N7CE|ue20k&L&fRTz>e{l3Soz;|R!C=MJeJ`|a{|Vo|{7_m`PYGLp(_3<+xt zqQBG^8C8f*o@?>or+yKCv2=UM_Y!MYNMAL}BCjhlbQ zGDr~8#Cu|c&Pl{fCP&f!=MsV?N%_B(6)uR1PK?}&DjNAEVlt~~?4J}e$*0lt*i>S2 zKsVt?79}NQCAfgp7US^gu*Kt%ujYv;2h-ar50E#bi4C`(Nn1oS}S;|0raS~4vWvapEc5IceGLGY|jf+{&> zQQKFuDqs$>a3(RS;L*tB0$M?+Pv<2u{W92sWizcZ=^kL;B0wyh4R$DxGpsr1`FTDk zP=y;pHQ2`6b+tv;Ch>s$jqvj6DcrgiHYIFU6zf}!cU|do8sCYi*z;eIvAX%!z|&>v zK4dWXs-{;wZ6r4PXLu+vKp<7aRn<^c%?^NTLm*X9t&0x|vK%C14OEUibc3(T3ZXUO z-`Z>q&KEy^Hj&-p^)CAQL156hr3$%l}n`q)<1{0<=J!r2yKa|33cztt9>RJINGNbI>cbU9;KV zDt!NPn*2C2H|hFiJ)YX^b<+RpPPr4THlGL|MUBldfVAbG`u7#w&z?Fh-gybyn(TW2 z6$1VHMKt;n)*mR40v#-(bgpM084F_w3_<^QpmywVf1K_P;oYq`S;Emhck`c)-3GR2 zrE1v#cM{sOyD5w#AyI!9WByR%uVZnw^Xef9*CDuikfgix-IyXuJ@k11!IePuf7Pte zN6D2dsKy5D!>)Ic42Z#~%F0T64X`2j!2jxCnocISbN)Qe((m`b>BS6)(1rn=%f{^7xpa==vY5-)hTrf^2Adw43Y|`ZB z^A?vc`3#E`)kk?}uE)B@Glv|lPcbN|@f`r1yD}L<1c(Y=&bSlTT3KjsAPJ+Ure+JO zeL#mf3jAAKzdA@QjG2DEs)$?ux3E6o{$FAJvZdo&W}qWaG_$!5k2F*O((E#!(XfBD z{6tcww(sviz(rd6h=y+_D=S;5)xcItK&QK23id-e19HvXebdqGKzkh#5%^MK0I4KK zeg9qn@J4l&b)bTnf}|cDSz23JEl)^mJ!jIEZf$MdGx?EAQU#1H|F0SsD#FSI>CHQJ z!C8-hy~_eIW+zoU;|0P%YnTOuP9FDFsi-v-_h7rM!rPKqV3{~3pq>1yp0cqF1wms9 zK$)c`@8j*)yu6_9BFoSdQkUs!50x^tB9TRaTK)Rpl02M(!}W^UZhQ0_Sm=eDo14e$ z?M#8AB3kKf(TlYHe(6N8SzdJ$sGQfgYI0b9B1#$L1L&yh2!KSu?ZDKG1l%o~?k4XE zllq9s$YjA5U%F~mKY%7JvZ=|+4k)se=9t#C)t_`i-`=}>GFB5@BC==~)YYh})hs`d zgPeLBQYZf}kH}ANcCYnZk`bVWoL_kh-b5z7fWrdng)sQ(PWE4OI5j|FxI zVUTs0{&;`rap!ZB6Q{jGb_nvy-?4t3i3aiR{V08avjdw;cr{;d5Sdf`OZ%a~=odlD za_5!6J)AK)4l`QJXv97L-^_Zy;rBOzsIHCw0SMP1Ravpet6h~17r@+BXTfag3M5m` zUj@AU^g-rY+Q}|-l3j2;l~HEH?brlG_NL5oQE77~Fjy!8F*xK>ivh@UvI-RQMnV4v zRr0+aYOB({Qf+qSq&Ec{ais(KQ>C83K6o2Wfyqyec6XtPJA5AK%w)iRh`Cc!s>*B= zVH6=Uvb^WGUCRJ@JABjiaDPkt@}+NhQHR(Rb-(I8qYz@9CqxSCl%GCsdg*juhDP z%-G94I*|c%&qfSHAkQeQ0lJa6wY5b&3r^E1I4EdTL`=*CNFg(!TUF%Jl-pb558NcU zNt5cZ6owkDPBQK82RFHkJ+oU*zql_+^EXw#)1hy&*+54gO`dzpkNNUB@Co>Qrb*3l z%w3r-(kcO#%gSAT+wsUtpQYc?|}tqV)ha|N)te${iv}3TPqS;SXc}IcT>6* zP>6vYAKAqdHP<}!4{;8Xf-x1uI|n)rJx zGFm#8?M?j^85l|XxlW9aYZd{Pl%*#tEj<9ZhA~JIDTgjU&dc`39YRfic-@GS3S-C+ zocwb*pgaizNhX|(Y$w>o3vq3!uGHTvaS{E^+Yt_Y}!C!+*8=Rv2bj>0TEg+eH(uI?!eOWRgl zBdMV$5#U3%8D$h3{Vmi9tCcvLSmkTrABaArdH8Urt@gg%#-Bt;vF|g8cS+UsFH7kd zlFxa~QFADnior7wLK->E<4I^FI^Z`AZdUHK=3W+aw-6w|VmJA6Q?Mqe9o`E2C#D!@ z#QOS7jzjg*3oWE616wVhectNxD|y%K-xClFIm>mrZ;{)})T`gndSid*trpRoof@6% z$LU03tNGs#ClDF^(~L%k3mTX?m)JJ=??WFI{^ zS1(2^Dz04ayUi`*CdD8t0RkpgR@NhCjRe9JO}I|)H&@8FOjSCA;&U|&gQ@f5pbr)o zHtzMJ52uAQsirb@9q!z2Z%K~Xo9r^ub9X-!MSl)Pm*7?J;;CZBA z?p6KZ$&;Ylg|ScF;#8+QSzv9(X8;D_c-Y0f&-q}pUrd1&+W8#~@&`GMZH(_c7Cp^u zt{RWBVC>*hrc$tsS7!Z$0Y%DZLJB5^snHg@#L#t5F0@?JbOe$Qkd5Pi4t;ma+m8IO zj?$(-0wpOO@K=2z$8QhRO${M<9in!##qNXGhZeU4_0Ran4}vxu!siZ&f#%&4aABbh zzTFtks+unE?jI_7X9S`ob9xuRFB!9&KnZ=Sk+2#5T-(w}5v@}r+}OEz7n*3t6TO}|I+!ez2h4~sVRXLbFd9UQa#i*NE3 z^XK7%jFjY&`CDIqCX~-sSh$%o3D%RgGdNC*nbwcq;&|g?3KlMTB84BfYU)i-vs}~< zTfShgx8{?-mY6-FNG_U(@h+b$oMs}*wqqOFIz8gZbq`a2_d{YITQlf6#Y>VNZ89g2 z{g7K5+7)DPOJTI#7r|HJOkw@VsAo1-%J}HuJq2>L>K~ttl^Tw3RcYbx0-%Zee2Y#D z6aD5}7JKvZAGzQtGDdC6gW9j4Oa|lthhHP#3_N)v2mNsUaKMa7u1e)j<+p;iyV$)r zXsWK8bqc?NvOv~^zj*U5t5>5dB5WG`qWL2Jr(R^&i7~t+>p$xHcM!7?bK%|buBLmB zHOzJ`)TzjgrCq{JD}y?KXx(L^aWFqkfh#c%wWfgN2r z!W}k@b1m=Q&C8~rKgqaM##BdqebIC%@w>gA@t4{PSGk?uGCj&Y=6Thp~s1q&+3J^rr|yZKXV!#09dKADcx{@|UA82y9#_ zVkc|-Y|pdfKmMI5dVNRvGu&wk^;21;Og6jDAS;)+uSD=7w3CjBcf=g9y{knxA}Fs{ zh*|N^u@T|srjRb$8cxQaIWY~oENJd9M)Ro(|GiW&uRVCr?mN12Wvlp^vkjEyzRdM0 zg3R;oq`V^1QwzAb-4Lm~UE;LAA|_^o?R_Tv&&y)}4}McyMgQ9RUx4$aw(%=uW8vk2 zLG!>AsV;$8~1cj`UepX1oHn^Q)khrJPjy>3V2F8}XYt}Zawn>#h>xC; z=W-?qy71+u5zT>wUA)Yp!a(E@>+hRszz)%14g3^XBi_vEtgEcRT6)WmA8&B1TDmJm zYVjlrK%=COp2n?dCN@@xF0~;#Hfn#WgS=Fj_qR+md?NhS<0yA~WJC>-gFAUjdYGZS z%&MGW$+d%%D+T?iBIzQ9^*mbGjhyzZ>1VbCPWulAS3}v{2Z3*bw-Xy08QhlgEQ@_J zF>HUjfMADFUC9Z%ZM>FHH$$p_1F`>8zpbq;x#y%i{|v6X4$DsJRsA6I3KGPl8OpE> zd0&{vniZN_|M?f+5>Vww{gEzJMATAJtX<0AE}``PJOHsipprSXBeqeMNPE5MF+J1g zFoY^DMd9!^Ub(FiX}f3bk?&ASe$)7G<105LAViQkUZFoOskL`xaH-;RnR~%@^=*ft zN?Uf4N(4f{_x$(Bn9}^i?r5^x_6?MXmPb#dM?@oVa*~V~os6672OA@do^k}?qpI8N?(YYzsAN2)YaLY-Wqh?;rMEOlXzmdJRK`E%042% zJVx|B$Lp`=k~X|$y9P!>*2T86vll684-13qNu(#@YertaM;2LUjU-!t+S?=-Epso9 z+UL}BQ(q4MLjwV8+kHx$Icx%6&e$>Pxr-uRh07o2DJj7DcKTe=$D_1YXv2n^Xt zcj&k>BCiLvtefCjOE>d z>TYg^b?y6O9GAjW^2I26bq<@`*=VEzcbi&(@FFh)aPQT%b#4uA$ucIg1jl=mNe{aN z6GDG-S0a{hH!!diVd{m@gpIJn}5O09R#v_4lj`;j1yKWrKT=k&0dMt~>ok~gBQ zYcmM3$*K1)d4U*}*yfC3;5z_f)VPy)oRvSDwH9@s5DTSxVQg+rVABGT)Y-+Mdq`MV zS$-;C6R@cWb>SSL@~4}3pXkv2)ZpoweS33ZLk_N=BQif@Ftxn8Mr^*Nh4cdwD}rC_ z1YAm3YbOvNpU5>~Ppujc?b;(q+zGhEv_x%gWsY>#M*C+ZyCNv(L*WdksGsg?<;;(8vYD_)!8Xs8QjTH~PuPQP# z9$itF|L_X~*RpJ{s%%eG$j5_%MNxq_HVwaLK$Yq56nVn9G@Ynd0d(QY7C#O9WSRz%B^yjP&Szo4*0*WEO z&-ioDN=|F*uh{Og-=+9L6b*GcE;~1JnYV951bW^!-68Gb#ou-y%#NXcJ+ZABr(EsOqE57Y=Oc$0n<{rpX;VA#>KvaOyrZqwUC{y)=zG7ku<}W3pCKP|r9$%DD27 zgVt6#RmbI^7G;}7mHrXiSKzb1tp%3o(tvmK`+tYy|H0tRpOcDmu#OoyeL#Xr(Lk zTvVsF6jgumWHg=q!{Ft=nAhkzz|O#RHnfF$M5jGXc^DZOC?``ZM}MOrMq2KL%AJZ+ zG<8OGPz$_>3*gn&IV zGBhqEAxz?s#4%nbV6g|2?g!?pNH_a4LGR5|{~w2WeTICEOED~VxfR>RnfJnn4->N= zS6Sv)=B>8Q&aHIm{)nTz)^tUqe}wTA<1RK9*X5Y#gm74EBkeb5k<8xtB+}ML`M1Ca z68t5ah_wB2Cx^8Q8p_+7OM3@m$`#AjCLgx)UUlox1>b-C&C=*UEWpqo3h?eS|08M= zGGzSg6i}FHlF#WVC6538F*fW{>C3J1%`F{d0Lrn8yu7^dmj~B>UEQ{C5v7ugzL3}h z)Sk@jEvEW|OstHd5Y_r%IT3}*d@Ez~O-cIgg3;ZO4T+*31~?IladQiV<61wk%fp zKB;=;pi8}P^Nhi}Qn1|;@n(%!WvIVD#6+=>h0Ao7O?dG2(tg#;y4F!=RQ){FOm>=P z>Wn2()Y}9rzRd#g1a-MN8Yg0VfFnN2>hkw9i&czsTu;tlYkHgHb?@Y>OF*>26<-C) zOOy|GKfu<+lXvytSa54^>qc%{BB7-MKEDsJ?{vo(rZ`nX@4a&tNJece5Q#@iSuyPo z5<+a=-U)A#f?^f-PxSPyjpgO%Fg;%<#~fhV*_SqvvZ}Ak?9E?v7U zvD(0rfmPlbonN0d=nIi``SIzg60EgbP6`zK-qGRfBdw7KeZF#u(aZ1(kX80(1D~#x zxL-WhC=Y^l_nn{d3BCgc5Ur>BF)%O<1@+n%3kXS|HQk;}vMa&KX z#c%2YtfmTMTA=7y?WuPPy!?{FqHGNwboDItg~=Z@ikhL>KNYfk*4kLlAk)b?f0tNX zEz;2Br;xZ?c`J1R`2|U1Lu**btgc@!4u*=Eh^m~~v{&d|LwW=f$-1Cai=Qs|cwT4y z@oLqedi@^`>oHE1E(OA?(^p#NuXvLrK1r35UYQ}pYkG7Q4)V?$0^1`_3i!D58GWIF zG~-pjd_1z6FH^w_Xn5-JE}Yo-#bOkxobhNwb&DBoRG;F^^2(<=nS+~&cuU$qB)8#U z8V`71j1|RLQ`hsM+_;OI6WMmnr|$yH`Y_O-TlwL-gX7>gM!&8aC+ZSJz{X{Llbg4q z5JjeJ@6m}Tr^QX&x}AqlzjRMcFhnc%4gGm_x5`ytpEs~1YNrUM(2Ot$)cLiCbPdA_ zfqrnfs6`?-794YjegZ%s5_DzJy7L3lO;Q0zb_mb!;hKC$>xFakW(q_^^#eS9d=9a4 z&s6iY<{<;gOr@RO?ZO!o8#@^`MQm{-AeI>L^u1_JJDEcRX_sF-xSj0GPfg(DCMAU*$NK(?-~_3pP@^ zPii7*5jK?n&?5Zu8&ziI{{NvxDm-29$0UJQ!=$57-x)2D?mnFV*Ht762p`B_LCs=M zTum!AK1{4c&3sLExSv!Yxg)*aK_r^KxgKPrW`kQ+Hx8q76apu%h! z78H6!+(oaD0iixzV!|BgDow^Er_h}ic|g$1s~sxZi1R!wWAEC#@yVjH@Spa9yN<1=f*XxKEUsYr6&#s%5W<9J?*2 zMA%cPJjQSICsfY16*(TQb>Vx&S>M|mtg5D@SlHN>KYBvPG%Tr%5qTwK@NF1yJ2l93 zpDn@c>P9RP6e|(KUq-bXpN#=bf+I=xtP;-5JxB`3XAyn=*_Tj}&b^HXq^quy?=w?V z^;#$kPiO;2;e@LDF!T1xW-xMUqn#v!&Y}B}&~5LA6ziOl6%XAj>&Z@kT$NmKp0__I z=7Yh_X%8)3y8Nh(Kk-77Jfl%_PhZJES0hO1y{DUXd8UhSO{PGcP{7IIra4<=PHeaQ zpMv>2^McgOs2e#2r@6Myk5Q;;u^(*tH0)kc2avojCy;qjDObisuER_~^Q>X(gwFS~43V{U=WiFWtMVq5 zl-k~&o@}I&()fxsgyyMFI8>T81f*PnB~EC3Ce>5N2AoBXur(e1=vKLLhKnT)SQTm(xjnK)(({7OST= zgp}D=RrBI@-M;0(x0SOQAp&!&k)<~uqgeRuHpnKiek}#5YJkig_M-7L;P4eAb2>{g zg0W<`XRAsvPfOr+g;k1-9@EETlN%+ox;MNIonby^Qt$qD1!(g*hjY3~EMdAer1)&{N$GYiQ)!OtI!}emHlD9dXy5Z{`s*UwwA9pm1ZMCU9LNl-eT$#<&1X?W z81Wb2ArGJG#3GQxRC+_R3&{i7)xtbp*)5`PBw?~9U7GmQmy}`9AyREJhQG8{JNzU` zA3L#Jt&+&W|1Fc(8JXydVMRV%3PKK6qMy#CUO7z|6F4AGr*MHGu=xu4y(c1%)l2=x zIyw(U@G$v8X5qc+G-u@OBAa_C#IXMAB*)l0bdkX92}bvw}+9!+>75()|mWI8Cr zPCwO1ztahPUUkuOZ@X@xDKR2Qcjr2-5Jh$(?CLam)+>{XK|{1XjsB^ErFg-0LXie?dg9+W8W~J zfe(Xgu09>CKKTEiK8cam9LGL+VY`DSD8`EuSl1YLo>@ z(b3U^x*zjNTHKw^kzkJ)3Y}fcaiTGv{NKC%-Ls1z0QT~)epx-4j!zc6=id+JL&%$;du-sr@wM(ZJLMt ztNj0e>@tfZd#zCvt0V$n+xw?bsqLrRR^8(**6%i#q_y0`Fkcf0<0&J_@rz)uFtW2A z#g#nLM|M7hqUw26|3D}NoPWXb@f-MhB*$1O?EfRtEq7mLBrWSG%Fz^)V=-L>IS)kY zguQk8y&Eai?=RcBm%(;Q_lS+}d1q3P#p1Q`S_J!W{L@0SYrn>^ySGP4*pm~nP6rHR z?tr}JReg11Z=rOdv#9+h(ZkQ*J814!Xv64>=s~k63-!qk3+7~5Nc)KchDvIv7oUl} zq)fT!Acfm}@ThDx>2nrUS&O^BJbuv5vGB&1dfd{<`|2D2;IGDm8IUB=NjghW@>*C+ zoj%a|#UCd1&rxd1zNIo5fi za>*v2sol*Wpm4fp$uN+=Dqmz8O~pnX# z7ZzE)Uf{%WL1r8NIu$ZR5Ua9oIFS8hshN#`67GHC*gxR^PI z>?3@v2-Es=CXox+{yPbFC*1dG{}i+VaM{| zodv>{hAZ#Nfo;8@_dnW+<`^>(E``*K%7`!c|HKs_|NlFr+xB$g+!d8j5nlBb_{J)j z{BS%?F~wy+AYx}H&54Wo{bb`(!W)Cl_na&#HJ(W?O-Vz7qkgVaoXmdl<>Mf!d4lPL zaUL}-%S0X;7xljIpPAHODS8We(Re{C8xiGK;~I5__`w*Ig~CI=V&fXG@rR$D=c3EA zjc(+c$DC8|+}g%|%W${MSOAlmN%eaejCXA&_;luXKr-wKp5f{gcfnM)pb+V_5n=Uttn!qRlIPC8X9KUpV;E-}nDv;_`nJ z3OU){(HgtE-ID+R2E>hnFdDAtX{)H*(bLyg1V|iuDS42Na>%aT&l8gUh*~F?&@}$E zQ#Kh2ibQzgA(JbFVKz56H6?U#FcN6PNA2()X)k1FXP296X?YNp&k|R986jRaTh!7$ z9CoN^6EbCB9` zF7<)Ftq%wwDI|Lies&^zJ{osHmy@kapxKD-6w}d!{NPrN1$)i(;*IYu|I1#JY^}i~ zahO*zoGNb}!0=!|gii69=I`#ryO0a!HPc!7m0sCSg#G1Rm*}!gT{ESmRu;tlQADA- zwF&=Mkveke)ZSQu_stTn#ao2tnKQWRh+h4^y2jwkVaEX##20Pu)&! zU1A|$7~$weB~kI(6zeteCUVcU(Qp`ZxbLr}eM>?@a6hdtx5BRPi(mWqH*y$8+5&tm zrb5dzx^{pTu$%)VwWgqhlU;n1j63M_EeFOTmhR{uA_~>orQgdBYV!B6y8XanKoDj7 zd4~87ns?5o10ybfPo74F&TWTxxp#0rcQZ31FU;R>FNK&9Pj)6V`^)U!A5Xs{DPAMz zh$a#Brkdc=f>wu_`*N!XU`tv4TJ!b(yDN;4GzQByRVUAd!0`|?H%pC z%H$D@ii>sez2J`&5kvRNtM@Kbrtc4Php$fpQn~g3t|L9S1b574FrXyJSTRvtJhSu< z!;n!mH~1Z?AAuC}9<~ZPGG&9`fLq}5YtLiCTgyK!-G z%2l?eAa=$LvZnoi;(mRk=GBP;v{Y0cpcP4gdNXhQc5oTY9vznc&=xB|mc^@$zY|HAB*{gDKEO?1or1ppeYhKysP`sQe5)NiH^*qsdtJ~rp3|L+m*f)tT1{dpHoo9 z|8jkUvnDqePGHu|*lEDQ%4OL1Z1uU9(Fpk?&WYc=mpEN_PBX46$3{Fl!zM5rA7J_cncngO)_ zke8RY+4l6nbOJ2o?7_joQ4q`gUN&AZ2YC`(VZPRs0bKRTKH-ry*iv|H>MhK5mJ%0f77nkRcY&S_|vATh(OdoDpFp`-AG`kM~mikOr^lR6QklY!qE&yhGb0dK0yIm|^8f@~{CuK$n3NGKj;dM)d zR_F>!Mjx7RC{Y*{oyW(7>v?pNO7sjniLmdj5n6rf@YPiA!OLume-PvgV#;wn8oa1 zgElBvt9u|YFnN8vN;MiXxgv6?=d-B-r6yjs$)^ZpwUOPvomBF^*JJE8OyrNN zY#?q%aI@neI|OIPyHFk0$cBf8{PE2emV+-MC@8_( z*VkI@`vsd~7sGOTF-w^9$Rp9g!vVFO^x}B1##!+_;{~tbC`cUL-K?&+mFFynoD%!u zUQXB%I&3|qSVoesfk?NIH*kPl<`c@{w6Gz&%3}Iz#J!zTK>GxnYynF2ol`K(O1G=_ zgzpT4ViUd#EKE$+Qx%>p=a8GEh(3(UWolf+pcO7+_!eM%Qy7(|(-@UzQ>f#r!=C39 zOHIe*OKp`CCIcxp=cDP?q&Aj>;{wf8e~p&+$FOlPV@coI<_>#Y&p8y?wf_CkJYWc_ zoTvq76X#o`%y-sVH#@59*cF{DSN0KnH!FT^ec164sexGVtb4i8Fh|E4j})d1RahG+ zE#loP1Y9+Vqo4%hKFOq?bVmJK$ z*c!Bqc2Au6ZD;WMbnxyEan9Wqzot6@--MnmE`bm5l)RzLgpG|5L%;zR6H}0uY**Q9 zPOK0`sY_V{^rgrH2*NG1(YSY{+pS7z1C8LT?$L6xRbBwSy~ye#=NpI4yX$Hk_vWxn z+w8l+S&?kB_PZB>eXO8;!V=1EXJ?l+k3QcSerrKey(BEah*YUHqJ~x3L9R%ITz#Du z_7U$M9B`gKoT~D+O?JDjJpa$PdCy^T-6aX|{6xaXujHfI3<4HbSB;fQ_vTni^Pu&A zCq?dLO1^Jq_&~^E=@KhOQ?QlE#6BqF4z%_nxhWz+4jP`$T4tg3m)(Qqj*aoy7&Fi3p)&va|uX)7Xl+{spGxe0`aNuu5e*Y)FGB{&ZHr@nVCx{`sRyJ39v2s6*e6`9kJ`absTU6T9^AwS_sSVwMV{?e5FY2nLH*xQ*yI%qL>BT=rjv(V4C4>iXCLPSGcWbpb`AMbyiXdOEU3?9L14jZE`5tq+H&OcPU!@oo!~ zuSzWqN(Vbi;cm$&zUj1?!8Tto>?)4|KlJS??>Bvgl(xF|dOoVanrgn++C!s7E2p_s zxb-Xe09a2g69OM6S$1@bP>=@u1%u9`gyUTIEr3 z@yUL_7Tvt_rEAY$pue;cBYNQwv#<60tLVIoYOsX)@RQsGEqFplo4xLO<#TKO^U7+y221E!3p0RVsZ5B)wN||MV!TtKo}XhN8uW_R$UZ%ARe}C&hjrDAkE& z1|$@0AR|j=Ftb5a`6ithjZ|m?2)+uEk{?HWD9tiiXSJZXK_-@tI3OzhDIJf&0gMcz z<=^@A!PCB!24E<5K0$Cr1|oPo;^Qd-JyxdF!grP^dnm59fhXh>d?vpd+G5nSyRwzg z?8e|hu-JSjb3WU{XktAqQ6T1i(ccdw{eywf{ia>{x=2HbNO}VU7cQ$&JL#13P^iTS z&5{bu7bM{)) zRK*1|xNRGfwC`$Gkx~18Y`Fk-ywJnz`o`*TUfQoOME$U?olFYrksLOdpvMzniqmDS#v{bh=b8zaH8JLpZ4P&$Gfc=KD$0czkU$XVOnyX%~^r3st4Nnmd|hv*LXYEAy|Kaa0BCP(0m{OgrA zBzUEUX7~e7$0YDz4y6SHyCEnGDgqNklao8Ie3(Q^rzdzgz9NmB-2#VXq+YH^(-?5- zhxE5M5B_`8&c3pK5PV^7gZ+zUyo{g!Ko1T5q_Q_8&O2R4Vew+sF|Xq3u~Sv=M{S)Y zM`;Fcf`bGW=7qGO!OrnRYtkh@5qgPccD4j-Y`a%}JBhn~!4!zx{CCOJ^>03OjRIS^ zj1;bMXvJ?nJ(@!GeFxh-x*Zr$7uZA{;KHS6rZ7Njd&+yTgeQ{dsbkRT?Fcb9#Q-sc zqr}>t*(i^|!wS1fyK$Vv;m0Nv?%_V5got z(9OS#O{%b`Ao7^;)BH-0`*4K1a)#2+-VZw;dS5&?@WpvL=W}abk|(PrDa0;}Rk^{( z`)S^PSb(?=i&%-#CWrG^E4b7nP(H|-cSCjI;aqG!85gnWbxZHY=n{N6GD)Q{XEgA} zWICyg2$15*N}kQ$6qh{>$S6KWPGx49-oVDdOvsRq^%oYO_594=WzVcUR@8QCCPz;E zrUysxWQp2nARRy6X))IBJ&aVy4yWO#^-w>2=7-%m9n*FwFDz!FeeKYev%&+V)Y0v) z^)-}{Zc<}crE?jC2Tq;Qu>K*dZXT_T4$Gh{r7|H&vRpYOzjGQF?nX^bjjI+dni4NM z`)sy}j0VlEfwvlIZJVthzh#}vV&#q#QX5`TQV~M@=}Ex5>jw?*Nr!04IP?C5Ee++6 z+RY>qAc0d4hU>C^Q`t}M=+RD0OqAf=LY0kGxe%e3DfvB&efo}@)2x1pIy0V)p|dbb6(DaX2zn*~})Pg)73rYN98c{`%@(|gt(GSH_&i_UMR1kMy_^LC`+_meyonIT)K(yQZ{+oKy!XxR2;Z2NtSM3Cu{zb_PE0ccyqgM-CY!l;F>M;NPo8Zrae^ZG6 zJNF{||IWQA{BQ2X-=7ZB`xiBbw}pIXkAb*HzDf}4NVNl77+BISF+CU<)k~ihBlixdFP8EF^C%DCi-TcyfG+s^<{i3L}rGIOOAw zp?y`7S6=aSf3>-dY;>jG9hIJj=GSy(euP~MvVR83?bak}cM6zSqQ-CEzAXxE{+v|< z8w6#)*x0zc>Xk*9^DqsE*>=X-f^&^>KSPXlUuu_*P@_)~8NdmnYb23|Q7%Qtw7~ zL1pKF;Vin%_=}5hQstb_c%9T40z4QY_M{As!6e^QMvZd$Q>ZYD|LqDnHJhRTV`rqK z6oPG0xwh~Dn}VwI8oqoOnJq+)6aCWxiab&)Odf)ybk~bdw`q^~bB8mOBuH7`eH<}| z2Vds?`q=PtH>(*kbY%!`DfYzz&?n!6l%#PDgam2p8W_Gl?uNxj4b4smYrPO~Ukq_+ zQ5!sP`@{W{2dNUmW5tGFOYJtbkd3tl?ogbev9)()eo02CwQbw%``8(+r0f7>`KXG<42U^)_=lxslPq_494J;*7k-VI0$MA=8Lo$Ydc~5^_%SiR_As z3PXShijU>0AkJnKqT@QcZr@89gh(2S;me)TP&H^auxj?1aE zC@#gE&J&s*Lm18#INHn**xbK-7XWS4?g-j_YjFsm+E2eO?0 zJpu_QQ)wD)x_AH-BFA_8UN9TNWn6p`)4`y6(C5w%Z5SutaG~Cd9LPYx8T6*MG5WJ* zQv7zU`PVN)cv6je-MB4140q&lWB>_y%sx|<a?@7bGydLMV)Z!>T4s+bq&@S{pyztpEeVXC$Nvde6HEqklWa$;%9k| zGn7kjqQt<@PX4;)#c4rj%8Q8djmKlS0s9$r42S8i@#qbN!BZhjSFo`PxR{k{uU?Xn z`k7q5rMq|P!pY&b%7^)wTq>=UIi)VbO*l^Q$@la22Ai+xZ=2=PC%#bhEyuRMZ_mCU z(70e>&^oqsG;VtNnmhgBpz$UCOT#{I{^~dO;96@THZwD0hy75FQ0N=N-8+!^@{x*j z#A7uiUQC8hHZ=-MHQpYmYn1uqNG`znk_4vBE&g%PcVi&sLH~|jLu9w&4TcQN1%A@r zMKRLASQa<#ykRl@HlC_^OO@Es#$f09cHf557*FbX-5e(6-GEnUkQ+74i8+6F7>`d_ zBG6qYW6mJ?BKGBGpDU}6p2@W^r!^PkpAT6B{IX~3tHq`lZ>0P_eeXG8POWfdrjM&3 zjjHAhXW(#*ym>=Li&RvsjX!E(;&Y%~0!jBlYC_QKf%M(QH8IQOBNZ!aYuY-GU#*?v zvDLeTQ!Y?n7<@?ZxIPEG*F!kk2r!x`@4_qxr5hrbwOKU^s&@CYD+ASQgfH zb0rt`S&B~-L^b}~WoAn!$!?Lx!m_y#IKtFU-K`wge%CeB7FA7X)|@9}Tot0ap1NV0 zQsi`KmZ_2xwIh%@X&`JN_H*_=?$tna6J9|S@1+sf-3tL!)4ja;AdkzW2TCXlPI)s(aQYQI)7yIrQ;dq;PXX~=kW%%&CqbBuqZ&*; ze(xOV|CGBqZ1ukG6^J^4!ap6K#pzd-SX$&Rf7$B39w`? z@+vBeWu0}~&w$G*bQWomXJzd(c~A$Z6aP3eLJQ;=o`2i8!A$8VrEg0< z!n15=&);vTDy|D|**@u2i~WPITD3iQ@>g0QLAy=-w)5stp0*rv_65&Nde|~C4hayu zk`}FGrJ28cK53Rjnn(17+}4#jBsOm0O6C`RUWAIiMVn<&3j(-=<1_dhlD9Oh(Y{ z;_NjX($c-Zwx!SRp!zr66zXM59cj0Pd+u*Yiry*|I=kEp zOoBHR5&>k9iDzjM0Rj$W8CXsZ%~I%4YAzNwZ5CLGLrg?@&C=nYS{L=z{xn>S&}SsS zFQ+_lv#&*Hz?SOHK{_&rh+v#JJE%VmpZdtqXkWkJF&d!Dfz?Tm+2$HE$(j(#8w%%U zOVMZ%h)#cvC*{dS5iJ(hTja*4W-6&3zZuYU-IxEbek(5}-t*<1)jgd65|0*cR@S|I zwe!K z-A_aA9F9}+!+%sxy=wehJs@s5N&oKM1}m|AS^ldmR7*)gHj+G89zlbXH|?9VH7Y`C zsL_TS#>T4d=-1p<_#9RzFQvqIV3QOnsLJ4vzu(|6tHE#{qp)34VvZH&6dFW~>p)gl zuS15nQy;ZW;Idwe-|#WeO*r!afqP;-LCIUReG zj42)wvIotQi2l{M6pTMU{bP84Sn_5hC)m`xy25GmrrR8dcFay&12%mq=QtlUd1A>| z?O)-oG`x1sb0}bR*vo8~`*eS@b#9$h2x`ghSl7~#Qu<%esHIY}f7xb~C z>^Zbz+I*sNh`Tbb=wEvM*-n?HDxa^>BVT2XABUX(C|}ItMsf_ga7*T<&-`ZrkDy^t zXX+<##)w>=8D9*?h0K^KTu|x#Ug}9`yc96${ihj4_M;jB#N^~x?aX3Q_HS2NV0#&y zbUc*X^yRyJtnL1NU?x@#_vN*}oh$NJ?weEj`1;m2wT>^1W@uK=Kf?RjaPPmtk!bgs zsoD+o*{0p{zCWV9WW7gmY(p|$?L3qCKX*o`c@vWt;xlr1+dd}Ow`Jm(Cx`OrEV1>! zW=YEHYS6tG2G6Q5C=UG6t-jolBQd8m8SX~R&mH(aMs80Dig-U#NCQg(qDz4Q9ILj2 zAcyKy{)+{1s%k_C>mkqgXW5yL{~lbHp!o2SOT5}{F6TuR-e6??aND;>n6 zcE=WQQ-A)B4ppP-B2PdyrllLNa_h7w-!2tj>wJWmT|;S;{mw+50tY|q-k4MC4Gkap zRR7big`xSc2#fItG!9JUt<|d*`u*4U}AlsuN zXQuwF^@bf?+K`LJ9!IXR%UHqO<X=NkR&=ZM7=gxD01o9v zM6Mzq0?6H0D*4*>)99cHM2IfKYTj>9KuhEu6Ts^-bUG@q*~ zz!>)Q>Zm;_NwnXd8Gu~AgVHN7T727$YOgP}@!vL__kp#vH1_`5$k%v<{5QfYu=}8* zs>*;)8#$j}#IS-I^)u`Q7J{x#|8NabS&%U{FkM$Jw|L8WL?+-kfDoXe@kXXODqTR= z5&cl^piN?*JghRGwusv1?WP)o&EG@}p$oousBU@0Imvqx+C0Cc)bu}9Ie6bhQxkqu zfJvaZIwGBR+isOrEM{D8`KI{z>G*TS`8b zb5o(y!0Bsr72mzrmyB{^Z5cM3)5|h)i1W{7&2bbhO~UrH?mCJ4bmb!Bz6|kGQOIN2 zac@Z&1qCHi$^b590+{bZr^~q+Dd5Bj>WLFFXs8EvlmA{~f%wYT)%KPodK9dhu#Ofw zB{2xg{|;5+P6>>aksvyF72F8Z(6R3HK<{b~qCl)DD>BT3-yhbE^B~~K$K*MfrOFqR z2S=qm62<|u#Wa}E)kGl{!obs8_SGw5C*G9+ZnG{|1bcsI=U;f-x}nQqcA28wujfi@(ODGtOnV?*?zxC6Sb0#d8!fDTQA? z_g=l_sJkPE;{8JDSMz1fHD)Y2&*2}oN>*q0FflV)yRpa4*~Z_9`jU!ia*LF_-d}XW zipZ53?mm+d)-&E>kJ#%g75fo;owsI}Hh0ze*fU+E##TB18BNwahC@diH+`J!`dP4< z=7q2GE>Y@C7kR zbLnn)*{*CZ%jb3$YQ*3Fx_utVG%k|8Y$jekwD45mR&xzFL#7|K%q(W2pt%RV| zOQ*gExTCHsH4i?2s{CoGgh_CdCF^vhd++=B(}09g(+L_V2U2%NyNq# zajkK>zltQ&S2GR+mbx< zyUVsTsH)8|ei(mw<_lfYuhWlw5Oc;hT4aDr;pFJJlWwmw<3dd>-fX!>heVkb?4_A| z{5tPez&Dt=_)|QM1Ie*- zQJxb(p%hS{Mx}%0@|=}76`N?g~5x@Z`Qjg<9yM;u{jjJ@wl9lM`m$nD?lKl+;kZ(2`kLV=`S5lis81rbv~DPV;w;wV;J9dpz-(g2%j%w&aHb&RL>Y$|%}C7cIZumO{+O zuRnG>CwfX{C&zr@vRBft3^kWZI&NLRCr_IP-(ws$9r)4ICISGy7{SUU{aH&_*f0s{fBM(4!j|l4J1yb-LrCcTM_#Wm_la32x)SGI{Fuz!Ihey(Bv(M4;9MNt;z z&V5x9B}{9r*&c00F>1(5O=Lm>&eQv^cyE7B=HT@om1TCT?OZ82H?Cc%*t#qJBBkeK zoqp!*?f+r#t)sGBzjjeTLP0=Oxz_Djp4U% z%Mw_n8yFNZmsG1a!nYn7IKF6MC>rLLV^tjp3w^5gJD3xN?qxs*zRMA&{FI_fzlYKH zyE3iqEeSj6@5<m%tz*mJhzkij4(|=^)oDjiL@L| zqMZ+5^6^>2(?<^{sE-a4MQgeEBPla$fMe#51MT^K5_fSw0npXB~@Ch zB480Y6G%Yk+=l97r$6u;7M}SJxcu_DoL6(NE#|!CRlL76o}x({6FwYn%va2U6!A5o z&VtWr1)}lF`Qy-NT4N0}9xPbtQ>-1iTo)R*lk!i%#VF8aF#42v=MGFujU zzaqHFC?6p?YKe+5Kz1ktI9$l7Soq!+4>fAPGTOa=)gi2xq#9L%Li)S6vClB)HT`vR zuRv$lD>Wj&^3Z0UmS(v2_|bjRI0}6&D5(;^>ROMRF_u!BgNyo zL*tXH`l0X_ zpC!NtXtu1Gb5YtJwWIRudmWCLP2^f`lKU~F{|oReG0;EsHv&pl~2CUjCTHn zz&FvfzeLCFF3mowk2s{lg$rR^`Jiy%{)HNXCOY-lOvhj}u6!Bni}ubrKZxhz4VkHROXBgqv^&WQU?e{iUqx*kkazW8C} zqWMmT{{s~Y{kVzXlQ-3doL+2F&z9wXClxojRuP22kj$7@=C_0hthJP!t;!u@uT|q*NZ+(%hsVD~~>tR_(_h#hp5JFkNbDfA=F(!8aJI{`|K4W>U;D}6-Id~hxd5T}k2Cw*C6IGG+^YPRDb@9D`D#ebh~gvj`$M;P&?+il=siUD z9hkupiBFzekw2Wwah@f?Uah>alBND|$0hGBdSu>ryP1>%zpaaV-{`zPXw9#AXOYTx zD6W5GeTw-e$}7l9N3gk_OCQ5KC&RJN+u{9Bx2Elpxf!p%@l@x#%0+MbI17t>m{}u& zQneG+U8H0l8_nQt`xuH5Tp{pU^RBvxoz>8AMS+-PN>ax`Z}SV+C24Mv*o)FwU$`h3 zCN^(Aeq7mZc=3XW>p{9&JYL#0#kaoH6E*HWJFZhe1K%iPP_MZ$?#gC~rnl@(rA4qr zcz5sO+W8#$J_)-ldjl7G#O)S4y^U9G(6--;H%f{opJt~~z2s)!^sxVR-p)_nsYl~j z(K(HVKF(L1Z!3$$pJGQQwzu??5wh1_U}x8RB<7*RTcU7B=IQn&F;|iA<7JlQLffr$ zNG3urXSbj@1WG@f@x|*BO_JFBQc=OXJXAnk-9UZ;7cz|dp`C7dL$?Sno(0l+6t!M3 z1aKjJ=&UYMm=9@)G9TDz8yU^?FkZoM%7$P56(z33AreguK>~6xg`7*H3NHtYcxaU> z(w+A3rfR(jaOT?!pMSrqSz!0e+QL5$gqZe) zn9n67B&dq+b&3jO<=p?xryoM+Bf&SS&B53pxVzjrnR@#0oKlFt+KXA6{}^k~YstWo zfJb-m>w76>D+@c7w-A}C^nR%2<2336ZNWDGAWi0zEp~~|g_@|B@dBe?_Bu2PhzO$Q zk|`wVJf&K~oN^o5%qCGEM8RhCA<`$jjLuSG|0KZH&2fY5UXXe|%QMfs>sSk%`DV{n zVxS~lJdAQlO`Dkc@-gZlJ-V!rDbb$19Rr&V?b3~j3s_6J`!^%=e{`4~Y52GSz;Udn z^7e*uw%N5RM^}6-26XwL#)D_vZys&W5ps@vyuQ6zn=>5vQswp=zaMf36T5Bw;r478 z7uGIe3s19-)j9gFJUvin^D$mKHx%SvJr;8n*2-Y55I)@M&4o@eAH63PAZck z)L#F~RuVWU(;`JZnF&c4IFH{nh1&oagGL$@X z<3Cyu(-{!4yHngR>F*?oe8r_s=|}aGx^L0@ib5JK9KQs;WewTd-nSOo5Cw@^e z(P&r;i1QXlluy|EN}EF-ILkb&Z)18}k&107jUVxxtdVs|>Oy6s?(^SaSA)=Sc~0i@TzD&QkQhBSrGNdJ8@kWSzLxda8; zVJ#)BJ;`jM3(m5trrmA57N^l5Ti&Ncnx;Nwe3D%HIYU<(W>*5KY5$-e?D~U<>{@kfWUeWUoe|YPUHY@C$|$qxXc5Jdqw*dmws+= z-eIdi<90Q?sp5KS$X{!4=i5Y$MV0XJh-vn?E6SkTmiRJ1S9T9Tmi zdY@6wI_b93b|d)6dHlIq&of>X>#cj*fYu1{*z2dT6e-Y^{C?{m zw7W6JyE5B%4PatBRzH)b5fZa|_upiD5{b$*KkR#L9+Th`HJSxTp9ff6%x1e#LjuYi zw`Q5eYm=pPOP~s|(1kLV8%zHDmp3*4%3jM`O~=I#oS!=bnEl&tSaKUfKP}ia-o(4T z-x=z#Y%y`cPiSsJ8#`-{snX3=L2pa`u4*Bw+x11H6BQKnXl>_ti5;CS^`ckJGM)Ik z;@eYfq6OpZRLe{Hau4DKn&L0B8U5Q1MCA6pf`&-u2pY-<-?z#+<;4JhC;CD0IQ3)7 zjm+uWJkB}0OSsu5hBy5lKJQTfJmq|Y`L}@geN#%2r&vTu8U@6uZypwYwOu!Y2;5tK zcv5Pv(sBHeW&;%)xY18rgE@p(Yrvq!8%)Ywb`B1@uMQV!#@A?W@X_mfdwX9eBWv@r z6XUNeyiASB$GkC2D+hE?QyApOL`jaAb%~^F{%jc0+~Ff+)%B|SMAF_*WkE{)ZhAw$ z?$oa{e*uR{s=UBICghoY&kMT)X&$z;>VOP!)2FsDcJ807c3rK%&E081=2smM?Q-7( zZP7|Gwv!7+3TKl&W!&yAqeM~CLi3XSzmp!`skd&fZ;4J(%6fo`e=HYyGFkrX)TL!z z2OJ2F0nq*4NarEm1|Fb`dF2zyV-Hl@W8a^*EHoe661i|A-{#QB0vKj3sqe|#H=l*| zZ^sXABaZ8U<;UF!Eg6gzomg_YG^`h*a$-Dz_947P$m8^GnJ1$;y7HaIlZ8j00Q8@o+ADbR06L_%Cl_ja z8r!^bR3|4=(@w5tZoC^Eu#vtwc*5axUx!fKNon27ck>|TwSw|S&ZA>jB_{1Rvg|JM zC2?=82R#%OU*EERopQTAB-xCPZt>_2&r&qq>xic|PEHx1{Ar73P3Uw(fYc1Rg!CaD zw@$MKs53m3OH34C8hIVj*eNP1N)HvDewXRYtBjRw-&PF92ShDhC}K#&FL($7Kzvhrob^nE9Y z;{l%&#OUn6crDJamVa=#+?pD3jsaQ}?WroC5>1O1HQa9JqAE58h9l&6j9xG|G5fv)d6KBlg7LC ziQ4uSUo8Av)YQ~U6A0F?qUtu6`z}O^yf~T_*tH&VarB{5Cy;8B1e%=f4O%7e)j1Ma zGdcR%UTx-)Rc7qAQ7z;mFN@@CPzkwyh-=&lUReF^%kiU~9<1g^$}G*RCRIs??@~vW zy>V@+-UjyH<~gBzzhp3(;j!+o+--u`(Eph~cSRmW=&tY$p9GyRzIiA$#(|kM1cbMe zZ;-TpwLrabSM&kNJyyvFNe^}}5~%nN$v%IcN<=^qZdCnZ2GbhafPNO;x?HeI))4?2 zS=6TrsyeyR!o%|t^Na(NOY90Gc{v%SXZ7wWaXw=A^XQhs?#wxi2#GMB&XMP8CUDQ8dW6cXR}n@nC8mY2Zz%nYWerYcl6Ko+T!1X3)1UD9FH^rtu0Iltpd_i=$# zUJ?}>Yf*Og;)W{YNiRJ!|Ghh%rRPEQ=_*IFMP*wixls|ZxqiBGJ!T;_$ugr?Evpu` zJ0DEcX?i^|KOI!*-wIxQ&@YtYS(!CR>F{vW=B9uDhn*qIysw|zM@GciP2*$w%OIG= z1oS1J-6oNIbFlZ~?mU9F*c2|_kbQm@}fl1L`TJSg8}VtIS*yCGbx6nt#WfuuJ8 zg?1WTe6Kek+MHAuZYd#G;}M{{9Xb6(q6;ReNk~kjjA3k}DI)Unp3}P48r%XSZwMJn z!0E#j#)5W_<{@3f1aj90e&lPn|0ABu$hbXOFzH*i4^Xfvm=O;lO3H4KTlPXf|18QW z78tV>3+8Z_YSy_QA*?|DhysLQckPxm+xtT35RA50Ay&+*-eyWH$_38)2M%O#Zv@`Iqe?LmwcL@ilA3+U0qqaUJ-Uuz8 zz=_ED7VDwDCfj>OyM3&9N`{^3)G8s zRaC3zB0OCOB5FVOj8a(gR)!?vMl;{$EUxnrfz>SvphSK91*8RMrJLfGLvjg)Q#7z^ z_wOAF5R4=RD;@D)#voY0`}#Cl-C{bqM@Xh|j7%X5kA<8QP@%w-7 z(lf;wU!al`rI9f);_v>oRjo>A)pSFw zeq8s6z*V- zy5RyoQzhX3^)ZB2c=?I2|GWA1b*s{v#F}BnuN0;^Wqk^k26vB&)co&mn5woK!mHyWSzR#_JFiJpG(!@5(^dl?*pKO7T> zU--FN%A8yz(U=*YNS^^3+;d*bnYw@s9u=#x*F0|9G`E`8R*W;>MY=&HqVa3g^fbx{ zgV3jAhHQ4nHs$G7R$G1jf0TEplg~hTS29)Mz;ig(3`+kjSOf;(%;gMCk(r~jEQ3E# zjn`5i{hQ!m89V$C$gzLM@84dy-iG}v-*spHY>CO{$gg@0W}YU1%u`%Z!djTp$+^1& zGfa(mNMT8R5&6)KQ#R>k4%3(!;?TbU<^_wx#tGaflw+TQfnxX;;Gpza)(W&7l<2V} z@c>0wg0g^;whM_NN{-p7L>q?OdBt%f$iH4&roTkPk_&PM;mlg!Uu6@cKfFnJ$Z*806e3nv(BNC=C=B z&w?6nGg$15HvJ`@eIV2w&okXu=f-=Fi+;qbOoDLr3kcd9P-)p~rx9y}|4EA%o-zLX z;ZX%grQ|2ii-{>gp?>#yWStMSa7JYwwhva;yMiwSADO$r7H(%7if5gUMs(S?-7c-S zwJ2(V_~8UWJG!+s9lj_}9dB-oS5v1t1Ld*=h|~T4d`n(ud(lN5$66lk_x$*3B-mKp zVC}GYP~o^n1~HgDkg~F7L$x;7P9*;zNMr&wj7=?fdr)$keJi97D#WrC6X7t36LTp# zSm)1)RY4Bc{z!2O$J3{;8#UI3!|nUtPNBh)F9AA3woY%!-y9tEW8q~V378f-N86*N zMq3x%J`%XBmQo9q`K0+|S0%qqL$2x7?@7oKm{M*|q9)G@oJbiSX5-wNnIP+1`j1Hp zgE*%TO2|BUi3}Dd)UhK!)-k37?6T-hJmS78UHS zGgkDe}&Z0fJ=e4hQ?PIDqNk^)Aa=H|u|`{puZY)0 zhVp;uo{V-z9(={J1Y?#kkT;6I8I>7TjGgULuI9gQwIqH{NPG$sW0{1c5)Q6~Urd&!l-BSJnMzzvmx_fsQe9p}70LK}rXZK2#oSQr2DE zp&%fLgjTWCOD(Hw9?)vf_$KiTpC)R6L4WL-J1T!Wj!Gn(D$|N8r)9$JSG+BDHj4lC?wuNj%o5u z&XIm32sOV8Zdv04!-@z4`wA^S>CjsI>*esjip`nEQm2}!NJ(RE2|Z|%d~gW5gLr{6 zoA7Uo#dKeSN#e|BFLBWESEc3fMtB21{l$^!)FH4;`B>#eI23i|SqSnxK#;e)m=da4 z3zF}N0n>o#lzOjh2W*}ts1vqZwZRs`S@Y6$rUyGik#vK$vT{^|H91j9LU5Yf(8CDl zFovJ$**dy8-lQb_pXsQK6zV>^d;71gxdi+Fzg716Y@x36l<8n2q%Y<}qh#Ngvg#5+ zc;+ED`_A)1k}6V9q5@j#f)Yh^c{KZquc$CBk9lcta39?EvgRS%dU9Di^l@6)S1PXg zZ>IxQuIoBw#BmA?bBiC0FzrL8@ZKuiw~uGv_U$5!3bIEhx!j}iiTA|~4^BFIHm(oZluN{Kx^)ycYgtnU1%;6FghZ=}gNA(&%B;e^sYeqIi4~)^X`b^j#yJgD5qv)f7Tlna}A|X0t5z43llC2lp`SLYVkn4 zONUXQZUgR(-J$lEtFedkQfpV)V*v$4HH>S~A=WW3K}g{~&qUG`#FS#b4DU;+);whQuCS z=5^3SjK&7_bvakxPyMVwO$p8P?8-bG87cSczoGLu2(3f5i1ZiEm0#O&gA2a$+yCK*Az@oJpw_Xv4IHc>3r1 z(syj;HEBsZ^ZxpD$KO8}W2a$C^{q432i;FY-cOjuoTrYlhn`kMB=Fh!FAV<4(hlPU zJ|xB>4Y@BBX)wNeK8}KBZ*N0Y4+1$>W?Z;OLb*9U5R4}gip4I*8x6r;J7XG&MRm?P zD5Cvr-_ZMAdYajW$Q1ew`BRYtFslV*Q^KI3&(=W)i7oCTT*^UgkH0(yY@ZVSwU`Z) zbU}{+KgkS#rff0LU;p5m6=)MIBr^@%{*oMvdx-Hj{86XJD(zM=6f)&$?};Qf zT3(L71>{CzZ(1TubrOmz-;hQ>xbTo_THv*UNxR-Vks8W>5KXOX?1Bu^m0xsc_;-(Y zaqK^{uT6u5=2rK`)4^b(tC1ua}xJ&kE)Su-W-&Bdna$6SPjKru$4~Vxi#^?w-@Evu6++2cF=zfZYEM+FvrAd zM??z9^;R1GC@_X%>w@bQ?wK*8GxdAJ&)+k%T0~9WGuV9ebW(!W&SIh_Mk<=6(+fj1 z5^AdL>+&9-guUw8OA2Ap?1xobmM`Wn_9xSpH9NRJ^QaDnZDgP-c%P%AzM}Ue<0*W+ zpgikX_Y(?ZFagM+8hWsQe2jNN$H&AXt2*$%r--y6Y?^6!9S9dEdIKK?gnJ`M8K|C~Kb!)H2}zG%UtNZ5 zxK9FKCOuD>k$ghJALqXh>c>ctVCSQFm5dN;$zT zbV`T^2UeJjVS5x34Y;)jnn1JOWQ=Z09VCbFKJWyL=yjIGDBb2_;}G?g%E$+)I+XPz zNE)x0V7ogG>EzjpY1oXr;FeP2apI_Y6R19qkh%=oudNN5>boJSz6U^()hRLVrn0IA z-WfkR7P61bRmp!BxJ)R|4)cXe9WxQoISdJIG) z*CWnko$DSD@MsEU?ATVbNlZBKqkF-+WcUO{tJ*OboReB8b)2ah zF<^ODV5#(vhSP@nd7rl;?i_Jj>YN=11VHNWhhbsgS(e0Vqv_JXye3`1LXo9JhlD`Z z?)E+5fi0aDBRepdZlpWQ<}ILXYX6rDu%hkwN1&q@bXOr69pMH7 z*ki?yA4=#R%9i$Ej%skjKOE?UFG!_=u!g=O%8IWZU&5Q)bQ!X+r-{0TPg8b4LMP;od77nGZ zVdl`{6w|OXWrZFXq6jdl_^@p*)H`SC-;!#4k6NAw@s_2=H_jO z5`&2=E^#osbJwU%?zkZ^*FXcDdQ$8^TFI>o#rU125v);O6H_ z*o>1c_GqT47wQWphq_b&V}v)fj&z0vlv{U2=~WtHjp>eSOlAr>=kXWzs#>M_jLVVr z$&L3NYtWDRn-q7b%|Wl|)O4f5b`HB-fuXy?|K}xs*UQ(R&OTbg1bYXk3JJ3hSx&9Np;k9tJE6hoU+WQ^gnMw>}~9Tk0~c=cwrnRcdL z!Jm6-S=mXgg)?bW6O+7Ts9AX@4Q&KRtCLm$E#Sr#>b_oss^>uCL?q76x6ru0c2dpp zy6&312@2vO|1MdBCe6d3TPN7!C?X<;{=5V;$ns(dpah!nb>Y3PL;a#X;_arC`4gUf z?ZhK!`ieAo7(urVCulLd0WLP3sBvk7W5a`v$SeOjHr@me9>_{wMz;@~`cV%~AM2}2 zU-qcmi)l?L_+P5OK(z~WP@4{f>+G%?%u!UM2x@Rz7Qbks(W5G?_iB`DP4vZd`=EtZ zU@XZLJ3||)LEuw_cBScZcSCD-IR5>^m%(TZ)C^1V_pUVPVl{Mno~-dyId9!y6%QzS z)#8(q;_p(*qFkufHGC%&dJ7xc%MY9-w6rK#nJ*o9&PdVTwd~NTqrmXqzhaKMl*p8n zYv{zE?U3}&i77g8kd5YoYKExZW2OWXbVhzb@G@L2^?m7^iC;o%E^_G}%ZX`IxD$+U zwoPp2_EX-ml3cf;if3XQN&KzHu>P|!Mf@Ps9XS@^D#*b76-9ZrU)X(l{VRb4qSSu5 z;%ypNUgiF_YRx9fe_fvYXF0hjW<^tsXR!*m1+lX@&i-BvR>vi{AGvWeN#i)%gG8!O?(Gcv){jYPO_ify<@nnp#)&uc|ey zLOaMmH0Q^|#&*rz2E8~9`#(3G_g~`;FsF$6TRQ(!vR>@^>zwhY9tHUnss3M!jz1+O zZ94>k|9uE~g2vx>|MPL7Fy#1a+Tjug)#MGcGE=0Q>~w>Iti!x*rhw+>bflV25_pjB zJbU|RZ58|1ZX^tb0rCm=Ju*Sx7a{EBnXO)U1DAyDFDUF7b*_~y98%IL#U0&Kt|6Os{>KCCJ@LIG5B{@${E=Vj)SmQ+wH=h zsRSVY5@ZPf3O__N8DR4ld&}IanRJc`dHJq?7BzI*Xm+Cx5-ISi0Q;=B0BJV)`y%xp z$c*OX`Tq{%`t5@Nvec->hhBClU*^Be9y{}AMqsY1P9#0dpi{%U2Zofj%wJ$Z_zB1o zX;`)8Xj!`PZ$K&Wz6*MuIv>NDk_om5vDOrgD8*2**hc*K(z2sD7RHJn^D|OYd%)KG z*}GcA;9|Bk+G)^DxotSUBP61@*7#dflRRR4@B^^`ff3;MaqX&LMX) z^vcgq0WwC^w~)fka=6HE!g&VwF1S%FQO#b9Cqx26L6qO~)VlVYi&m{`sqKWyhQb|V zVE#uHw-w(%-4u_{hZ>oJAF{s7x!c|U*mmFL82saQV@nV?h4QdZqaRyPP|$gIj0ld9 zQCbP4(70 zm~8PPzl{?-ek4scTtKb&wZ%I^R0^PX;sS?_{Y(=W!zYN>fsx`97(187+(>}w?teaN zt?_uRTeQ#(wqVKVBbXpd8(94DY*zXA*62A5qqN!pRNZ2h1gZ|k9R3eCNPK_O?9`~`gnDVSB7+q7}VDf$mA~3fq7TT z>T|diYO=8;<`G(HfEWHZ{uCvZ-AHkY?{2OKnNfB|&mCtqHuq7YF!9`SQjpujhZ!1r zl?Jrb2eTdqO+l(*SOE}!oCImqaI{eqE@ImU-El+mwYXxvn zbx^;Oeeis#jvd&d_Z*HV?CIRDBy$#WMv=CvE<{mx_qMk=a7r#2;7p7Dx!)zO{Mn>L zTMga8&vSJ>G~AQnS0Jg)*|CFTK+7_b#1&@+HL)Z?O_uUY^r}urq2Tv*bSBU501br% zK%dFKe(8lcygW%d)!)?)B}l}+J`EUnAB;cw?s*p!ux4CHQzL8zDdgm)|I2skbD~%| zvY9?2pD6M#-vAoF?V(ou}@2eC?nke@Lc1?k5oIOYr&7P7*2{C zQ4J>&T!-#8oGq4oo!aHyKUZ$Px3l1%uf%fU%_eB?7Q-c}l7Ijk81FJqO2+a0 zB006a4IbV8MK%&*Vo9(}HBfUgVEYQy{>SL#SGJds(P#cCEEC-0I{=XFD8AkN{Bm0x zvgG!8TQl2*RUcQ_E#PHbFdXiE=?7D^=_RzYlY#Oe=g^AszvcX}0AIP8oY)8nohb;d8 zR~}M-p8J2h6VAGhFj-9;dteoD^lZTcY|bKLV>>?8*Ly{^Mh$My4!U;VIYLsI z5Gd!KYFFmquvrP6NR>o zm8hv*7Re07*JxC`33uc3aQ;hi3Y8Ep-#xapz z^l?mGx9dj1yimq0&SVgXbLT(U+ZvGGzig{HQLGt1Frduu^}9xC7G_YKtpY-bO*U=! z)Nc{i-Opf?Jy_0Tx>Se0Ts?b_o8sEzJwVYz)xPzNUk@&r+2FjEk~yIkzPp#MUUfZA ze>>4cU@!enT!eD+_ovf~-wRSQM_MnsdbY_N&po;^M+BjLcfbb<8av~ic%UWpf`#i+ z>5xwGNF{(o)P&5M5wLzfM2fYDi0l0ht6tDo3u^u;W1g0?AoIlZ6&r02BwiVqNvW{G z_JNaJr`0!@fu^(8pZ2Y4*c1mCOL(JUWI~O=1w-!uLs==QWWY$3z(Kq-iMck!5ETf~&R*~fYL|DS&JFa7B6ouXf- zPW^;r^Nm`uQti5aY?liagxVA$#T8v^aWw$aX7Pu2-J1yX*mE|$;g{|kySs(~p=1`sZ6 zG(^H3N@6J+R3}v0!4+6F{0Pt@qd9^j$xlecAAoaPLWg;rqs3`2#H>2Aj+JJE zci%VU0MAGn_6N->`>bJWM5rNE5gkoT9STFCovTz-R>h|(Z5s5Hk@t*Pb+ZjKx8wQ2 zOJsis@V4#iDSUSGHl$zKERHBtTvq3_)4=of9}7l?eTD<@=I?`yxTXafH46)-1tpdE zzsl)o)-OCqaQtdu741se2K#4}X7d_f9;>lm91r5i&xv_c+|mESH~M61o9P*hZabb! zVpIc}HN=!P_Fne**KsSXbq@|4Q^1N__Ln8o!TGtDv z6K-o~uJ@@{3Y$G9hdjm)G*9a84>@?Ob9Nne+jWN<++JIo!?M)-H0?N;@O$IITF5Ow z%g&IL{pRTlKZ;~Wa2p>K0kSxRa)b~O-0NWrUcadhrasHA8(^!i4=r)x3OC}BF5qq5 z4=w^ht|$&ZnG)4bS3f}KG;}T1|GoY4z;_vgoaReY;lMbFt~ESB8$@V4)SUw!K37{?O8PwcOxNPP|Z@dk$62(T|uc<9dz} z$9jhky;~GdVrq31DeaID$IwB>l}ppkA%=Wlu~k}XHO_CJtMlR;Axp)y0&5MK)AGY; zSYj)iKan|3anV0H;TG~-;Y*o26ly-Th)&=S+7}wE!^sxx4WSNZ>AgraSY1wm@!;!) z$AtrOC6))(^~sdoo^>0ZZ7Jb2&>h&P2y%^x;vRP2Kk2~G)xQ&p))s(wHrXm(o4>I1 zdjAvY+6UrbM-!0Lo1nk)!JazD@ySl82_e8odHG5u&wjZE(+}t7a06XdfTYp$1?tnjd)eMD3s30X z*`!Th#@O~RdgGtt%6HrT9he$g4Lwck#n!BI>c3>BDWtFz=>p|bzTomWE^&;y5^hMH z$B8D`poy<2&WL)Ecde}LaGCS~MzmM0?P>Zb-&LP*QC^%r&tIj)693JlM-{kd9DH2)0c0Ll(kgV_wmP{c}|{1 zNDi0`EusYflGo!+F<`6RZn_aTDShVg9EJ1LOEhfneKdhH3d+q~F^uVG3O0RLVA={pmu%T6@RowtC25M_?=uwPp1w1$oR_ zyiq!GVX@MX4vXx$J&bPMM&GLqRK*jXbP}H13Ngn}gE(-D;dfI-J{8-tSL$Qh=m}Uj zx*z@fZD%zWq68b?5K0%RjGl7}iPw#L4w%KiKP)ETK2^KzN#-9%O17Uj@t}Zs5E*O; z0NmukDjA#%mBlCw7ROJ=H$T0rIASio{}lQ=o&vsD9^#)Vt8*X4k}5Zrj|OaX@&w-5 z3cTvqGT*cRmi#FEMn3UCio?UGmXxC}WY`ca;LhWu^}Wq!OjmSW33prvg6c-&cMtQ7 zg-@;AO8A3r1#)0H2(}ba2$$e-2bSJno9yQck9qan8=4h?AE%#b4 zD6uPoWgu{V196?wxJhb_9X$PB9Ejiw+KgZ{Xm8CRl&Kkp0JG)=f*~d zzI^}Cp{EN@31%O5L58H=J$?aS@Yl9Rrgs|On9fYAwUDtgRXuXUrEEPln!}XWP|>|x zjV3Kh-CfAkfxGqTra!oAeM<4N9JgXo21Hd-!NQ_)V?qOM4jAC z%06pqh|DU_vTqUsT^p_P%O!f-fD&*EC_Ox#{_vDWc&q&0o^NV zwgP+48H+u7T8b0q4%60p2yr9EOS^skh{K&#kX-ZZd|N2*0m=tM(SzMhoe7HmxVYMlIv9Nn@aICaHE783r`-H%qgHqv@{^bhtWE&S#C0q zwH^HM=R6K4&(Cy;Q8tyRLh~;tZ}_k@WK9ZsNS3(Q@69Zz6Xt;k*Sfqe6noXI9z#Qz z?Ma*e9Q{L=*obE3uv9LqiIWMtjTd!+Ek|@4N{WiA8QcNbC}AE0*9a!wdoK=*rmwuq z;4TnkRhWMf*Oa4xg7Q*VN;Y>k7n{g1ZFY7RQ|fh+Xq@R-PLk`5>8bNt5J!-hMgJHJX`_emRs3;;`4}PJdyhI-?HkRXYQa#nu737icTtN``8O{cyZ*X z2^aY#+eDbgtNURSjtLb>LQAw3V;ujzY!3{EC)UA)r)LN3^6p%7UAzbOH|;XEZS38i z-FzoE$^*wT_*5iLv?t#J`B7b#p#8vShR>L069q-&9+2UJv~3zNOzYIZ<~T>EwyGNI zIq-~-^4*D1vm|rw9>oryalP?eU8Zh*9Z1kw*H=W>kMWwfYkxU+k!~A1!AES(xh4HU z4+sWcA2+zI#(olkykyQ0tq7;ZePWdOwruQYfNsBe2wgO)zfDiCabp^uNwNjc3y0O; z7&?Sv7_p4PHDz2r;hIBv-<@~Rn5#swQTRy&!_@tg z+7NE9bPpJ+BSAz56JWTgcs35nj7AIbZuyjuz5LnXAyS)rfSK(+T!NEXIvf2Np%BKw zR+0^2#cGncSN5jc?&33OIA4tZZlw$T3CS|z8`dKw-`6W<@NzVY(-}i0KG%S=c2-nU z(cRX8d~E@qv2sB8!6{g6NNHghLMDJO;;}9iSqhE~j?JVo0y6Dw$6Ibeoluupx@m6A zH5uIJS$7gXU4LsbO1S1EzL!B|QtJc}&ec=?KY|6>2VB;5fOFFeVu?6z)7HfMRj^~>ghByWt3B~X5qd~PtVGQHP zA8`4)L6@#AZV%qCKgukhI-yO)@V-bE@OE7-Yymly!#zmAB*T%%z99k-4gsd#gQ~!IhS2dM0RDga5$l+Eovr99<#|DGXC-R|JwDn~wNz*X8{-}< zzq&gHrkZ1uR$w{O$PeQDmyF#22uv?a-i}a8m%dbc4$k})MT-xX;mR%sQu5e(-HFvu zpt0^@#+fqkNuSoaS0XTVKJ67x#q9S^%scR%of_UljZ}TyS9L?~f|#kB(H>sRcCcswglSFElH>9t!DBI1;)ZH67mj<#tFw7(Fv0}!lQEwC+kl2_|*5u zSn5x0PvF?E0pelbj&qKOsS+US5q)M6J;`Bg|L4G0rCO-#C`PnAHbs|s*H_`P;nrQ- za&&`gA+$#B@na22(zpvK&kMk`yTWUt2nLHzeMu7^cFDv5u(A<)j4ym@vsem4*vfj4 zqRYAG?YjNT1yJ(Xhwz0mXdkQIlkSPULQFQLTK&Hb(s}(S! zwTRYs)cBadXTKOGuyl-gP{PU0*saS9&%gFmuHN%(?E5efeZ;y4O@>~}|2~aL(1;m5 zcqpOVy*#W`>uP^BYluVYxg)&C2y^+EJ`9ksnI-3b{N9AP@&@i^;2`M{t(74 zey(GsYtV4p3t5Km9)Ta~@OsrszH))F;TgI^&|HbZ{ql_in*E0oqjLvNbnZfC%DQfA zrFCsY=0Ybz_k1$N>rV!{t4;@=F}2hrY`B%2<)1$_vvDg4#tw6yY)A}gRIhJe@?b@J zC5{umW__Xf_wV16e;8)x6o_xGCCk2jgDT;&o5i#4Mr)$|x`UX_lOU>$+n*&k>0s66 z3-MQ8Zkj>5J`gih>9p7YTgFl75V%-8#X~~rEfo7mYFOx2v}*@^nW!3 zeT988@obeKBrB8w#>_mP=u3TkV#9x@&ql5ru=HF=^U4^f=~z3tK{oh|H*W*@1&_FQ zD*%*Gg zo|J!!@6_E`zarG) zjxNm(hwBDdfkbo=Vdh>UUgX zWGz5QGwnLMH+F5eipxTE<&v(X(SuvV$LrM_TDFX#ROae={zsU!JnL_17?i5~-5DkQ zWR+DW@9HuEnmB8#{%S)>=F*`1jQVr-dmdaSMLsV%ZavSg})|9o82+?BRW zyy9bl%rU(1Nzq_I>|@@J>^1D;>T7GxT`Pa~wHNk2d%zU0ygdJrHOnRHI3S;CWa{-i zmB+3^^puX>JqMrUYlM3dfL7eu<)a_$d;HQr+D!d<_>G6M8VU2(=J{qI=~{ckm~S)O z*oaZdLZ7Fkx*3#zJAcmnVrDeH*p7eg?3;K5n4tgGC#cBX!`-dScU4fzha2XiJVn#q zSX4H{We&D!tD8V(tUHSQbnx(sy-)V6F&R;=$3C2_rx1xC-7YWpaURFK!W#B~araFy z@EZaD?f%mmo*K2$3<(+Mug!@olA<*9DknfmAG0~Ifu(Z7Ho~4YFAJS-=|gwt$vD37 z2{gdCz2;^WX7z&wx|85BIoq~6cXBB?{)~!TbRcQcW7$~SbGc`OeFIiW<9b#A>F70U ze;=>FaMRFgBvAsVsYu(iXS=dQtVmWAuqM1S-dGO{$vu! zW<#7)y_-sgF=|CTaUYi6J^L8Fgxh@zjj}dJ1;`0T<38WXiimfsm-*r69HIMCKqXJc@rzt^DGR|Xpt)?Xj6!am)!gGW%pF3R$}%%?3U9gi4eFK=rf zjc?_h=VWEIc9YL+?3bjRw?qn28vMS}AfEW6vcw>`VtN2#taZ|*7txR4{Y z=XPT218n z5=vbTSNAI_cs-gI$QPgGMOIHJ9bd(Ru`~bEc6j7v2OYd~Yj~HyM$IY<8oVExNXY+g zjSs*5&*5tI1N%TjpnoKd0ynY$Y!!wlxj@=jzpsFfl6e4KCH?Q0K<&+xpzyffN;MDD$ddHpnis|y7o zC7DmuRKldD$sdmr@IaMf!1I5?^pok%ojdb$f7=0*Ek|HI05S6^O*?jW$K6l~SuGH5 zt-Dm(&Y>|-LLbx;2(Np!13dp%fkeCykeDWHfLe5?>7T5Mu&A7A(&s>)-U1qSl8+(m zV8_ZOoMB1^+UycAp;w3EW9*{0jjQ$7SC{gc%$jnxCZN>j84&|Frdf&GCeQubJQ{I% zUj5($gZ3}&6nTPG1y9TKBUyDPF!3lBeefr?m80WfAUO)y zdr$zxUA-b7Q)WGB1uTnFDBpUZBA%JZ(W)$BhKUE5sc*!~#c+J{{7-$!YFDz&@Fj8V z3J5ogpi&!)>}HfZcN+e#O@)R^9b@@C2R==I4bP@qe`T=J8avZQJnDph1!fS;mqgQKm%ZsUq_{EQQQd zhGm`=8jvz%wo>LX^Au9Z5Mq%z88gqz5WfA?)pcL@{XF0E{hs%G{n78Li)F3zJdfkp z_HEy`ZFkdytiX=wuP3sa+Z&k%H^Xv4g-U_!At$e{C(^GugMX!%#13M3za3;0j81<5R+;2YOOVJY3u*Z2c_m=v ziv>ltS6(9e2XN*@Pho{L#AQ&Z?+kk{66n9g7U`W`eZmHKV|xOugdzmCxGE$a_&~v- z`_1l!b3}c)-C-1JA%GTH3a_9nE`1m>*!jqrRi`LZwi>RXbmXD4zmmV-%;Xsp|ACof zG?jV$!t9cWWC=*Adf*ZzSdHyNF_Qn@+g9d>hQ=66mKxct5IzYmf9de9OyZf_kIz)r zd1?!-mVJE7cTgWRM2?WWp*l}?=4$mAkD@^x^lr=E1VjEmD1+~O$H8K~2FG*XJENz- zv2d$Ic0W@eXJjEV0zrorj2A!*=1!%`;X1c@!!5aAOi7lWzh?REgtHb3b;|)E2X0+O zKgxxAV5=ix=RRms9u`{me1)Mt7Q36>RpJTl7E^f|=juVdpc6tbqzmNc>U4l`$7?AP zHqaHRWohGuKvkPxt}G2YbMXXt9-fxFCrfMULjCUuom&Lva(hSxfgZXCik;zzq|p7L z8ufv73Q>Nq6aA*LK>Do?Q;lhO&BJ8VUHYtwuWz4BNaepQI-u=#kj0Cljs<`S3gWO8t7k${fUEv+r2__m627n|&MRkBMLHHTT#dkGX5pb0@N+<;B7z`^^+xZe#|`8i5^1&fK$`62JgBWzeAXfFTz_h zci7<;jdJWgaH7cZ21<;SjX@|Htf0l)RpsTycT}ttWPQVr9QG3du~P^uRR<`7{Upy) zExkZBwqFZ*SS|OEMPv^YOLY8pALZN*GlVO`mR^#xW#hFK1A}@xVf5c@vL9T+tH_1X z9f!n*IT-QKxlIC1kDzH+1n)&^h$pdUS1dJ_WCNZ#e;hg9M!BN3l@djLR6m~sud^A{ zGEe&ipl@sj$ABFZJr5R+;cA3D^FDwK^FnqUw+d)2Mt0Yzzu zyW*7xQiQfI(G7xj8CYp&_@rF-!8oXAIa-HtedO(hQ^M9L2kUN3X+1t)N9{9#0LzG zid-b#bz!dN zfRV9ROQS|elY->Z3>bj)gA@AITT#zD##-k9>v;F$KnYv)VE98x&IFh(;JhiKRf(_2 zcKG=DI1_;B=%I7!*N5eR;D>JqsdVpw#AuvV zq62sb-twQxF*C9DU-Cfo)(cgI1qchD{p9xigFw*oU}*saLI7#PH*7a%0^Iv zdLkJW#-{NF)VR>=HXJtLZ!f}hknK!Y?KD}x-!NuVOAjLjZi%!{L+Jg&2a>d|l%L16 zQzBF#63GpkgdLZ=0jgkJJ`lu=V?EY-tP_kQhDIiUkS&HPbik@ASZ!8hUUOAPL~Pbc z1ZvhhB>*7$hcfPfiL03V%Po(o*%U!BJU8n8#Xb1=3Y;b#yoyMuhfbAe^s7BV=6lYw2lAyigyY8AIUg9&d2BAA_KvxTEgevThcNENJAIeiO7s`slApOPGcsN|FGQf9 zcQw2N3 zP{NUvx-O8xS_12=5p9GZE|SX#*6ae0i0ZsqDS{({3c$T*;#&TpJ4y62D6wMZh9Eu@ z6gN7sQDgiHpmB!l#@|3(P67T~8J^&H8*ZHa3HzNzu0osuo^b8HBmZz;HoZsCLK{Jn z=n=ejnSYrtF>qIf@GO+m+h^Srnnox_Q+|+vzJvX%l$NOHr$aCU9_Pt$8nxgs-PHo; z*;ufe$ZAsi>Bau};jL*%LbrCKpjG0pWEDp$4p76CkyChvNT5m;?Ck9JbKgkzQTUH0 zjIsW_Tf&j5)=68}eHJ|2$O&*D>FfkZxC;S0Ye#Vz^&``A0XM%_O@K{CJAK0Zj#;2{ zX2OvVaK7mpUQJ5$8=4isAYf4^x$%^ID^m*HU#;Kf=6`` z@_t1yVhJVsnyK_W^n|3BTfpLG(#@1X`~t1f7$P67n9XSQHuY_BlSY{Em0xjEw)rtgRN_6UOaR7aO+et-M&<&W8FFnQE-v z$`M38pG0^hQQ!6xmZTHeDk;Gh3#Nw&&@EBe(7m6NHvXLLF$OM#Zv@m&G8$bS0~|S= zDY%U&+{cGgblv^i4?tC!zuYP^^v-X{s`VD|ECX#G+i}Uv4~N3ebGEYAP_o3xLhyO9Yt~nInfacoLEX%Er@kbmcZ$sV zs^>l;K$?|uDqMk$&qq(=3z3}PPe~Wrut23PS?t@7p}irV2TS3@TnT!msCQV- zP05p#Iqg@|-JKu7c;~P~C_ALvfjWN%vri>RX7$M+wAne*;y4~t-`2PW>Y?{rx=>GJ zl6RnJ=T2^AA&+-8!tz)Dg{Au+EdCpomi=*2CM?B_Vv2N)ua6hp(Vt5P)%C{4}>btYHP+Pu{fx_Nl_IiY=ui=^hA6(Jvlwj4=w(ED~Y@ zR@OGMwUe0_`}ih4#7^)HZ;KR>_cQ6l17}l5ZaRz|qLUcROE)YAILQf(@$ESiFS8zy zQdXAFB%f3_-%^|V{TZ|2ly4v}Ntep)Y%?2rUs)dg@uxd63Uz=jDg$al@BR-8&*Mbh z$6YiOZ_PV?W{$k?ME?6H0LY47$kZ;pR7>zHI|o1VI2fT#L@#t^>F||5h5r)Y>lkvJ zcKKP;>9NcE)5EM}0N7XzAc$`cYAuw@Ap{TDd-4@UvVnO8y^ zqc36Q^CF!{@GiH4sOPk+7tjcRnq$YapsY=c;I$jQh4kDguITl1R4j-i03BG=gjV>x znV}+2yWnHI6N4Z_E1;!&Fvnm5*ux;Y8(qi=0pnKlfRX=(9K8F;HFRwsA%{~D&=@K^ zb70Ob1i_Mjd=&_E*CMNaeeyp>{iA3W4hm=m_QINK(!E+cZvxk*OaCMMmLtjFZ3N*# zPLCYl!2O9>`(QPgLeJX<7?pbS58lTq?MKkQ5x;rc5S)hUFo6v@-m&w817EH)&8ql; z-=&O|2CO_Ss!_WetzNyb&s!k(n#Kn(IGM&A)D~p~lG7~rz(ym7g$H&S?CdUG(6VT= zgBsHacJEv^sKw<5hBBIuntxoP2B3xAD-67ekDa*}BDD5u8?*}7z`Ftyte?q6aWSv# zKs#;*Cdn270pt+52&Y~j=xYbU0{*>VASL`R7{Gjj54x`CG6wjf5LQX~JxN!jmka3m z9kTgP1duT}`jE}rAdO*|hf|5*ZG=$R;)cq}f)l?FcKbK&$H5d&669S1RnGuRVEJ4b z+e*|QuHx4g?32B~SXn{775K?52)%G--h?71D& z!wjV)f0+CInJ_dD<4|E>;SW2;hau)50xSCD*9bg~NcniQMJ6m?IFy9pL7C8{{<;J_ zUkOSa$OwrX7nR#f6RnWP-VdOUBXBsVZea_#_=S6$`96Sypi2D7`@JKH{Gi@`q1f!} z$u)m7ZK?b&{e@G{(nJNUM{ko89*d~4!M z+$;cX+tRae*BHuX-YjwS+t$;~9X++_hAHF9`tBz!;oQ{t3>x?4W)$Gd%ZEx@IXXme zDG&zx^5G{UI56gSJyyfnbhN_+Rfrz#5_WeIWGS_v#TO&&Q3ibuk>tG%=4BXZlkAhF z>#RZK)VIz0D+rYngCp+bAI#kiGr^F6@b9?Y|ci5Z$Ouzm0#DeM{s zx8>!n?-|!O&p4wA=Be?4&hA_@d5?t1s%|@z`4F_7Yxyece;&zfZ&roU_v8s82Y8lP z{u}Ok^aWDWKo9a7vQwkJ(;&|TeEq*lN#OteCo9k2_=jPJpFJZfceb`%3ZLoB+U_(@YEqMgSe#avX z9~=Qzl>roAg|K=q;w`|HXFFL4e=k)!2m^(Sq2by~%GZAnc<(tR0&i&gWF;RMqL3L; zG3S(Eubl2J%+N6b3a_rTs#~D-#=KxT?9Q{m9N-9BD?bh4csUks2vIgEmIS!Fv!xU9 zB;l2?WK+WUQkR^Kzr_BG$cNoKbqh|s*eAcMLCCf!limco;|y$z#jxXj+x^wLTSDvr zs~VHEjcoXE@@PV6#SC=zi{Zfk3bq3in4+AnBMqg#74R?FoQT0GhRf&)22{OsmCA2r zHuNHICPWEf)3;Z1ys%6x9F;4|8L*ik9P!*XKyEGI3^AprQ5*0ZW*BzY14wwB5$XoQ z=MhE%Iso{u{cAq^50x1`gA?nE$gVpwvzjFN^d;2>>x#uX(WVjpk!RdMwSOLf+wET@ zVhP(T>b9hq>dbE@DBf$wr5iQhnfB66FV8@#J88F$#^#|f5mNX<6F?idN6Z6#Sf6Wf z+-U(b)V~GG%87~SIGw=hqt_-~jJufsCWf<>-##HRxC|!hmG{2%<|P(y#F5V7?CYl- z0!AKxxT4YlbWoyqEle5F6_rwr0Gjz5+#Z#K^uCuu3Lvlpd8gx7_fCL!paT`mJzjwoaKe~@NTKXhX3W(j8asdb+vPWbw{vnGUg|-LfR2U)rwn;@eqs|p% zDVBhhZ7~{v4!CtAq^Vfe^FUC<7bKoU&T~+bpT_qA&|b7^uqqZ9e!muw#D#xJS26Ma z3o$1d{oTO7Z3Q9=>nD%wu7kBb*n6kjzo;!;h$DApFmX)B;m;R&XJ4^ab=ViUF*apAXUw_9X#zKmdA8=DTyv zL8_APHK5FfL38)Sue0A(5xoDKu~yZ;b$WzMUU<~r`?4}L*SIn2Q3y`vcQTC zeH5pmzpQ$iWQ2?&G~^6wfAg2{-us!^u~D+Tu~;?YG0eiro5^q9lvEr?nCbFpKS8Jr zIH-J=gi~!9tbsXQgV!w?GadCtFTuNtd?7uRP5}y(D4lvlg#aifn1s<81|aB5JvnXr zm>$VRu@(Y=cEm$kECBL%HKBo}dqPyBPw{nApLIF=!qP*9k-$vA6W{MDU%PyTwQ=MP z&pxLAW)lvMB{_;}(3=`qbM1ls$u4707x?zRaeYULhQzAm$B~!%A?!0Q>vA8GMh7j1 zNrzVcr49LCZlmz+{~_D^Z}!^2Bv|FWz_GlvnuhYWasXg(M1%CRGy34`SX=G+gONS- z1nN|8Qkwy^0meMQ8mzzauMB6P+6J^|gTNJ7T+QH+Foz9xZJV&z@5mVa=FOWK4)I`7 zpg&QJ`~U3VdSQWmdVei^QXVol9HqWsI8|me%}q6ZGQe<0IKL zILeW*sl%8?A zk*OPkLLpesKIkg%d%*h9=@*gAT*n~~LMZ`}68@&a0%sjs=ZiAlS9I3{rG>CZIuoF= zG(&*E{Ok+h2kXEEBEW_zD~Q8(X>~!<4@5dvWk#Kjm#QB?KjhI=o_Z>OrhRN%&u&S`@KyUTI?mxFC1|va{)2d08>7sU& zA%7imR28u$c|_6U#eiK$ik)kaq6<{=TGpCJ=ITg2oPzz=roVL)R=RX-NO@_uyU4r? z5UhqOZ3KjiVOr0tr_To{)dO8s-C83EGM@9`)gAQQvf2W<*9=0#yUjv6IK=&|^+0Op zR6I-=NAzx%;O}#41?#iHzo@y$pD<)G7mA)<*!uKW>$2r-yrRa4q7j(565KqX2b+Bi z7{@OUwqE0hp?N%@xC2U#`fwG}84x0W_7y(99Z^4@hm58yhkbt*saXJdw9lTxE3_E} zkDXBw6|a0BMID5jPg`<;BDgV2rT(oGt-r$VqK-(rsnF()ZA#N}hhxa;buix`jR9r$ zw@UfH?{~Slydpu3N+O{6Vnp>%<$VIHAHbPki|dA6_CC1#-@#eMv(gK7lo4EfY)~V_ z61GA2C1266$9}TyY&WAi(*d&KO0=00X#Nnd)3jE|qamv*P^4M`T;cFetTW&V z?L$y(v79;2cx%EMIJM7Bd4ILCJRqiW<3rk=;frt37jhT_{Uh9r zx+V_?eK?GCEL2;=SrjUNy+epTK_T}uW$0Yb9AaC6Xdbf{#%(pNj%l1}o42n_o_L=H zH>$wmIdoJ7T0q>_KBZ_>4ivnKhcxMyGhG06_{9iArloag;QGY)pST~DzkEhEWN7oT zbcfsN1Lc5CGQl5iO&v9mf|8fAyA255g7!x8aVC4xj=jEhE6odrogV0Ld_D>#y$%ed zuqfrB6=I6+-Ua@yJl=*^QcZnKFc7B34m(5+w@8IpR+~ss?yhu!RI2lNkh;ig=@mFy zXQZE+PF6|_N|LC*VNMcQS(1{JCvAJ+HdY>3Z~IEg#en~sJZK|LCO9eQH`|3ZQ13vE zkbN8?!)AR?KsO*;$N2Kr>FPA3R7Kg5jwvZ}l_5+6Zeo~^xs)Vja2c_u5oeBnu#gcu zpX2j(cx&-Z##K|z5)SfI>1X~q7BmJ*Y0Au0l1CIqQ}ipNpT<**S(YcvDxZ^;!?(Fn zw+9{_qdWt|pM`09=oub5CQ!25shs}cvcd7gmX;W6$xH@^(V>9Y0ehp6) z%(4S9O^lNW1l9Jzc(3HfQIo!KU1gsm2M_WHC4%*c(Fd7Xn*|(1LAaCtLa?}A-%yfW9Jnm_#>>;LkMsTOiwAQ9Ck4B zM=a2IleT*O=k@+4IWL)|aqyRdKPpmGt#UcE!i=`p<|z7M$XuZMxMm1vkhtQ~orBMQ z@X|3w%*#vnWsBBA#W$Y)1ZOD6PvamsdGxH9Wq7-zPnYdHX z(&l;kiXZCZ!tl2%K@zSo0h+Y_sb;5qy()3mGqgAs27zX8sjM zI}h5MCocSmeUVfFqo?R{WNm)P1~{=V4y9lF)*#daKc?9>b^$M{a_**3$c#QAAJdqU z2yMNoL|G?2#~!2Xdj6zP%IJ_BHWE{j9jLD6cn_XEy{_n|33(4QYo)Fq3>TZZTTArU zq9?rCrKoyfUmk9xU$H&2l84K9n@YdRKWmRya5mKZ%z#hq^89LxgW(KM9{S4Uh4F)^ zE@;b?NeiCq&Bgp#G&_zhX>z^e9nU9mjce^J0f_|G=TyH1a@A_=s8Q9jeZJ^)^U;0Y z47&Jpyn9Nq`@YFk_Z`YdU&vlgx&4uA;s98WkH{4!9w%aS-1BHOJZw1dF!VJhi}A*J zgQ)!IWERt-!O7l}m#ConbRu8Wiq5EnD60 zUYNxx%7hq?D^_GKvIe9P<8mJGIe zEWfn@gbW=vfnW1BXZ;2)&k^t1G09rot>^Ekauw2Lo zD5aMOaoqt;kU2zXDx`OF!07BY3#iKNgzEpn9-Kg@3xdi^vZq zb(;j479?)Yz=K`@oI#@#T_e4iT%-B~|qO_$9Ig4gG}(coWxlI%=v4JhbmpK*V+mfv%dL>6B$ z#0jK=)NN~rjP`olL52#CjPJ_-K#$iE01fyNTSXz}6&dRE5Bw;x@DKdh|9PvxtfPNR zzy%q1Hw$@3xmDYu!@#5v|n6geJ1&QiRpi#mm>oRcXvuGSb zCLo%Va4TpCXM3vJcup(UTws?VEd_YlD!lv4z8qNS*?#-hptNSsshfR?b#H?g;b7d; z*=cA1U|qrlcy0{Lw|9Z!-Uwoz=5DM2bT1oG+>*pnI7ESF%9anS52Y?B($JCiXW#7z z9=&CHUfNjN5S)!J0!Z7mD{Z8@Z+^JL>Q|Qnk9Nq8e3f4>$F}g_$dZxic291_8)8&?Mi`*Q*9W2|3`#8Mlj*tXT$r?y zU9pEdcaE7>)$6;iCdFH<#!e0U5!({SD%h1Z2DXp&0MXtJ#3YF%dP?G;za0=6zO0=5Xh=4QUZU{DeRm{e8 z!*S+;SP(QauHvW7E2NHNG!&(4D=V?AC5cr*EAw8vec+xNb=9f$J1!Q^PYDd5%&P9^ z6`qsTIn80EAk8x)ru)^2L%)n(d#TiR_V8ZwJEw~RVZ8R;H=>i)i5wcax>VkmD7K%E z^lSsyba~Re>ALEngSR1V&8mkvV%|*pbie9}J4xZtVVjH~=eY5{slnqG2T(h9L0|;Y zzkkhjWl}L|ASv`fXo8yG9TjTC`<*q`{@l0)-*v*eg`g2qh(--woz*O2V~U=*5?WhT z=s9*`!8CgtoLV!kN%-rX0;<`!rYcpe5A(Y79Js;VNB;(5D-ybfOp=>_Vpyvc=+e(- z3~%l{pkJu#nM5Y(b&XU8Bw&!KXG+U0Ks?h^vQoK%+HKhR^6koY!&p(l0IXw7PT6G}F$ zaWN3=@JjE>K}ub!KRI0a5j~Z2c7#4lrxaDuv}Sgzw2Q81vaQWyj6{*l?EY1zRIWqp zNgn9?QQMuklS1YkRfDouZtE9tZQ&k1MUQ6*9vzM}&@HlziUUmm<(tfG2_@@*2Lcy( zc^MnHG}2W+A7tef9|eB&=s~NndC?7K=*Mp^%6628y;?5nv$jvt4_jAdauyILfnkFvyUI( z?JbPeTnb%h4KZyfvSSwnq-VX+h)#$ayD!<7q}PI&8<8-LuC$pgP=_0(SNnD4+z&8! zH+{7Vm4*$-wbLTrZY)QSWJ$&WmcmWGCeG#>IizMBvZHRaX!pVNOJ$@pH)T5*cDUF| z-|QobHpcxV0=DjUAw3WsX2O>Of-4bFjE_M($y`U%pIES`HC7e5lr%nJ;gv>|mcd{u z5OQ+=rS98pzP%{rs z_gA2TDnt}|T8_U#F!=~CU9HWwWZxQa)lkiEj|Y=@c!lSS)yM8rk7ulEYKUprD`O~l zGqF*8m`%BhU(K5>INtHS!CV|SUr6)u`h$Tl&Npr)o|<|Py_0xAUppA>muGAnq8{I% zL?)A&Vn&&gANx#jxtZIVrLYSZ--kGEFhWfIu!W9ie@&dlyUA!efrB*8j$e*fz=2F z?t3Ia=Jsm{A3AL-Z*gBj+GtHh55^8irmZZloa< z_|KDNTu*{U*kx5NJK$*!ruh!0G?bTs<(cv3_jF3{Qb{ZG%Tt$*3|9s%CSN9)KTAEk zA9da1AE4Pk$7ByQ8|WpwqVX0{QHixb!%U!?o0hD7-j42^=eN5kuBV9p8WojzAc%U= z4T~bJ@A_qq*-_}A;&~$+Kh}QLL5{QisuyO=;K#?u)&s>up5@HubmuH`t5EkT5jBAK zoi^t4e!0+Vg^E48{EZRPF4=kH9C0&=HSD2$hX7Bpj(r)7R+(*lo{A03tl8Q3Tnhp1 zqlfpPd+9-bdS#}1SXJ=#zj1%qcIN6!xPdVJoz)-s%%|d2{%>%5(wUyqK+-<}A!pyM z?QyeT$m{bNfR`+U*diTp2W5iNR=v<%O$S06=c_^{iuON#wkN6}hH1h!5UiYEk@`pQ zn9x1d_P>SLjPbwi*C@O6Kb^Q7^S?TAt$5rJ*C8d5$1KnrvSXWd3Nk2#2He0h^dXI$ zLcMkOgpiEg?F+`FN3JNneu&5`R`0oXp6V$uQz-|UO}P>gg$L;M0RuGsnM1Fl1R}=U ztV@Rd+uD9Y5|~N)**?%Cu&E?JivzlC?QbKrg@;qWZ2&$~t0R2FmxLo1ur4>At3c>) zkQ|DZ1TFzpJGk-gw^AhG(|MGeCj_=vDqfEyhzdg>~d-t`+$oA|j zPQw{o0KHwG6WpI(XibfkA0LBwxV-7?@-L9-Z}kr$CXEVg+aYW7h^ON(Fl{Ze)tU1@ zg6ZHf=Xiemep5Bt;27(nvt=C(9E+9dJN2VDFOe=`Tqzz*XbrBzUjsYb_<|9ou(C&m@3F|#0BVNID)WH zlmD&GETrXirl9Tl2MX>!K@bF(W1UKPbNi(wtWfLVO~4`s9P|HBd>j*rRC4(R=kPW& z6!nD`5C2%ZHHr{6c2lWxBTIrbuA8g#IkYMWGjLuM169}uovr>?jWa2p1*E;Ly+gpK z#|?PxZf9qoBitX^~{V??ZW|7t^Y}H+Q?u$~z3aT1nk^B)OT0}>JJ~lL) z9^1aaqEHXv@BIQ;TIVex_p%^_Lz`|N`ssAD@9j@oMDS6QsX2A%*7^TWfUNZgAkQEG zdAO5%^S=P(HVd1|)d=YIcOy~@H;`G*0xv_tYaj#}_I5$4yvMR3LgY^PO`(XXEH$R? zB{o~X|`J$h;OLpmldR5m2Z`zz7vQ1s@Nq~yy zKR_`wX{yNNS{HNz+Sk)+o3q)nMii^?0E!(d(l{$&BT?DWs~`O%A-!W1T)#LI(3K*+ z3uThKhwwYkUzX)XN<>$Kx*q>(`P@v{$N$pz9%5>rba{M|S)9oaCc`3*Y0_Ys5j_S0 z^2PQ0-wa9I=soju36-r6hfG%f<;k!MLPQ^Wjo=z6HzOUCd;H^^TME7->!sH!N@TTD z<>xh&Z(ZO#tBIPxm*?w52f`VWA?Wt((`UZN#nvH1wBMD9P#%9%{$N9+z_TKFuJlKp-Xgjc zwD@zQFTo@H=&IFILCFM`!;VlOYC+3)fJ>g>Ml|YhKJr=U_}0&xipy(fc!?NOkW5cq z1zC0JRhYguf!+@dtS|3Y_k{-2sRV@f08f$ywyLzj4o>uj7Vil}YxNf~D=EknZLvqp z4w~{F2V(YYU|2h$DTaZ*CpFl_c)Mf!ef_(t*H$430-gN21s>!!p%WomS^-cq60Qqk zJR4)_>g{>N@F6=Um%jD9m8H@+1PjbjED)q%-R#`lLnMqTh9CVDj`FZHrlsb39y=@m zvj0gwrK+IKpiUbj=dXf{Y^))^{v|LVuATG{SrzM|ZV9w?dx3r!gODQ6GJpKdCA1J) zq2owA3WffFXaSTN6wt0YxZX0$1T-b223ZA4fxkre1arkr%#d6QS*tuXUjOnITcCjI z>zfE!8^xZ_AXAd6k8eymVGTkzN03$NJbzbqT&k4r18cyeWa0x_Jhx(c_B(rU7hjF_fiRi|YLWIo`VO)$;PMr@r zW@>g0lnGnUd`TN;7oZVUg4?=MvEVDalXA+-=oOibce)tjXwHD>5ekqzuwR^XKd&AC zg3g@fy}yWs4N@&q?p(O!bk?bYOW&itA@6MCdIno=1O zB3rV+ditP(GL@g_BTQ~LE*YOn+uKbhxZqv>+8gauCL`u7S`E1=sh+CREv?Py#|%l~{9PkjNhd%w^qB=v zDp0j%2vzGRePOCZ(OU+H+Gf^!6f439?T$(duE*+$wGp9yb=V|(-td`89BwKzczBX; z>Z4wyL1(|IO<*PZsJZvQakfE65YCoVL)v4Hv%Ma4Q+;EPvwbVp)NQmHmOJ+^&NfI4 zv!rkvMflCx2D|Vh=4Vnj-gU_%C)K>BezrV&Hg&ak2N|l2Irjx=Px7{fH7K<*xqaL2 z1oB*O(pRo5-H#*O1yQ!rPZUUByRT{tvhPGc4qQ6D9q+?J8wc!5XvVkrQf3|Z6F!c8 zdmNu6oH8P-p?BEk7~4h?y0ce7fLM(z`W%i?{xbC1oYhB!dv8T_jHMvkk+ke&Ba6UJ z!DYETXP|&|pI@5vI68V-7ysc9DGhj77(?%4g98du2^S)i$aFz6f*Pg#jY8L}b&XJg zbX9Q;vpliFNqx+bx(56R>w3k|i>QIUVe$L^$LE2={pEPB7-s#c`Oi;$WUBxMcbRLA z%Ddn9Z*l8yuJ-p&A(9!pW2AIzklP0*`SWFj414ps>Btn9nO?G&kx#3P=f0zP2Uq}4 zKCisR5k-nPlKUVRee&IxkriaZ3-mm{&TvSJ(`%FsmSnT;K#YmzYo(~dtTJ`JNXujK zzQ@aiORe=#@KI0=m!$ho!%Ve4?+__qIZk1&DcaYZMzCa+g>Ojvp`@1b-(G;$c};5P=pS1 z!q_l#bdI45V;L`Nz59q!DThuuZsaJu@#>#U3ny}`c2;u`krtuEvge)S`hq?*!?U(W z7{Rbyk3lrf~lBR~VR6c8|XF!PskjI36;7UE>Wr4J4R zitd05?xh7fhJh2Vm63xyN7+X}vAYIir%TSRgu($}gs5m}9^pzCkqjinP_viL0zDOd zXf?gtiXC@E^5t;xgCNNr0KL_Z7hm96XjmPzDDZJ@5!!SMT5B^w20%gQFF0*SRpV&0 z!(4xlT+2F=h~U>rvoA+hCJmQXW)7;ALa?H$(XC8(x5{x(f|(Qpfi1X-L$29%*~zC; zi%3sl+kHNdK1uAi5_-9UzXRK$+5S+R~djp>jND_ z^aXn$8p}fudNxJ(Y|mtgRE}K!wZ4-4z_jzJ>p1T_Q|M)9yKjCmJ#YtVuK^yapyXQ? zFnDgj9Ld_-(t8j5Z%UX-jfBVDQP5o5*E_ZaEiRBNw@Caexw)M zmW$=JLo&!?xJ`md4yXdx(_+yY=<10tQN1U&akjJJE28?aweP{p{({Q=ewn*@>^67} zZI4igJBgF#1EEnCZ^FC6!z)QD&Z?Q4Xz`8h>hN~l(jmz6Bc6aWXVRAj3SYZ~?#jmW zF|%m@BBuPHRnQNVxE<61{UMbrhExilGW8CiU&^lm3Ju-iIef#hR&E?|iSMv}*e#3+_5~w%>QjPm+i3gyvVh1U&FvQu z_dP&MV^H#at}yo*sS^NUQi8V zciJH=5a-#pIr^hzKTFY^=&pDr-|^ z)7jAvTBvTOO#P|XzYd$LW^3iw1<{v)(MmT8Z$C@gm%wKePxBZj9WN~!=$eSv$*Rjj zFEDtlgt}pZK7pI8tR5U+*q{;3N8L!?utW&{sv6Tbny5r2hT+cZ8WKA8ORMq8C!IAa z6#dkUn_ms18`0#6t`xlTg+Ws;8v9$5{IjM+0?jC2E0GNIT)GcVZrsGBWvEki4K=nT zLnthf>syhbW?E(DRyZIKd`Tlac6@@DOMFX<)SY*f!o4k!>|i<1IYz<9hSEkYGoD?X zui9;|fmodCn9_n&M|9^2kb0DD#Y#``n0@`Yowd*V=xH#-vROw(y=|9}d6G8&bn3bu zKYfKEM`b8k59imK^7Kap%_K0*LmEY6kWawNo>DvB!}t=7@1mrftCUkyL`Nl4oywq# z^>ZhSZ}+Z56+S6P?;h_yDT&6)Iwxb+BMH(9shn@gsmm@sbS?N4bMA%Ges$9K3-nIy z4aDt-NzK4NoQlgPr-(WtXgc{m1qNQ=>ZdRS-{WTOx5(vU+IUW8Y1GgMS++U4RpJMW z?!fHzfv>%kA<3uSP&sH8n1+@=LQIzZn;l6?7lt37;I(6lm1cJqn%T5;{{E@8Nb2kK zW~Q(BQDv6gw`>{CN!sUJut^)+M+Ka>RD2Ev5ig=&w>KioeByf3WO^PjqfwT#$r^A0MsHtakNgbRz%r?>*fp$GaQV(w&hF-w?u8Dy$|4~2Uc=GH z#WAfWT5AtHl!Hi0-K!-=Q^`lr5ozyU*fOmlve}rzRzcR7N+p#G>&FFuSLafL;ni5S zTPNeWt6Rg#I=QpzO0y#pQNgZtc#`Z`;$b7@`29J>f?13=3s%!i^tJ?I_pwte%>_Pt zA?x3b(IyHp?W4Rsrj#q)!FbwIdA=$++_db`P?J=0FSwOb)A0pB3F)jeP0s!cp;E!jYH`A?#2n->^xaLxtyjsSh!rR@X0m#<_CvDvJd% z@H&JwD{@5}le4kQ25}xlBRLbU6Nsav47hS72`#1zo!+a-N|M(^E2ZD~o}ynhwHU12 z$JY2ban&4S-jIH1V7js!Ps~$R3uwJQPZ47lrI(GuOmV8Ck0jE&^0tvc(vRM9vg$9d z*@3Ez%VX4s7JBSEn+~>r_%Qw?yydKy=+Rvi3A5RZZ;;dzRrL?N=g$;$YTq)cmOp&N zOoD@0O!;8Pfsf;kb~#U4D1=Nd3N}T(v~@OFiyMd8e7-c}X*==ShTCZ~A%;j3$S#{_#}6IP|4CvXzKVxG#xpTI~?rK8xg zYv8EOKXS;v%wKhv-k>>9;t`ozcpWDjT{F0=uJRjk8rqZ5Utr!0zsc{uQdSn@9SbCqdyCMC+vDZXhzq*9qdj!|1 z)7l?fp)wSf9Jxk3_Y}ok_T*!=S02XU;Nwd^*XIF3xGeVqYp)JMZ<-iFI@z%v<~oi* zo0O4~jPK`(>7Qkjcu4sP)JK7Eqn zOop!UuCejUM&QZ#zm@VziIN*?NJc(SElVq7kfe}wzm{QrExe@F7W0~%0Z+Z?^0?J5 zu;=U=-LyfU$Cr)Rw;xMCZa)=eC6)sJ!Yxg-9N zF>m(UaXM2XRQ0!JbIwH5SIA_8m^5lbRMk3VWi*x%t&HcJk$7yAlawiFToLH6dra?D z^epb=fbb6*yrxLwY?Aito{|t^EeDA}X{$SY)cI7Bth+!om9zSU zdZd3ikNo=4x3caiMC_7Tcj=6*Zd2-+)ZI=t*)^f!WcUaaM!NN9P+a>Sl>`7$<+#)< zox2FO63czw>w;1eH-zhL@5W#l9P?&{C)~d%8^o>8)R&whzy8s~%5&{UrD0pg^@Nh~ zMK}4tg{h1j>wWKEY4cnX+Z@a(<;sACDxpm`ysfSi-+lK+MrGHpG(8bn!T7r(B30Q_ z>z_x!`LHu!^2=v4(G_K{h-K}OI&d66RGG@sW1{|a^7Tu$bbTaoAyZ5&Nw!aynCu`* zUO`PtFX3Tgg|#12Au1KojumS>u3y@;_1Lc1(9{*V<|Xb?UA0;EDPkhYY5tYUr`pV| z0@yIBS&KqjDP3)i?f0XSFBrcIlzBAiYNSqr^7gw#^)4jgYRP$&h(7!s<loXVqaTGRU8Tx*V~)^^2jlHA?NFp zLDxk>w5}6P)(**|ylYyx!kjIMsj%3Y81m&$clGKD(oriLeJ%~NC=_L);DX~J&eiWY zo9lTNitRrE+2g_VFj$_Ry&Ss6kZlYD3-?3heu`S%tN`RTAU%Tl5aPZI8oQcM#>`?|| zDlXdB8B#9ZgoZc{Zu~{#Q)z_OL#Kz!nu0xd~Typ=5X}lE`7E^A^Cjw&I5wF)GT=1Q= zjo|9u4L48HE5>Z8@->@xJ>@?@_9+;D+$v-ms>KzBL)V4j`{WhxNIrn}Xv;E!WbfQr z09D+!5INUqu)_5XZ(Pe%;C!jMmKWYRYqRpbXJ4znnkU5L3<~AGY^zg-`oYxvJna{$z87gJZ`I z)6=Ft`v8At9=BPkm!&wL96A-9mlacIaEX31{?->&MyvfMJjGUP;b|#1)jl`M9cco} zpRkpYk>Q0lJqaj?6RFMTF}k|Hc<~oz&*;9}to(|<@Fh-fnFUuHTdrL&5ZKw08gLxH z93zQLW!-K8^WhHdupd&Wd!LmgJV|wcIYD0ng3|q6`wCp+PqFayRp)aSe?Mz-Db$!D z3M>20!G0uZY4|0G4_AA7dOU~@!0p)z^f^BSQ24H{hJY@%;ATuX+3Hk4|FwnDAp8e< z*Vusm9`^L^)>HWyRYRdzOvtk0?zZs;CY`7^>Re4 zRXEt4zd32Q6p3A!R5U@pr-!sTX>5-p$BSsnX1trs>VpOSQgJ zIfdIi!Q=OIQ&{<|WE>D&sC!Yv68SAXl(d~0bY1KNm!tcf9v$g3&OY;jW&Vcx!qtbW ziOis&jnwv*ArVjix|ScAsq z&S#Wq56+sz25{B}N*y3u{eG{|5MNuS-qJ=XshW<>IjKg5FIqJZn37N=V@xLu7|kH; zMe@ZI9r66UsxO(YEIQ%=R$>x5R8MhVOx9{@PYkfzWza`&&B#1VZ^IqElwOTHdcHTE zE}bTR$~E*H`0r4!B4gWdSzEON`F&EM?JWj6npdR@B}zx11iV)xYssUDRwL{C5CGG8 zy$+{8$L&WuGXSBWMbbOu<15-faC+x4mE4CYEJnOn0-~h@@+a!Ve+~CotFjC zZNhe!4)vmU(th$hZBFBxZtY7Rzcb4{A-H)NHtz2a?CR}Ib>gSy+PYR>n`-F^*$5Y5 zCrn9%?bvvG!%|KPc6M9EFrTBiHFHw9xb`;VhuV7BDU zXuOfl!R7WN5~&d4-J)t-^D(yGqE;iN%s&AE?YI%*h8|?0md%6rm{V+?I z*E^n15i8y8TGK8yiWA0tm=O=)KS+k@8}%oa$n0Kq?Xw$(YgJva8b@uQg9@dg47-up zJ#jst*Z4}k<>?S|;bYX7XHGI6bCHMNcg!%8*fn8}s{FultEO_j1l7r_kEG}K-j!oo z@}w&S@p&8lJawVKN@LBrsvmoSPTW(A0;lT2pr$614Kx zCGL>36O|?u0(a6DZ>X=g6%H0lgj%aD#?LYVm6 zC$;&UE|93f`HO1eyuVt@k+VwIY1chMA$ZK;@|L=Y1N;0bYxtkJcRVC~Fy#ivsA*}1 z99|jJZk!mFnDacw0$L@}MqxkU2%(}VkIAce=@rdkiN2-&?KF4zvFkz-2lkI0@8dY< zaCqVq&urgw25#?fkM1E)bo6mLR2b8oP2j9TwwU`fltJJ&{PTlo$60@+;DVu~7>WH` zC-QI4lmpE1?kKQHRHXlYCfJ;VbP5-2kFCbS%WK9jc~3VBJG|1_?}~jxKwDtoACog; ztIP7q=*D{$h1F4<;a2dNe;dd%e#%1l=GaU?59rcV8`2VVbC8m)L-ot3&`n-K-I`{- z2w@s|WNe>N91Ht&haKnR5ooXZ0gJrXcpp)%=5H_1odLp;%$OUKByl)OI;e8}Cj88J#%0QxF!Wf8LpJ>F z&RwloZxyR&2~#fc^AB{5Yz^cqi9Dw!;I3Bdhkbhdbzw)O8-Ba@+3 Date: Wed, 11 Dec 2024 07:47:33 +0100 Subject: [PATCH 023/103] chore: update version to 2.0.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/org.gabmus.envision.metainfo.xml.in.in | 8 ++++++++ meson.build | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 944770b..989abbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,7 +554,7 @@ dependencies = [ [[package]] name = "envision" -version = "2.0.0" +version = "2.0.1" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index 196473a..512e892 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "envision" -version = "2.0.0" +version = "2.0.1" edition = "2021" authors = [ "Gabriele Musco ", diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 0981352..1e40a8f 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -30,6 +30,14 @@ @REPO_URL@/issues + + +

Fixes

+
    +
  • add screenshots to appdata
  • +
+
+

Breaking changes

diff --git a/meson.build b/meson.build index 57fd7a0..4d58596 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'envision', 'rust', - version: '2.0.0', # version number row + version: '2.0.1', # version number row meson_version: '>= 0.59', license: 'AGPL-3.0-or-later', ) From f04723c1c4753f3d1451e0a8da18953b16d0a947 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 5 Sep 2024 22:45:01 +0200 Subject: [PATCH 024/103] feat: use ubuntu for the ci --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 41aebbe..6eb8f0f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "debian:unstable" +image: "ubuntu:24.04" stages: - check From e781736ffa83e17984172fc0873864cc2dae7e43 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 22 Aug 2024 07:51:16 +0200 Subject: [PATCH 025/103] feat: single stage ci with tests, clippy and fmt check all in one --- .gitlab-ci.yml | 47 +-------------------------------- dist/appimage/build_appimage.sh | 1 + src/main.rs | 1 + src/meson.build | 23 +++++++++++++++- 4 files changed, 25 insertions(+), 47 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6eb8f0f..f947193 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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/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/src/main.rs b/src/main.rs index 4453911..41494cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,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; 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, +) From bc5c4a4a406ea855de9086c927b4e28c1568611b Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 18 Dec 2024 07:31:53 +0100 Subject: [PATCH 026/103] fix: print active runtime related informative logs as debug --- src/file_builders/active_runtime_json.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/file_builders/active_runtime_json.rs b/src/file_builders/active_runtime_json.rs index 8804a4d..e637385 100644 --- a/src/file_builders/active_runtime_json.rs +++ b/src/file_builders/active_runtime_json.rs @@ -11,7 +11,7 @@ use std::{ os::unix::fs::symlink, path::{Path, PathBuf}, }; -use tracing::{info, warn}; +use tracing::{debug, warn}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActiveRuntimeInnerRuntime { @@ -107,7 +107,7 @@ pub fn set_current_active_runtime_to_profile(profile: &Profile) -> anyhow::Resul if dest.is_file() || dest.is_symlink() { rename(&dest, dest.parent().unwrap().join(ACTIVE_RUNTIME_BAK))?; } else { - info!("no active_runtime.json file to backup") + debug!("no active_runtime.json file to backup") } let profile_openxr_json = profile.openxr_json_path(); @@ -135,7 +135,7 @@ pub fn remove_current_active_runtime() -> anyhow::Result<()> { bail!("{} is a directory", dest.to_string_lossy()); } if !dest.exists() { - info!("no current active_runtime.json to remove") + debug!("no current active_runtime.json to remove") } Ok(remove_file(dest)?) } @@ -152,7 +152,7 @@ pub fn restore_active_runtime_backup() -> anyhow::Result<()> { } rename(&bak, &dest)?; } else { - info!("{ACTIVE_RUNTIME_BAK} does not exist, nothing to restore"); + debug!("{ACTIVE_RUNTIME_BAK} does not exist, nothing to restore"); } Ok(()) From 36322b3b2c52f6d914d8a6e551d72306d86ae192 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 18 Dec 2024 07:36:22 +0100 Subject: [PATCH 027/103] feat: version command line option --- src/ui/cmdline_opts.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ui/cmdline_opts.rs b/src/ui/cmdline_opts.rs index 48458e0..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}, @@ -10,6 +13,7 @@ use tracing::error; #[derive(Debug, Clone)] pub struct CmdLineOpts { + pub version: bool, pub start: bool, pub list_profiles: bool, pub profile_uuid: Option, @@ -18,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'); @@ -25,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(), @@ -69,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(); @@ -99,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 From 0020dcf3d4ed0d88725e248086f276c64614c0f4 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 18 Dec 2024 23:19:24 +0100 Subject: [PATCH 028/103] feat: clearer messaging around setcap failures; getcap after setcap --- src/ui/app.rs | 22 ++++++++++++++++++++-- src/util/file_utils.rs | 29 +++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index ca00f4b..2b3f94a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -41,7 +41,9 @@ use crate::{ steam_linux_runtime_injector::{ restore_runtime_entrypoint, set_runtime_entrypoint_launch_opts_from_profile, }, - util::file_utils::{setcap_cap_sys_nice_eip, setcap_cap_sys_nice_eip_cmd}, + util::file_utils::{ + setcap_cap_sys_nice_eip, setcap_cap_sys_nice_eip_cmd, verify_cap_sys_nice_eip, + }, vulkaninfo::VulkanInfo, wivrn_dbus, xr_devices::XRDevice, @@ -653,7 +655,23 @@ impl AsyncComponent for App { 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}"); + } else if !verify_cap_sys_nice_eip(&profile).await { + setcap_failed_dialog(); + } } } Msg::ProfileSelected(prof) => { diff --git a/src/util/file_utils.rs b/src/util/file_utils.rs index 59f8dad..936aacf 100644 --- a/src/util/file_utils.rs +++ b/src/util/file_utils.rs @@ -90,13 +90,34 @@ 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 - { - 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(); + match async_process("getcap", 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") + } + } } } +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() { error!("failed to remove path {}", path.to_string_lossy()); From ca813d6168c5a93c371c268a35fb99d8b1bfd86f Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 21 Dec 2024 11:05:22 +0100 Subject: [PATCH 029/103] feat: press enter on env var entry to add --- src/ui/profile_editor.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui/profile_editor.rs b/src/ui/profile_editor.rs index de8affa..6b8bf80 100644 --- a/src/ui/profile_editor.rs +++ b/src/ui/profile_editor.rs @@ -511,14 +511,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() { @@ -527,7 +527,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 }}; } From e69a7a9bd62bef27f3d84b8d037bafe55bbed483 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 21 Dec 2024 11:35:38 +0100 Subject: [PATCH 030/103] feat: make env var description selectable --- src/ui/profile_editor.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/ui/profile_editor.rs b/src/ui/profile_editor.rs index 6b8bf80..353dff0 100644 --- a/src/ui/profile_editor.rs +++ b/src/ui/profile_editor.rs @@ -12,6 +12,7 @@ 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 { @@ -546,17 +547,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) From 696c5415989bc8a8c21ee490c2ec3dac894c897d Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 29 Dec 2024 10:09:02 +0100 Subject: [PATCH 031/103] fix: debian package name for gstreamer plugins base --- src/depcheck/wivrn_deps.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/depcheck/wivrn_deps.rs b/src/depcheck/wivrn_deps.rs index 38eb8b2..0bf45a9 100644 --- a/src/depcheck/wivrn_deps.rs +++ b/src/depcheck/wivrn_deps.rs @@ -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()), From e5435d0aa3fab85ac03f249ae5326b408196c6df Mon Sep 17 00:00:00 2001 From: BabbleBones Date: Mon, 30 Dec 2024 09:47:38 -0500 Subject: [PATCH 032/103] fix: use boost dev packages --- src/depcheck/boost_deps.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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()), ]), From d38acf0a7e8d0106ef5f80451091ee0149c3b864 Mon Sep 17 00:00:00 2001 From: GabMus Date: Wed, 1 Jan 2025 19:02:28 +0000 Subject: [PATCH 033/103] feat!: plugin store --- src/config.rs | 28 +- src/paths.rs | 4 + src/profile.rs | 3 - src/ui/app.rs | 106 +++-- src/ui/main_view.rs | 3 +- src/ui/mod.rs | 1 + src/ui/plugins/add_custom_plugin_win.rs | 169 ++++++++ src/ui/plugins/mod.rs | 63 +++ src/ui/plugins/store.rs | 499 ++++++++++++++++++++++++ src/ui/plugins/store_detail.rs | 327 ++++++++++++++++ src/ui/plugins/store_row_factory.rs | 227 +++++++++++ src/ui/preference_rows.rs | 67 +++- src/ui/profile_editor.rs | 8 - src/util/file_utils.rs | 12 + 14 files changed, 1471 insertions(+), 46 deletions(-) create mode 100644 src/ui/plugins/add_custom_plugin_win.rs create mode 100644 src/ui/plugins/mod.rs create mode 100644 src/ui/plugins/store.rs create mode 100644 src/ui/plugins/store_detail.rs create mode 100644 src/ui/plugins/store_row_factory.rs diff --git a/src/config.rs b/src/config.rs index a35dd93..18cd864 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,15 +7,38 @@ 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] { @@ -29,6 +52,8 @@ pub struct Config { pub user_profiles: Vec, #[serde(default = "default_win_size")] pub win_size: [i32; 2], + #[serde(default)] + pub plugins: HashMap, } impl Default for Config { @@ -37,8 +62,9 @@ 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(), } } } diff --git a/src/paths.rs b/src/paths.rs index 06b7575..f714964 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -87,3 +87,7 @@ pub fn get_steamvr_bin_dir_path() -> PathBuf { XDG.get_data_home() .join("Steam/steamapps/common/SteamVR/bin/linux64") } + +pub fn get_plugins_dir() -> PathBuf { + get_data_dir().join("plugins") +} diff --git a/src/profile.rs b/src/profile.rs index d67a7ac..4a637fe 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -363,7 +363,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, } @@ -422,7 +421,6 @@ impl Default for Profile { lighthouse_driver: LighthouseDriver::default(), xrservice_launch_options: String::default(), uuid, - autostart_command: None, skip_dependency_check: false, } } @@ -543,7 +541,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(), diff --git a/src/ui/app.rs b/src/ui/app.rs index 2b3f94a..5948153 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}, }; @@ -21,7 +22,7 @@ use crate::{ build_opencomposite::get_build_opencomposite_jobs, build_openhmd::get_build_openhmd_jobs, build_wivrn::get_build_wivrn_jobs, }, - config::Config, + config::{Config, PluginConfig}, constants::APP_NAME, depcheck::common::dep_pkexec, file_builders::{ @@ -56,7 +57,11 @@ 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 { @@ -74,7 +79,7 @@ pub struct App { config: Config, xrservice_worker: Option, - autostart_worker: Option, + plugins_worker: Option, restart_xrservice: bool, build_worker: Option, profiles: Vec, @@ -89,13 +94,14 @@ pub struct App { vkinfo: Option, inhibit_fail_notif: Option, + pluginstore: Option>, } #[derive(Debug)] pub enum Msg { OnServiceLog(Vec), OnServiceExit(i32), - OnAutostartExit(i32), + OnPluginsExit(i32), OnBuildLog(Vec), OnBuildExit(i32), ClockTicking, @@ -120,6 +126,8 @@ pub enum Msg { StartProber, OnProberExit(bool), WivrnCheckPairMode, + OpenPluginStore, + UpdateConfigPlugins(HashMap), NoOp, } @@ -235,19 +243,43 @@ impl App { 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 { + Some(format!( + "'{}'", + cp.plugin.executable().unwrap().to_string_lossy() + )) + } + } 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); } } @@ -277,27 +309,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![]; } } @@ -363,6 +385,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() @@ -370,6 +394,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!( @@ -384,7 +410,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 @@ -791,6 +817,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(); + } } } @@ -892,6 +933,17 @@ impl AsyncComponent for App { } ) ); + stateless_action!( + actions, + PluginStoreAction, + clone!( + #[strong] + sender, + move |_| { + sender.input(Msg::OpenPluginStore); + } + ) + ); // this bypasses the macro because I need the underlying gio action // to enable/disable it in update() let configure_wivrn_action = { @@ -963,7 +1015,7 @@ impl AsyncComponent for App { config, profiles, xrservice_worker: None, - autostart_worker: None, + plugins_worker: None, build_worker: None, xr_devices: vec![], restart_xrservice: false, @@ -974,6 +1026,7 @@ impl AsyncComponent for App { openxr_prober_worker: None, xrservice_ready: false, inhibit_fail_notif: None, + pluginstore: None, }; let widgets = view_output!(); @@ -1046,6 +1099,7 @@ 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 DebugOpenDataAction, AppActionGroup, "debugopendata"); new_stateless_action!(pub DebugOpenPrefixAction, AppActionGroup, "debugopenprefix"); diff --git a/src/ui/main_view.rs b/src/ui/main_view.rs index 28ec87a..8765a32 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}, @@ -148,6 +148,7 @@ impl AsyncComponent for MainView { menu! { app_menu: { section! { + "Plugin_s" => PluginStoreAction, // value inside action is ignored "_Debug View" => DebugViewToggleAction, "_Build Profile" => BuildProfileAction, 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/plugins/add_custom_plugin_win.rs b/src/ui/plugins/add_custom_plugin_win.rs new file mode 100644 index 0000000..c06f0c8 --- /dev/null +++ b/src/ui/plugins/add_custom_plugin_win.rs @@ -0,0 +1,169 @@ +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), + 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)) + ) + ) + }, + }, + }, + } + } + + 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::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..11732df --- /dev/null +++ b/src/ui/plugins/mod.rs @@ -0,0 +1,63 @@ +pub mod add_custom_plugin_win; +pub mod store; +mod store_detail; +mod store_row_factory; + +use crate::{paths::get_plugins_dir, util::file_utils::mark_as_executable}; +use anyhow::bail; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)] +pub struct Plugin { + pub appid: String, + pub name: String, + pub icon_url: Option, + pub version: Option, + pub short_description: Option, + pub description: Option, + pub hompage_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, +} + +impl Plugin { + 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()) + } +} diff --git a/src/ui/plugins/store.rs b/src/ui/plugins/store.rs new file mode 100644 index 0000000..95bbf42 --- /dev/null +++ b/src/ui/plugins/store.rs @@ -0,0 +1,499 @@ +use super::{ + add_custom_plugin_win::{ + AddCustomPluginWin, AddCustomPluginWinInit, AddCustomPluginWinMsg, AddCustomPluginWinOutMsg, + }, + 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, + Refresh, + Install(Plugin, relm4::Sender), + InstallFromDetails(Plugin), + InstallDownload(Plugin, relm4::Sender), + Remove(Plugin), + SetEnabled(PluginStoreSignalSource, Plugin, bool), + ShowDetails(usize), + ShowPluginList, + PresentAddCustomPluginWin, + AddPluginToConfig(Plugin), + 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), + }); + }); + } +} + +#[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_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; + } + sender.input(Self::Input::AddPluginToConfig(plugin)); + sender.input(Self::Input::Refresh); + } + Self::Input::AddPluginToConfig(plugin) => { + self.config_plugins + .insert(plugin.appid.clone(), PluginConfig::from(&plugin)); + sender + .output(Self::Output::UpdateConfigPlugins( + self.config_plugins.clone(), + )) + .expect(SENDER_IO_ERR_MSG); + } + 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); + // TODO: populate from web + let mut plugins = vec![ + Plugin { + appid: "com.github.galiser.wlx-overlay-s".into(), + name: "WLX Overlay S".into(), + version: Some("0.6.0".into()), + hompage_url: Some("https://github.com/galister/wlx-overlay-s".into()), + icon_url: Some("https://github.com/galister/wlx-overlay-s/raw/main/wlx-overlay-s.svg".into()), + screenshots: vec![ + "https://github.com/galister/wlx-overlay-s/raw/guide/wlx-s.png".into(), + ], + description: Some("A lightweight OpenXR/OpenVR overlay for Wayland and X11 desktops, inspired by XSOverlay.\n\nWlxOverlay-S allows you to access your desktop screens while in VR.\n\nIn comparison to similar overlays, WlxOverlay-S aims to run alongside VR games and experiences while having as little performance impact as possible. The UI appearance and rendering techniques are kept as simple and efficient as possible, while still allowing a high degree of customizability.".into()), + short_description: Some("Access your Wayland/X11 desktop".into()), + exec_url: Some("https://github.com/galister/wlx-overlay-s/releases/download/v0.6/WlxOverlay-S-v0.6-x86_64.AppImage".into()), + exec_path: None, + }, + ]; + { + 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::InstallFromDetails(plugin) => { + 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() + { + sender.input(Self::Input::Install(plugin, row.input_sender.clone())) + } else { + error!("could not find corresponding listbox row") + } + } + Self::Input::Install(plugin, row_sender) => { + self.set_locked(true); + sender.input(Self::Input::InstallDownload(plugin, row_sender)) + } + Self::Input::InstallDownload(plugin, row_sender) => { + 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); + sender.input(Self::Input::AddPluginToConfig(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::()) + ); + } + }; + row_sender.emit(StoreRowModelMsg::Refresh(true, false)); + self.details + .emit(StoreDetailMsg::Refresh(plugin.appid, true, false)); + self.set_locked(false); + } + Self::Input::Remove(plugin) => { + self.set_locked(true); + 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) { + 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::InstallFromDetails(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, row_sender) => { + Self::Input::Install(appid, row_sender) + } + 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..5a1b12c --- /dev/null +++ b/src/ui/plugins/store_detail.rs @@ -0,0 +1,327 @@ +use super::Plugin; +use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG}; +use adw::prelude::*; +use relm4::prelude::*; +use tracing::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), +} + +#[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::Label { + add_css_class: "title-2", + set_hexpand: true, + 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::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::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::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..3ea2629 --- /dev/null +++ b/src/ui/plugins/store_row_factory.rs @@ -0,0 +1,227 @@ +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, relm4::Sender), + 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(), + sender.input_sender().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(), + sender.input_sender().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..4502ebf 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,6 +199,60 @@ 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 {}", 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)) @@ -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 353dff0..99f7b7b 100644 --- a/src/ui/profile_editor.rs +++ b/src/ui/profile_editor.rs @@ -130,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, diff --git a/src/util/file_utils.rs b/src/util/file_utils.rs index 936aacf..ff56926 100644 --- a/src/util/file_utils.rs +++ b/src/util/file_utils.rs @@ -7,6 +7,7 @@ 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}; @@ -151,6 +152,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; From db5c295435ab2015554d58306725d791315f9c2c Mon Sep 17 00:00:00 2001 From: Bones Date: Wed, 1 Jan 2025 14:05:45 -0500 Subject: [PATCH 034/103] fix: add wayland drm-lease protocols dep for monado --- src/depcheck/monado_deps.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index f92ffd4..ef5ece1 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -30,6 +30,18 @@ fn monado_deps() -> Vec { (LinuxDistro::Suse, "wayland-devel".into()), ]), }, + Dependency { + name: "wayland-protocols".into(), + dep_type: DepType::SharedObject, + filename: "wayland-protocols/drm-lease-v1-enum.h".into(), + packages: HashMap::from([ + (LinuxDistro::Arch, "wayland-protocols".into()), + (LinuxDistro::Debian, "wayland-protocols".into()), + (LinuxDistro::Fedora, "wayland-protocols".into()), + (LinuxDistro::Gentoo, "dev-libs/wayland-protocols".into()), + (LinuxDistro::Suse, "wayland-protocols".into()), + ]), + }, dep_cmake(), dep_eigen(), dep_git(), From 31b22b59f377eab84379d4c417d7e508e73bc110 Mon Sep 17 00:00:00 2001 From: Bones Date: Wed, 1 Jan 2025 15:49:23 -0500 Subject: [PATCH 035/103] fix: add libbsd deps for monado --- src/depcheck/monado_deps.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index ef5ece1..d364ffe 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -42,6 +42,18 @@ fn monado_deps() -> Vec { (LinuxDistro::Suse, "wayland-protocols".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(), From 0adf894b4519824682b6e0886fa1c6853c9b7344 Mon Sep 17 00:00:00 2001 From: Bones Date: Wed, 1 Jan 2025 17:07:39 -0500 Subject: [PATCH 036/103] fix: Include not shared object wayland-protocols --- src/depcheck/monado_deps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index d364ffe..8b8b217 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -32,7 +32,7 @@ fn monado_deps() -> Vec { }, Dependency { name: "wayland-protocols".into(), - dep_type: DepType::SharedObject, + dep_type: DepType::Include, filename: "wayland-protocols/drm-lease-v1-enum.h".into(), packages: HashMap::from([ (LinuxDistro::Arch, "wayland-protocols".into()), From 4767a4eb1382a30d1fdc9eb107bc4131dd271ca1 Mon Sep 17 00:00:00 2001 From: Bones Date: Wed, 1 Jan 2025 18:34:47 -0500 Subject: [PATCH 037/103] fix: switch to searching for the xml for deb based distros --- src/depcheck/mod.rs | 6 ++++++ src/depcheck/monado_deps.rs | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/depcheck/mod.rs b/src/depcheck/mod.rs index 54da91d..002a8c8 100644 --- a/src/depcheck/mod.rs +++ b/src/depcheck/mod.rs @@ -16,6 +16,7 @@ pub enum DepType { Executable, Include, UdevRule, + Share, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -49,6 +50,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); @@ -145,6 +147,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 8b8b217..fce59f7 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -32,8 +32,8 @@ fn monado_deps() -> Vec { }, Dependency { name: "wayland-protocols".into(), - dep_type: DepType::Include, - filename: "wayland-protocols/drm-lease-v1-enum.h".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()), From eef963793d802faee412597ed3d8451f25c05c43 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 2 Jan 2025 11:21:54 +0100 Subject: [PATCH 038/103] feat: add cpu to debug info --- src/ui/about_dialog.rs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/ui/about_dialog.rs b/src/ui/about_dialog.rs index 593e0f1..8f4235e 100644 --- a/src/ui/about_dialog.rs +++ b/src/ui/about_dialog.rs @@ -30,6 +30,8 @@ pub fn create_about_dialog() -> adw::AboutDialog { .build() } +const UNKNOWN: &str = "UNKNOWN"; + pub fn populate_debug_info(dialog: &adw::AboutDialog, vkinfo: Option<&VulkanInfo>) { if dialog.debug_info().len() > 0 { return; @@ -42,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: {}", @@ -55,23 +57,35 @@ 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(':').last().map(|s| s.trim().to_string())) + }) + .flatten() + .unwrap_or(UNKNOWN.into()) ), format!( "GPUs: {}", vkinfo .map(|i| i.gpu_names.join(", ")) - .unwrap_or("unknown".into()) + .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(); From e5a59ebf62dd93edda626d8ec6077bdc7cc2c3b2 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 2 Jan 2025 11:42:43 +0100 Subject: [PATCH 039/103] fix: get steamvr bin dir by parsing libraryfolders.vdf fixes #171 --- src/paths.rs | 25 +++++++-- src/ui/steamvr_calibration_box.rs | 93 ++++++++++++++++--------------- 2 files changed, 69 insertions(+), 49 deletions(-) diff --git a/src/paths.rs b/src/paths.rs index f714964..7761758 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, @@ -83,9 +85,24 @@ 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)) + .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 { diff --git a/src/ui/steamvr_calibration_box.rs b/src/ui/steamvr_calibration_box.rs index f2f9c7d..beae630 100644 --- a/src/ui/steamvr_calibration_box.rs +++ b/src/ui/steamvr_calibration_box.rs @@ -10,7 +10,6 @@ use relm4::{ }; use std::{ collections::{HashMap, VecDeque}, - path::Path, thread::sleep, time::Duration, }; @@ -145,51 +144,55 @@ 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 { From 6fa7d1e2a357c079b33cef2f6581788e3f5e3d9c Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 2 Jan 2025 12:17:53 +0100 Subject: [PATCH 040/103] feat: ask to build profile after editing it fixes #166 --- src/ui/app.rs | 4 ++++ src/ui/main_view.rs | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 5948153..14c0787 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -675,6 +675,9 @@ 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() { @@ -989,6 +992,7 @@ impl AsyncComponent for App { MainViewOutMsg::DeleteProfile => Msg::DeleteProfile, MainViewOutMsg::SaveProfile(p) => Msg::SaveProfile(p), MainViewOutMsg::OpenLibsurviveSetup => Msg::OpenLibsurviveSetup, + MainViewOutMsg::BuildProfile(clean) => Msg::BuildProfile(clean), }), vkinfo, debug_view: DebugView::builder() diff --git a/src/ui/main_view.rs b/src/ui/main_view.rs index 8765a32..34391c1 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -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, @@ -60,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, @@ -104,6 +107,7 @@ pub enum MainViewMsg { SetWivrnPairingMode(bool), StopWivrnPairingMode, StartWivrnPairingMode, + QueryProfileRebuild, } #[derive(Debug)] @@ -114,6 +118,8 @@ pub enum MainViewOutMsg { DeleteProfile, SaveProfile(Profile), OpenLibsurviveSetup, + /// params: clean + BuildProfile(bool), } pub struct MainViewInit { @@ -722,6 +728,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() @@ -759,7 +769,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 { @@ -928,6 +938,29 @@ 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?") .build(); @@ -1062,6 +1095,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, From aa9bd09372e194db383b1c0dc073cd3340b5efb6 Mon Sep 17 00:00:00 2001 From: Nova King Date: Thu, 2 Jan 2025 15:25:11 +0000 Subject: [PATCH 041/103] feat: add telescope to plugin store --- src/ui/plugins/store.rs | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/ui/plugins/store.rs b/src/ui/plugins/store.rs index 95bbf42..4f3471c 100644 --- a/src/ui/plugins/store.rs +++ b/src/ui/plugins/store.rs @@ -250,20 +250,34 @@ impl AsyncComponent for PluginStore { self.set_refreshing(true); // TODO: populate from web let mut plugins = vec![ + Plugin { + appid: "com.github.galiser.wlx-overlay-s".into(), + name: "WLX Overlay S".into(), + version: Some("0.6.0".into()), + hompage_url: Some("https://github.com/galister/wlx-overlay-s".into()), + icon_url: Some("https://github.com/galister/wlx-overlay-s/raw/main/wlx-overlay-s.svg".into()), + screenshots: vec![ + "https://github.com/galister/wlx-overlay-s/raw/guide/wlx-s.png".into(), + ], + description: Some("A lightweight OpenXR/OpenVR overlay for Wayland and X11 desktops, inspired by XSOverlay.\n\nWlxOverlay-S allows you to access your desktop screens while in VR.\n\nIn comparison to similar overlays, WlxOverlay-S aims to run alongside VR games and experiences while having as little performance impact as possible. The UI appearance and rendering techniques are kept as simple and efficient as possible, while still allowing a high degree of customizability.".into()), + short_description: Some("Access your Wayland/X11 desktop".into()), + exec_url: Some("https://github.com/galister/wlx-overlay-s/releases/download/v0.6/WlxOverlay-S-v0.6-x86_64.AppImage".into()), + exec_path: None, + }, Plugin { - appid: "com.github.galiser.wlx-overlay-s".into(), - name: "WLX Overlay S".into(), - version: Some("0.6.0".into()), - hompage_url: Some("https://github.com/galister/wlx-overlay-s".into()), - icon_url: Some("https://github.com/galister/wlx-overlay-s/raw/main/wlx-overlay-s.svg".into()), - screenshots: vec![ - "https://github.com/galister/wlx-overlay-s/raw/guide/wlx-s.png".into(), - ], - description: Some("A lightweight OpenXR/OpenVR overlay for Wayland and X11 desktops, inspired by XSOverlay.\n\nWlxOverlay-S allows you to access your desktop screens while in VR.\n\nIn comparison to similar overlays, WlxOverlay-S aims to run alongside VR games and experiences while having as little performance impact as possible. The UI appearance and rendering techniques are kept as simple and efficient as possible, while still allowing a high degree of customizability.".into()), - short_description: Some("Access your Wayland/X11 desktop".into()), - exec_url: Some("https://github.com/galister/wlx-overlay-s/releases/download/v0.6/WlxOverlay-S-v0.6-x86_64.AppImage".into()), - exec_path: None, - }, + appid: "org.stardustxr.telescope".into(), + name: "Telescope (Stardust XR)".into(), + version: Some("0.45.0".into()), + hompage_url: Some("https://stardustxr.org/".into()), + icon_url: Some("https://stardustxr.org/img/icon.png".into()), + screenshots: vec![ + "https://stardustxr.org/img/carousel/workflow.png".into(), + ], + description: Some("A more intuitive overlay designed for spatial computing from the ground up. Part of the Stardust XR project.\n\nTelescope lets you use your favorite apps directly with your controllers or hands at any time.".into()), + short_description: Some("Use your existing apps spatially and intuitively.".into()), + exec_url: Some("https://github.com/StardustXR/telescope/releases/download/0.45.0/Telescope-x86_64.AppImage".into()), + exec_path: None, + }, ]; { let appids_from_web = plugins From 69eba0153bd33b1ac025bc504cedb8d6e270ad30 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 2 Jan 2025 19:32:31 +0100 Subject: [PATCH 042/103] feat: fetch plugins manifests online --- src/ui/plugins/mod.rs | 32 ++++++++++++++++++++++- src/ui/plugins/store.rs | 56 ++++++++++++++++++----------------------- 2 files changed, 56 insertions(+), 32 deletions(-) diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index 11732df..8d82a44 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -3,7 +3,11 @@ pub mod store; mod store_detail; mod store_row_factory; -use crate::{paths::get_plugins_dir, util::file_utils::mark_as_executable}; +use crate::{ + downloader::{cache_file_path, download_file_async}, + paths::get_plugins_dir, + util::file_utils::mark_as_executable, +}; use anyhow::bail; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -61,3 +65,29 @@ impl Plugin { && 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;2] = [ + "https://gitlab.com/gabmus/envision-plugin-manifests/-/raw/main/com.github.galiser.wlx-overlay-s.json", + "https://gitlab.com/gabmus/envision-plugin-manifests/-/raw/main/org.stardustxr.telescope.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 index 4f3471c..58ddc26 100644 --- a/src/ui/plugins/store.rs +++ b/src/ui/plugins/store.rs @@ -2,6 +2,7 @@ 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, @@ -44,7 +45,10 @@ pub enum PluginStoreSignalSource { #[derive(Debug)] pub enum PluginStoreMsg { Present, + /// sets state and calls DoRefresh Refresh, + /// called by Refresh + DoRefresh, Install(Plugin, relm4::Sender), InstallFromDetails(Plugin), InstallDownload(Plugin, relm4::Sender), @@ -158,6 +162,8 @@ impl AsyncComponent for PluginStore { 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())"] @@ -248,37 +254,25 @@ impl AsyncComponent for PluginStore { } Self::Input::Refresh => { self.set_refreshing(true); - // TODO: populate from web - let mut plugins = vec![ - Plugin { - appid: "com.github.galiser.wlx-overlay-s".into(), - name: "WLX Overlay S".into(), - version: Some("0.6.0".into()), - hompage_url: Some("https://github.com/galister/wlx-overlay-s".into()), - icon_url: Some("https://github.com/galister/wlx-overlay-s/raw/main/wlx-overlay-s.svg".into()), - screenshots: vec![ - "https://github.com/galister/wlx-overlay-s/raw/guide/wlx-s.png".into(), - ], - description: Some("A lightweight OpenXR/OpenVR overlay for Wayland and X11 desktops, inspired by XSOverlay.\n\nWlxOverlay-S allows you to access your desktop screens while in VR.\n\nIn comparison to similar overlays, WlxOverlay-S aims to run alongside VR games and experiences while having as little performance impact as possible. The UI appearance and rendering techniques are kept as simple and efficient as possible, while still allowing a high degree of customizability.".into()), - short_description: Some("Access your Wayland/X11 desktop".into()), - exec_url: Some("https://github.com/galister/wlx-overlay-s/releases/download/v0.6/WlxOverlay-S-v0.6-x86_64.AppImage".into()), - exec_path: None, - }, - Plugin { - appid: "org.stardustxr.telescope".into(), - name: "Telescope (Stardust XR)".into(), - version: Some("0.45.0".into()), - hompage_url: Some("https://stardustxr.org/".into()), - icon_url: Some("https://stardustxr.org/img/icon.png".into()), - screenshots: vec![ - "https://stardustxr.org/img/carousel/workflow.png".into(), - ], - description: Some("A more intuitive overlay designed for spatial computing from the ground up. Part of the Stardust XR project.\n\nTelescope lets you use your favorite apps directly with your controllers or hands at any time.".into()), - short_description: Some("Use your existing apps spatially and intuitively.".into()), - exec_url: Some("https://github.com/StardustXR/telescope/releases/download/0.45.0/Telescope-x86_64.AppImage".into()), - exec_path: None, - }, - ]; + 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() From cfb874fa35cb4702de6f1bd60d9fb183e8e28a1d Mon Sep 17 00:00:00 2001 From: BabbleBones Date: Thu, 2 Jan 2025 15:19:43 -0500 Subject: [PATCH 043/103] fix: correct wording of lighthouse calibration --- src/ui/main_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/main_view.rs b/src/ui/main_view.rs index 34391c1..c81a5a9 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -453,7 +453,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.", + "will need to run SteamVR Quick Calibration.", ), add_css_class: "warning", set_xalign: 0.0, From a651b87cc38ab9a208c9272b4f6e5021a1fbfa66 Mon Sep 17 00:00:00 2001 From: galister <3123227-galister@users.noreply.gitlab.com> Date: Thu, 2 Jan 2025 20:27:33 +0000 Subject: [PATCH 044/103] feat: switch wlx manifest --- src/ui/plugins/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index 8d82a44..db7136f 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -69,7 +69,7 @@ impl Plugin { /// 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;2] = [ - "https://gitlab.com/gabmus/envision-plugin-manifests/-/raw/main/com.github.galiser.wlx-overlay-s.json", + "https://github.com/galister/wlx-overlay-s/raw/refs/heads/meta/com.github.galiser.wlx-overlay-s.json", "https://gitlab.com/gabmus/envision-plugin-manifests/-/raw/main/org.stardustxr.telescope.json", ]; From 8ffb44aa11eb46b8b0614ed4391d1fc0d8c14753 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 4 Jan 2025 10:57:06 +0100 Subject: [PATCH 045/103] fix: use exists() to verify existance of socket file --- src/ui/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 14c0787..cd7d63d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -210,7 +210,7 @@ impl App { self.xr_devices = vec![]; { let ipc_file = prof.xrservice_type.ipc_file_path(); - if ipc_file.is_file() { + if ipc_file.exists() { remove_file(ipc_file) .unwrap_or_else(|e| error!("failed to remove xrservice IPC file: {e}")); }; From 1c3b4decb50593268c16cf8b47397fb3a7a87c9d Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 4 Jan 2025 19:39:42 +0100 Subject: [PATCH 046/103] feat: add xrizer as an option for openvr compatibility module --- src/builders/build_xrizer.rs | 50 +++++++++++++++++++++++++ src/builders/mod.rs | 1 + src/file_builders/openvrpaths_vrpath.rs | 9 ++++- src/profile.rs | 6 ++- src/ui/app.rs | 5 ++- 5 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/builders/build_xrizer.rs 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..1f66748 100644 --- a/src/builders/mod.rs +++ b/src/builders/mod.rs @@ -5,3 +5,4 @@ pub mod build_monado; pub mod build_opencomposite; pub mod build_openhmd; pub mod build_wivrn; +pub mod build_xrizer; diff --git a/src/file_builders/openvrpaths_vrpath.rs b/src/file_builders/openvrpaths_vrpath.rs index 773902c..842acf8 100644 --- a/src/file_builders/openvrpaths_vrpath.rs +++ b/src/file_builders/openvrpaths_vrpath.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use crate::{ paths::get_backup_dir, - profile::Profile, + profile::{OvrCompatibilityModuleType, Profile}, util::file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly}, xdg::XDG, }; @@ -98,7 +98,12 @@ pub fn build_profile_openvrpaths(profile: &Profile) -> OpenVrPaths { external_drivers: None, jsonid: "vrpathreg".into(), log: vec![datadir.join("Steam/logs")], - runtime: vec![profile.ovr_comp.path.join("build")], + runtime: match profile.ovr_comp.mod_type { + OvrCompatibilityModuleType::Opencomposite => vec![profile.ovr_comp.path.join("build")], + OvrCompatibilityModuleType::Xrizer => { + vec![profile.ovr_comp.path.join("target/release")] + } + }, version: 1, } } diff --git a/src/profile.rs b/src/profile.rs index 4a637fe..89177ac 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -264,19 +264,21 @@ impl Display for LighthouseDriver { pub enum OvrCompatibilityModuleType { #[default] Opencomposite, + Xrizer, } 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", }) } } impl OvrCompatibilityModuleType { pub fn iter() -> Iter<'static, Self> { - [Self::Opencomposite].iter() + [Self::Opencomposite, Self::Xrizer].iter() } } @@ -286,6 +288,7 @@ impl FromStr for OvrCompatibilityModuleType { fn from_str(s: &str) -> Result { match s.to_lowercase().trim() { "opencomposite" => Ok(Self::Opencomposite), + "xrizer" => Ok(Self::Xrizer), _ => Err(format!("no match for ovr compatibility module `{s}`")), } } @@ -295,6 +298,7 @@ impl From for OvrCompatibilityModuleType { fn from(value: u32) -> Self { match value { 0 => Self::Opencomposite, + 1 => Self::Xrizer, _ => panic!("OvrCompatibilityModuleType index out of bounds"), } } diff --git a/src/ui/app.rs b/src/ui/app.rs index cd7d63d..77edeeb 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -20,7 +20,7 @@ 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_wivrn::get_build_wivrn_jobs, build_xrizer::get_build_xrizer_jobs, }, config::{Config, PluginConfig}, constants::APP_NAME, @@ -518,6 +518,9 @@ impl AsyncComponent for App { OvrCompatibilityModuleType::Opencomposite => { get_build_opencomposite_jobs(&profile, clean_build) } + OvrCompatibilityModuleType::Xrizer => { + get_build_xrizer_jobs(&profile, clean_build) + } }); let missing_deps = profile.missing_dependencies(); if !(self.skip_depcheck || profile.skip_dependency_check || missing_deps.is_empty()) From e62d0ced36ad2d681a899fdd9caceca031e90f89 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Mon, 6 Jan 2025 16:51:26 +0100 Subject: [PATCH 047/103] fix: get ovr compatibility module runtime dir from profile ovr compatibility module struct --- src/file_builders/openvrpaths_vrpath.rs | 12 +++--------- src/profile.rs | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/file_builders/openvrpaths_vrpath.rs b/src/file_builders/openvrpaths_vrpath.rs index 842acf8..7bd431b 100644 --- a/src/file_builders/openvrpaths_vrpath.rs +++ b/src/file_builders/openvrpaths_vrpath.rs @@ -1,12 +1,11 @@ -use std::path::{Path, PathBuf}; - use crate::{ paths::get_backup_dir, - profile::{OvrCompatibilityModuleType, Profile}, + profile::Profile, util::file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly}, xdg::XDG, }; use serde::{ser::Error, Deserialize, Serialize}; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OpenVrPaths { @@ -98,12 +97,7 @@ pub fn build_profile_openvrpaths(profile: &Profile) -> OpenVrPaths { external_drivers: None, jsonid: "vrpathreg".into(), log: vec![datadir.join("Steam/logs")], - runtime: match profile.ovr_comp.mod_type { - OvrCompatibilityModuleType::Opencomposite => vec![profile.ovr_comp.path.join("build")], - OvrCompatibilityModuleType::Xrizer => { - vec![profile.ovr_comp.path.join("target/release")] - } - }, + runtime: vec![profile.ovr_comp.runtime_dir()], version: 1, } } diff --git a/src/profile.rs b/src/profile.rs index 89177ac..ad6b170 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -313,7 +313,7 @@ pub struct ProfileOvrCompatibilityModule { } impl ProfileOvrCompatibilityModule { - fn default_for_uuid(uuid: &str) -> Self { + pub fn default_for_uuid(uuid: &str) -> Self { let mod_type = OvrCompatibilityModuleType::default(); Self { mod_type, @@ -322,6 +322,15 @@ impl ProfileOvrCompatibilityModule { 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::Xrizer => self.path.join("target/release"), + } + } } impl Default for ProfileOvrCompatibilityModule { @@ -453,8 +462,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!( From 869927bb5c43946418125b402334da1c4c57ba6f Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Tue, 7 Jan 2025 07:06:46 +0100 Subject: [PATCH 048/103] fix: canonicalize some steamvr related paths to hopefully resolve symlinks --- src/paths.rs | 2 +- src/util/steam_library_folder.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/paths.rs b/src/paths.rs index 7761758..cc2d0eb 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -98,7 +98,7 @@ fn get_steamvr_base_dir() -> anyhow::Result { } pub fn get_steamvr_bin_dir_path() -> anyhow::Result { - let res = get_steamvr_base_dir()?.join("bin/linux64"); + let res = get_steamvr_base_dir()?.join("bin/linux64").canonicalize()?; if !res.is_dir() { bail!("SteamVR bin dir `{}` does not exist", res.to_string_lossy()); } diff --git a/src/util/steam_library_folder.rs b/src/util/steam_library_folder.rs index 253ed12..75af1e2 100644 --- a/src/util/steam_library_folder.rs +++ b/src/util/steam_library_folder.rs @@ -14,11 +14,9 @@ pub struct SteamLibraryFolder { } fn get_steam_main_dir_path() -> anyhow::Result { - let steam_root: PathBuf = get_home_dir().join(".steam/root"); + let steam_root: PathBuf = get_home_dir().join(".steam/root").canonicalize()?; - if steam_root.is_symlink() { - Ok(steam_root.read_link()?) - } else if steam_root.is_dir() { + if steam_root.is_dir() { Ok(steam_root) } else { bail!( @@ -30,7 +28,9 @@ fn get_steam_main_dir_path() -> anyhow::Result { impl SteamLibraryFolder { pub fn get_folders() -> anyhow::Result> { - let libraryfolders_path = get_steam_main_dir_path()?.join("steamapps/libraryfolders.vdf"); + 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 {}", From 96e1a20eda1afc34eb3f3cf97bcff3692ef0c0ea Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Tue, 7 Jan 2025 07:38:11 +0100 Subject: [PATCH 049/103] feat: write rolling logs to file --- Cargo.lock | 47 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 ++- src/main.rs | 26 +++++++++++++++++++------- src/paths.rs | 4 ++++ 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 989abbb..a11284f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -577,6 +586,7 @@ dependencies = [ "sha2", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", "tracker", "uuid", @@ -2781,10 +2791,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]] @@ -2793,6 +2805,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" @@ -2905,6 +2927,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" @@ -2937,6 +2971,16 @@ dependencies = [ "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" @@ -2947,12 +2991,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 512e892..2e10d37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,5 +40,6 @@ 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"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] } tracing = "0.1.41" +tracing-appender = "0.2.3" diff --git a/src/main.rs b/src/main.rs index 41494cf..bb72425 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use file_builders::{ 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::*}, @@ -13,7 +14,9 @@ use relm4::{ use std::env; use steam_linux_runtime_injector::restore_runtime_entrypoint; use tracing::warn; -use tracing_subscriber::{filter::LevelFilter, EnvFilter}; +use tracing_subscriber::{ + filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, +}; use ui::{ app::{App, AppInit, Msg}, cmdline_opts::CmdLineOpts, @@ -69,13 +72,22 @@ fn main() -> Result<()> { } restore_steam_xr_files(); - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy(), + 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), ) - .pretty() .init(); // Prepare i18n diff --git a/src/paths.rs b/src/paths.rs index cc2d0eb..769c33c 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -56,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() { From 1a71c82d1a57e5e920e216267071888cc05a8ace Mon Sep 17 00:00:00 2001 From: Sapphire Date: Tue, 7 Jan 2025 21:24:44 -0600 Subject: [PATCH 050/103] fix: actually return steamvr dir in get_steamvr_base_dir --- src/paths.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/paths.rs b/src/paths.rs index 769c33c..fed075b 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -95,7 +95,7 @@ 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)) + .map(|(_, lf)| PathBuf::from(lf.path).join("steamapps/common/SteamVR")) .ok_or(anyhow::Error::msg( "Could not find SteamVR in Steam libraryfolders.vdf", )) From 5187a0097183522c4c9a995800b7ba2f9f68821b Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 8 Jan 2025 07:25:02 +0100 Subject: [PATCH 051/103] fix: remove canonicalize from get steamvr bin dir path function --- src/paths.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/paths.rs b/src/paths.rs index fed075b..6384380 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -102,7 +102,7 @@ fn get_steamvr_base_dir() -> anyhow::Result { } pub fn get_steamvr_bin_dir_path() -> anyhow::Result { - let res = get_steamvr_base_dir()?.join("bin/linux64").canonicalize()?; + let res = get_steamvr_base_dir()?.join("bin/linux64"); if !res.is_dir() { bail!("SteamVR bin dir `{}` does not exist", res.to_string_lossy()); } From b24c8e4c0b725aa7b3916609461bca831cfbfa1b Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 8 Jan 2025 07:40:05 +0100 Subject: [PATCH 052/103] fix: typo in plugin schema --- src/ui/plugins/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index db7136f..91d55ca 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -20,7 +20,7 @@ pub struct Plugin { pub version: Option, pub short_description: Option, pub description: Option, - pub hompage_url: Option, + pub homepage_url: Option, pub screenshots: Vec, /// either one of exec_url or exec_path must be provided pub exec_url: Option, From 879637115c4c248e79031da64b916cd748525086 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 8 Jan 2025 07:50:44 +0100 Subject: [PATCH 053/103] feat: homepage and author in plugin details --- src/ui/plugins/mod.rs | 1 + src/ui/plugins/store_detail.rs | 70 ++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index 91d55ca..c900fd6 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -16,6 +16,7 @@ use std::path::PathBuf; pub struct Plugin { pub appid: String, pub name: String, + pub author: Option, pub icon_url: Option, pub version: Option, pub short_description: Option, diff --git a/src/ui/plugins/store_detail.rs b/src/ui/plugins/store_detail.rs index 5a1b12c..4bd9d9f 100644 --- a/src/ui/plugins/store_detail.rs +++ b/src/ui/plugins/store_detail.rs @@ -2,7 +2,7 @@ use super::Plugin; use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG}; use adw::prelude::*; use relm4::prelude::*; -use tracing::warn; +use tracing::{error, warn}; #[tracker::track] pub struct StoreDetail { @@ -25,6 +25,7 @@ pub enum StoreDetailMsg { Install, Remove, SetEnabled(bool), + OpenHomepage, } #[derive(Debug)] @@ -91,18 +92,42 @@ impl AsyncComponent for StoreDetail { set_margin_end: 12, set_pixel_size: 96, }, - gtk::Label { - add_css_class: "title-2", + gtk::Box { + set_orientation: gtk::Orientation::Vertical, set_hexpand: true, - 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, + 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, @@ -161,6 +186,15 @@ impl AsyncComponent for StoreDetail { 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); + } + } } } }, @@ -211,6 +245,18 @@ impl AsyncComponent for StoreDetail { 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); From 18e5670d90c1277f09705d4c04585078ebff1bf6 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 12 Jan 2025 10:05:51 +0100 Subject: [PATCH 054/103] feat: launch options for plugins --- src/ui/app.rs | 14 ++++++++++---- src/ui/plugins/mod.rs | 2 ++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 77edeeb..acaac50 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -256,10 +256,16 @@ impl App { ); None } else { - Some(format!( - "'{}'", - cp.plugin.executable().unwrap().to_string_lossy() - )) + Some(format!("'{}'", { + let mut cmd_parts = vec![cp + .plugin + .executable() + .unwrap() + .to_string_lossy() + .to_string()]; + cmd_parts.extend(cp.plugin.luanch_opts.clone().unwrap_or_default()); + cmd_parts.join(" ") + })) } } else { None diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index c900fd6..ec8a9e3 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -27,6 +27,8 @@ pub struct Plugin { 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 luanch_opts: Option>, } impl Plugin { From eda210556620586636c61146cadcc3dfb0ebe1d1 Mon Sep 17 00:00:00 2001 From: Etch9 Date: Fri, 17 Jan 2025 22:56:26 +0000 Subject: [PATCH 055/103] fix: use correct wayland-protocols package name for open suse --- src/depcheck/monado_deps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index fce59f7..93055d3 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -39,7 +39,7 @@ fn monado_deps() -> Vec { (LinuxDistro::Debian, "wayland-protocols".into()), (LinuxDistro::Fedora, "wayland-protocols".into()), (LinuxDistro::Gentoo, "dev-libs/wayland-protocols".into()), - (LinuxDistro::Suse, "wayland-protocols".into()), + (LinuxDistro::Suse, "wayland-protocols-devel".into()), ]), }, Dependency { From 160d733054d3a67e051da790f96f89850c8a2ccd Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 19 Jan 2025 23:13:05 +0100 Subject: [PATCH 056/103] feat: support for plugin dependencies and wayvr dashboards (using unreleased api) --- Cargo.lock | 26 ++++ Cargo.toml | 1 + src/file_builders/mod.rs | 1 + src/file_builders/wayvr_dashboard_config.rs | 13 ++ src/ui/app.rs | 4 +- src/ui/plugins/mod.rs | 100 ++++++++++++++- src/ui/plugins/store.rs | 127 +++++++++++++++----- 7 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 src/file_builders/wayvr_dashboard_config.rs diff --git a/Cargo.lock b/Cargo.lock index a11284f..01a7fcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -583,6 +583,7 @@ dependencies = [ "rusb", "serde", "serde_json", + "serde_yml", "sha2", "tokio", "tracing", @@ -1635,6 +1636,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "libz-sys" version = "1.1.20" @@ -2555,6 +2566,21 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/Cargo.toml b/Cargo.toml index 2e10d37..1f5a0fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,3 +43,4 @@ 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_yml = "0.0.12" 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/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/ui/app.rs b/src/ui/app.rs index acaac50..c3226c9 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -248,7 +248,7 @@ impl App { .plugins .values() .filter_map(|cp| { - if cp.enabled && cp.plugin.validate() { + if cp.plugin.plugin_type.launches_directly() && cp.enabled && cp.plugin.validate() { if let Err(e) = cp.plugin.mark_as_executable() { error!( "failed to mark plugin {} as executable: {e}", @@ -263,7 +263,7 @@ impl App { .unwrap() .to_string_lossy() .to_string()]; - cmd_parts.extend(cp.plugin.luanch_opts.clone().unwrap_or_default()); + cmd_parts.extend(cp.plugin.args.clone().unwrap_or_default()); cmd_parts.join(" ") })) } diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index ec8a9e3..a992ac9 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -4,13 +4,36 @@ 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::mark_as_executable, + util::file_utils::{get_writer, mark_as_executable}, + xdg::XDG, }; use anyhow::bail; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +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 { @@ -28,10 +51,79 @@ pub struct Plugin { /// 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 luanch_opts: Option>, + 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_yml::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() @@ -72,7 +164,7 @@ impl Plugin { /// 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;2] = [ - "https://github.com/galister/wlx-overlay-s/raw/refs/heads/meta/com.github.galiser.wlx-overlay-s.json", + "https://github.com/galister/wlx-overlay-s/raw/refs/heads/meta/com.github.galister.wlx-overlay-s.json", "https://gitlab.com/gabmus/envision-plugin-manifests/-/raw/main/org.stardustxr.telescope.json", ]; diff --git a/src/ui/plugins/store.rs b/src/ui/plugins/store.rs index 58ddc26..e8fca91 100644 --- a/src/ui/plugins/store.rs +++ b/src/ui/plugins/store.rs @@ -51,7 +51,7 @@ pub enum PluginStoreMsg { DoRefresh, Install(Plugin, relm4::Sender), InstallFromDetails(Plugin), - InstallDownload(Plugin, relm4::Sender), + InstallDownload(Vec, relm4::Sender), Remove(Plugin), SetEnabled(PluginStoreSignalSource, Plugin, bool), ShowDetails(usize), @@ -308,33 +308,70 @@ impl AsyncComponent for PluginStore { } Self::Input::Install(plugin, row_sender) => { self.set_locked(true); - sender.input(Self::Input::InstallDownload(plugin, row_sender)) - } - Self::Input::InstallDownload(plugin, row_sender) => { - 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); - sender.input(Self::Input::AddPluginToConfig(plugin.clone())); - } - } - None => { + 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, row_sender)) + } + Self::Input::InstallDownload(plugins, row_sender) => { + 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; + } + sender.input(Self::Input::AddPluginToConfig(plugin.clone())); + } + } + None => { + alert( "Download failed", Some(&format!( "Downloading {} {} failed:\n\nNo executable url provided for this plugin, this is likely a bug!", @@ -343,15 +380,25 @@ impl AsyncComponent for PluginStore { ), Some(&self.win.as_ref().unwrap().clone().upcast::()) ); - } - }; - row_sender.emit(StoreRowModelMsg::Refresh(true, false)); - self.details - .emit(StoreDetailMsg::Refresh(plugin.appid, true, false)); + } + }; + row_sender.emit(StoreRowModelMsg::Refresh(true, false)); + 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() { @@ -387,6 +434,22 @@ impl AsyncComponent for PluginStore { } 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 From 2bec37ee24d404a7b3f27aae499be13d5873b56f Mon Sep 17 00:00:00 2001 From: Nova Date: Mon, 20 Jan 2025 17:07:28 -0800 Subject: [PATCH 057/103] refactor(plugins): point to stardust hosted manifest --- src/ui/plugins/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index a992ac9..c70e813 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -165,7 +165,7 @@ impl Plugin { /// each manifest should be json and the link should always point to the latest version const MANIFESTS: [&str;2] = [ "https://github.com/galister/wlx-overlay-s/raw/refs/heads/meta/com.github.galister.wlx-overlay-s.json", - "https://gitlab.com/gabmus/envision-plugin-manifests/-/raw/main/org.stardustxr.telescope.json", + "https://github.com/StardustXR/telescope/raw/refs/heads/main/envision/org.stardustxr.telescope.json", ]; pub async fn refresh_plugins() -> anyhow::Result>> { From 35d268e01bca592d5209aa3770db1bcb99b29dc3 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 26 Jan 2025 11:03:31 +0100 Subject: [PATCH 058/103] fix: wrap single plugin cmd parts in single quotes --- src/ui/app.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index c3226c9..bcf1c21 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -256,7 +256,7 @@ impl App { ); None } else { - Some(format!("'{}'", { + Some({ let mut cmd_parts = vec![cp .plugin .executable() @@ -264,8 +264,12 @@ impl App { .to_string_lossy() .to_string()]; cmd_parts.extend(cp.plugin.args.clone().unwrap_or_default()); - cmd_parts.join(" ") - })) + cmd_parts + .iter() + .map(|part| format!("'{part}'")) + .collect::>() + .join(" ") + }) } } else { None From 67e2ade501e17fbcad3c67eb141afff187846f25 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 26 Jan 2025 11:22:01 +0100 Subject: [PATCH 059/103] fix: always mark plugin executable as executable --- src/ui/app.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index bcf1c21..98510a3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -248,13 +248,15 @@ impl App { .plugins .values() .filter_map(|cp| { - if cp.plugin.plugin_type.launches_directly() && cp.enabled && cp.plugin.validate() { + 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 From 9bdda7d63d7fd75e2c164366361eaba91bb9eee4 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 26 Jan 2025 11:31:17 +0100 Subject: [PATCH 060/103] fix: refresh all rows on plugin install (fixes dependencies showing up as not installed) --- src/ui/plugins/store.rs | 34 +++++++---------------------- src/ui/plugins/store_row_factory.rs | 4 +--- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/src/ui/plugins/store.rs b/src/ui/plugins/store.rs index e8fca91..73c10a8 100644 --- a/src/ui/plugins/store.rs +++ b/src/ui/plugins/store.rs @@ -49,9 +49,8 @@ pub enum PluginStoreMsg { Refresh, /// called by Refresh DoRefresh, - Install(Plugin, relm4::Sender), - InstallFromDetails(Plugin), - InstallDownload(Vec, relm4::Sender), + Install(Plugin), + InstallDownload(Vec), Remove(Plugin), SetEnabled(PluginStoreSignalSource, Plugin, bool), ShowDetails(usize), @@ -291,22 +290,7 @@ impl AsyncComponent for PluginStore { self.refresh_plugin_rows(); self.set_refreshing(false); } - Self::Input::InstallFromDetails(plugin) => { - 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() - { - sender.input(Self::Input::Install(plugin, row.input_sender.clone())) - } else { - error!("could not find corresponding listbox row") - } - } - Self::Input::Install(plugin, row_sender) => { + Self::Input::Install(plugin) => { self.set_locked(true); let mut plugins = vec![plugin]; for dep in plugins[0].dependencies.clone().unwrap_or_default() { @@ -333,9 +317,9 @@ impl AsyncComponent for PluginStore { return; } } - sender.input(Self::Input::InstallDownload(plugins, row_sender)) + sender.input(Self::Input::InstallDownload(plugins)) } - Self::Input::InstallDownload(plugins, row_sender) => { + Self::Input::InstallDownload(plugins) => { for plugin in plugins { let mut plugin = plugin.clone(); match plugin.exec_url.as_ref() { @@ -382,7 +366,7 @@ impl AsyncComponent for PluginStore { ); } }; - row_sender.emit(StoreRowModelMsg::Refresh(true, false)); + self.refresh_plugin_rows(); self.details .emit(StoreDetailMsg::Refresh(plugin.appid, true, false)); } @@ -534,7 +518,7 @@ impl AsyncComponent for PluginStore { .launch(()) .forward(sender.input_sender(), move |msg| match msg { StoreDetailOutMsg::GoBack => Self::Input::ShowPluginList, - StoreDetailOutMsg::Install(plugin) => Self::Input::InstallFromDetails(plugin), + 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) @@ -554,9 +538,7 @@ impl AsyncComponent for PluginStore { AsyncFactoryVecDeque::builder() .launch(widgets.listbox.clone()) .forward(sender.input_sender(), move |msg| match msg { - StoreRowModelOutMsg::Install(appid, row_sender) => { - Self::Input::Install(appid, row_sender) - } + 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) diff --git a/src/ui/plugins/store_row_factory.rs b/src/ui/plugins/store_row_factory.rs index 3ea2629..72f9e02 100644 --- a/src/ui/plugins/store_row_factory.rs +++ b/src/ui/plugins/store_row_factory.rs @@ -36,7 +36,7 @@ pub enum StoreRowModelMsg { #[derive(Debug)] pub enum StoreRowModelOutMsg { - Install(Plugin, relm4::Sender), + Install(Plugin), Remove(Plugin), SetEnabled(Plugin, bool), } @@ -104,7 +104,6 @@ impl AsyncFactoryComponent for StoreRowModel { sender .output(Self::Output::Install( plugin.clone(), - sender.input_sender().clone() )) .expect(SENDER_IO_ERR_MSG); } @@ -142,7 +141,6 @@ impl AsyncFactoryComponent for StoreRowModel { sender .output(Self::Output::Install( plugin.clone(), - sender.input_sender().clone() )) .expect(SENDER_IO_ERR_MSG); } From 3680e305a9824f851a441bc96fc7654ef849b01a Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 26 Jan 2025 11:41:43 +0100 Subject: [PATCH 061/103] fix: add plugin to config via function instead of signal --- src/ui/plugins/store.rs | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/ui/plugins/store.rs b/src/ui/plugins/store.rs index 73c10a8..0faf99a 100644 --- a/src/ui/plugins/store.rs +++ b/src/ui/plugins/store.rs @@ -56,7 +56,6 @@ pub enum PluginStoreMsg { ShowDetails(usize), ShowPluginList, PresentAddCustomPluginWin, - AddPluginToConfig(Plugin), AddCustomPlugin(Plugin), } @@ -88,6 +87,16 @@ impl PluginStore { }); }); } + + 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)] @@ -226,18 +235,9 @@ impl AsyncComponent for PluginStore { ); return; } - sender.input(Self::Input::AddPluginToConfig(plugin)); + self.add_plugin_to_config(&sender, plugin); sender.input(Self::Input::Refresh); } - Self::Input::AddPluginToConfig(plugin) => { - self.config_plugins - .insert(plugin.appid.clone(), PluginConfig::from(&plugin)); - sender - .output(Self::Output::UpdateConfigPlugins( - self.config_plugins.clone(), - )) - .expect(SENDER_IO_ERR_MSG); - } Self::Input::PresentAddCustomPluginWin => { let add_win = AddCustomPluginWin::builder() .launch(AddCustomPluginWinInit { @@ -351,19 +351,19 @@ impl AsyncComponent for PluginStore { ); return; } - sender.input(Self::Input::AddPluginToConfig(plugin.clone())); + 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::()) - ); + "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(); From 338e71145590ea2fe30950148f9b8fd913ad385e Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 5 Feb 2025 07:59:12 +0100 Subject: [PATCH 062/103] feat: set wivrn launch options in the default profile --- src/profiles/wivrn.rs | 4 ++++ src/ui/job_worker/internal_worker.rs | 5 +---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/profiles/wivrn.rs b/src/profiles/wivrn.rs index a6e4f83..980e1ed 100644 --- a/src/profiles/wivrn.rs +++ b/src/profiles/wivrn.rs @@ -5,6 +5,7 @@ use crate::{ prepare_ld_library_path, Profile, ProfileFeatures, ProfileOvrCompatibilityModule, XRServiceType, }, + ui::job_worker::internal_worker::LAUNCH_OPTS_CMD_PLACEHOLDER, }; use std::collections::HashMap; @@ -28,6 +29,9 @@ pub fn wivrn_profile() -> Profile { 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/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(), From 1ed031a2bf25c81ba3795e42c5b063779bb391bf Mon Sep 17 00:00:00 2001 From: williamvds Date: Sun, 9 Feb 2025 18:19:07 +0000 Subject: [PATCH 063/103] fix: typo in XRT_COMPOSITOR_SCALE_PERCENTAGE XRT_COMPOSITOR_SCALE_PECENTAGE -> XRT_COMPOSITOR_SCALE_PECENTAGE --- src/env_var_descriptions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/env_var_descriptions.rs b/src/env_var_descriptions.rs index 34e646d..ef92101 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." ), ( From 33db18bd62d59a2d22550a7f42e15de8ab5c79d5 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 14 Feb 2025 21:40:59 +0100 Subject: [PATCH 064/103] feat(wivrn): replace pulse dependency with pipewire --- src/depcheck/wivrn_deps.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/depcheck/wivrn_deps.rs b/src/depcheck/wivrn_deps.rs index 0bf45a9..fc704d9 100644 --- a/src/depcheck/wivrn_deps.rs +++ b/src/depcheck/wivrn_deps.rs @@ -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(), From 39ace1d8db1bb38396ae82b8acdf04cc595f8e23 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Fri, 21 Feb 2025 17:27:55 +0100 Subject: [PATCH 065/103] feat: Add WayVR Dashboard to the plugin list --- src/ui/plugins/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index c70e813..df7d894 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -163,8 +163,9 @@ impl Plugin { /// 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;2] = [ +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/olekolek1000/wayvr-dashboard/raw/refs/heads/meta/dev.oo8.wayvr_dashboard.json", "https://github.com/StardustXR/telescope/raw/refs/heads/main/envision/org.stardustxr.telescope.json", ]; From 9c6bfe110a50f7f96cd5204205397a9ccd2548d4 Mon Sep 17 00:00:00 2001 From: Faith Connors Date: Sat, 22 Feb 2025 05:55:00 +0000 Subject: [PATCH 066/103] fix: typo in install wivrn box --- src/ui/install_wivrn_box.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/install_wivrn_box.rs b/src/ui/install_wivrn_box.rs index e8abc36..769e012 100644 --- a/src/ui/install_wivrn_box.rs +++ b/src/ui/install_wivrn_box.rs @@ -114,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." ), From 96717d193f5e39a829161fc1f2d6c13393eea186 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 1 Mar 2025 17:10:49 -0600 Subject: [PATCH 067/103] fix: onnxruntime build error when latest release has no artifacts --- scripts/build_mercury.sh | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/build_mercury.sh b/scripts/build_mercury.sh index fffd979..b3db66a 100755 --- a/scripts/build_mercury.sh +++ b/scripts/build_mercury.sh @@ -11,7 +11,22 @@ if [[ -z $PREFIX ]] || [[ -z $CACHE_DIR ]]; then exit 1 fi -ONNX_VER=$(curl -sSL "https://api.github.com/repos/microsoft/onnxruntime/releases/latest" | jq -r .tag_name | tr -d v) +ONNX_RELEASES=$(curl -sSL "https://api.github.com/repos/microsoft/onnxruntime/releases") +NUM_RELEASES=$(echo "$ONNX_RELEASES" | jq -r '[ select (.[]!=null) ] | length') + +for (( IDX=0; IDX Date: Sun, 2 Mar 2025 10:11:16 -0700 Subject: [PATCH 068/103] Use serde_yaml - Switch back to serde_yaml due to concerns #199 --- Cargo.lock | 28 +++++++++++----------------- Cargo.toml | 2 +- src/ui/plugins/mod.rs | 2 +- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01a7fcb..72b8573 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -583,7 +583,7 @@ dependencies = [ "rusb", "serde", "serde_json", - "serde_yml", + "serde_yaml", "sha2", "tokio", "tracing", @@ -1636,16 +1636,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libyml" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" -dependencies = [ - "anyhow", - "version_check", -] - [[package]] name = "libz-sys" version = "1.1.20" @@ -2567,18 +2557,16 @@ dependencies = [ ] [[package]] -name = "serde_yml" -version = "0.0.12" +name = "serde_yaml" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ "indexmap", "itoa", - "libyml", - "memchr", "ryu", "serde", - "version_check", + "unsafe-libyaml", ] [[package]] @@ -3089,6 +3077,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" diff --git a/Cargo.toml b/Cargo.toml index 1f5a0fa..70878ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,4 +43,4 @@ 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_yml = "0.0.12" +serde_yaml = "0.9.34" diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index df7d894..8d2d792 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -98,7 +98,7 @@ impl Plugin { }; let writer = get_writer(&wayvr_conf_dir.join(self.wayvr_config_fragment_filename()))?; - serde_yml::to_writer(writer, &config_fragment)?; + serde_yaml::to_writer(writer, &config_fragment)?; } } Ok(()) From 92d17512a40a9d9b4ad0546adf07b9bd3916f9ae Mon Sep 17 00:00:00 2001 From: Bones Date: Sat, 1 Mar 2025 19:26:56 -0500 Subject: [PATCH 069/103] chore: update version to 3.0.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/org.gabmus.envision.metainfo.xml.in.in | 61 +++++++++++++++++++++ meson.build | 2 +- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72b8573..312a7aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,7 +563,7 @@ dependencies = [ [[package]] name = "envision" -version = "2.0.1" +version = "3.0.0" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index 70878ae..f41c7f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "envision" -version = "2.0.1" +version = "3.0.0" edition = "2021" authors = [ "Gabriele Musco ", diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 1e40a8f..cbe2b3a 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -30,6 +30,67 @@ @REPO_URL@/issues + + +

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

diff --git a/meson.build b/meson.build index 4d58596..8a4176b 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'envision', 'rust', - version: '2.0.1', # version number row + version: '3.0.0', # version number row meson_version: '>= 0.59', license: 'AGPL-3.0-or-later', ) From 1ac253ecbfd605439b0117608014daad98fc393a Mon Sep 17 00:00:00 2001 From: Etch9 Date: Tue, 25 Feb 2025 18:02:34 +0000 Subject: [PATCH 070/103] fix: libnotify headers path in wivrn depcheck --- src/depcheck/wivrn_deps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/depcheck/wivrn_deps.rs b/src/depcheck/wivrn_deps.rs index fc704d9..acf9a35 100644 --- a/src/depcheck/wivrn_deps.rs +++ b/src/depcheck/wivrn_deps.rs @@ -253,7 +253,7 @@ fn wivrn_deps() -> Vec { Dependency { name: "libnotify-dev".into(), dep_type: DepType::Include, - filename: "openssl/ssl3.h".into(), + filename: "libnotify/notify.h".into(), packages: HashMap::from([ (LinuxDistro::Arch, "libnotify".into()), (LinuxDistro::Alpine, "libnotify-dev".into()), From e117986715e1e9ef955009ad7f03ec110aa14940 Mon Sep 17 00:00:00 2001 From: Bones Date: Sun, 2 Mar 2025 13:51:58 -0500 Subject: [PATCH 071/103] chore: update version to 3.0.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/org.gabmus.envision.metainfo.xml.in.in | 8 ++++++++ meson.build | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 312a7aa..bc56ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,7 +563,7 @@ dependencies = [ [[package]] name = "envision" -version = "3.0.0" +version = "3.0.1" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index f41c7f6..575b302 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "envision" -version = "3.0.0" +version = "3.0.1" edition = "2021" authors = [ "Gabriele Musco ", diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index cbe2b3a..8925605 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -30,6 +30,14 @@ @REPO_URL@/issues + + +

Fixes

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

Breaking changes

diff --git a/meson.build b/meson.build index 8a4176b..7e406b2 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'envision', 'rust', - version: '3.0.0', # version number row + version: '3.0.1', # version number row meson_version: '>= 0.59', license: 'AGPL-3.0-or-later', ) From db45103d1bc23d56692571d652f56f8866dc956d Mon Sep 17 00:00:00 2001 From: hypevhs Date: Thu, 13 Mar 2025 11:30:35 -0500 Subject: [PATCH 072/103] fix(monado dependencies): use wayland-protocols-devel on Fedora --- src/depcheck/monado_deps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index 93055d3..8c555fc 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -37,7 +37,7 @@ fn monado_deps() -> Vec { packages: HashMap::from([ (LinuxDistro::Arch, "wayland-protocols".into()), (LinuxDistro::Debian, "wayland-protocols".into()), - (LinuxDistro::Fedora, "wayland-protocols".into()), + (LinuxDistro::Fedora, "wayland-protocols-devel".into()), (LinuxDistro::Gentoo, "dev-libs/wayland-protocols".into()), (LinuxDistro::Suse, "wayland-protocols-devel".into()), ]), From f38199601ed7d88fbc3ce74c2403960e1f71ee76 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 6 Apr 2025 12:33:26 +0200 Subject: [PATCH 073/103] feat: remove monado vulkan layers check for nvidia fixes #208 --- dist/arch/PKGBUILD | 1 - src/ui/about_dialog.rs | 6 ------ src/ui/app.rs | 1 - src/ui/main_view.rs | 33 --------------------------------- src/vulkaninfo.rs | 41 +++++++++-------------------------------- 5 files changed, 9 insertions(+), 73 deletions(-) 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/src/ui/about_dialog.rs b/src/ui/about_dialog.rs index 8f4235e..795d546 100644 --- a/src/ui/about_dialog.rs +++ b/src/ui/about_dialog.rs @@ -81,12 +81,6 @@ pub fn populate_debug_info(dialog: &adw::AboutDialog, vkinfo: Option<&VulkanInfo .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()) - ), format!("Detected XR Devices: {}", { let devs = PhysicalXRDevice::from_usb(); if devs.is_empty() { diff --git a/src/ui/app.rs b/src/ui/app.rs index 98510a3..5df4dba 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -998,7 +998,6 @@ 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, diff --git a/src/ui/main_view.rs b/src/ui/main_view.rs index c81a5a9..ce5f7a6 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -25,7 +25,6 @@ use crate::{ file_utils::{get_writer, mount_has_nosuid}, steamvr_utils::chaperone_info_exists, }, - vulkaninfo::VulkanInfo, wivrn_dbus, xr_devices::XRDevice, }; @@ -75,8 +74,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, @@ -126,7 +123,6 @@ pub struct MainViewInit { pub config: Config, pub selected_profile: Profile, pub root_win: gtk::Window, - pub vkinfo: Option, } impl MainView { @@ -461,34 +457,6 @@ impl AsyncComponent for MainView { 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." - ), - 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, @@ -1103,7 +1071,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/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 }) } } From 2fc33b10b0050f3b836b234bd5674f7192c215d6 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 6 Apr 2025 12:58:54 +0200 Subject: [PATCH 074/103] feat: add support for vapor openvr compatibility module --- src/builders/build_vapor.rs | 85 +++++++++++++++++++++++++++++++++++++ src/builders/mod.rs | 1 + src/profile.rs | 12 ++++-- src/ui/app.rs | 6 ++- 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/builders/build_vapor.rs diff --git a/src/builders/build_vapor.rs b/src/builders/build_vapor.rs new file mode 100644 index 0000000..10206fe --- /dev/null +++ b/src/builders/build_vapor.rs @@ -0,0 +1,85 @@ +use crate::{ + build_tools::{cmake::Cmake, git::Git}, + profile::Profile, + termcolor::TermColor, + ui::job_worker::job::{FuncWorkerData, FuncWorkerOut, WorkerJob}, + util::file_utils::{copy_file, rm_rf}, +}; +use std::{ + collections::{HashMap, VecDeque}, + fs::create_dir_all, + 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 cmake = Cmake { + env: None, + vars: Some({ + let mut cmake_vars: HashMap = HashMap::new(); + for (k, v) in [ + ("VAPOR_LOG_SILENT=ON", "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 { + rm_rf(&build_dir); + jobs.push_back(cmake.get_prepare_job()); + } + jobs.push_back(cmake.get_build_job()); + jobs.push_back(WorkerJob::Func(FuncWorkerData { + func: Box::new(move || { + let dest_dir = build_dir.join("bin/linux64"); + if let Err(e) = create_dir_all(&dest_dir) { + return FuncWorkerOut { + success: false, + out: vec![format!( + "failed to create dir {}: {e}", + dest_dir.to_string_lossy() + )], + }; + } + copy_file( + &build_dir.join("src/vrclient.so"), + &dest_dir.join("vrclient.so"), + ); + + FuncWorkerOut { + success: true, + out: Vec::default(), + } + }), + })); + + jobs +} diff --git a/src/builders/mod.rs b/src/builders/mod.rs index 1f66748..9d57245 100644 --- a/src/builders/mod.rs +++ b/src/builders/mod.rs @@ -4,5 +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/profile.rs b/src/profile.rs index ad6b170..7433de9 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -265,6 +265,7 @@ pub enum OvrCompatibilityModuleType { #[default] Opencomposite, Xrizer, + Vapor, } impl Display for OvrCompatibilityModuleType { @@ -272,13 +273,14 @@ impl Display for OvrCompatibilityModuleType { 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].iter() + [Self::Opencomposite, Self::Xrizer, Self::Vapor].iter() } } @@ -289,6 +291,7 @@ impl FromStr for OvrCompatibilityModuleType { 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}`")), } } @@ -299,7 +302,8 @@ impl From for OvrCompatibilityModuleType { match value { 0 => Self::Opencomposite, 1 => Self::Xrizer, - _ => panic!("OvrCompatibilityModuleType index out of bounds"), + 2 => Self::Vapor, + _ => panic!("OvrCompatibilityModuleType index out of bounds"), } } } @@ -327,7 +331,9 @@ impl ProfileOvrCompatibilityModule { /// 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::Opencomposite | OvrCompatibilityModuleType::Vapor => { + self.path.join("build") + } OvrCompatibilityModuleType::Xrizer => self.path.join("target/release"), } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 5df4dba..9910d2d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -20,7 +20,8 @@ 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_xrizer::get_build_xrizer_jobs, + build_vapor::get_build_vapor_jobs, build_wivrn::get_build_wivrn_jobs, + build_xrizer::get_build_xrizer_jobs, }, config::{Config, PluginConfig}, constants::APP_NAME, @@ -533,6 +534,9 @@ impl AsyncComponent for App { 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()) From 25c90d175f83e4a4439b4c58e2ce687843ae3c1b Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sun, 6 Apr 2025 13:30:30 +0200 Subject: [PATCH 075/103] chore: clippy --- src/linux_distro.rs | 6 +++--- src/ui/about_dialog.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/linux_distro.rs b/src/linux_distro.rs index 7bdfe0b..14cb0d0 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('"') diff --git a/src/ui/about_dialog.rs b/src/ui/about_dialog.rs index 795d546..0e28534 100644 --- a/src/ui/about_dialog.rs +++ b/src/ui/about_dialog.rs @@ -33,7 +33,7 @@ pub fn create_about_dialog() -> adw::AboutDialog { 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(); @@ -70,7 +70,7 @@ pub fn populate_debug_info(dialog: &adw::AboutDialog, vkinfo: Option<&VulkanInfo .and_then(|s| { s.split("\n") .find(|line| line.starts_with("model name")) - .map(|line| line.split(':').last().map(|s| s.trim().to_string())) + .map(|line| line.split(':').next_back().map(|s| s.trim().to_string())) }) .flatten() .unwrap_or(UNKNOWN.into()) From 8742a27b7c61c4e3f4fbf7f45188868a9a7dae73 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Mon, 7 Apr 2025 08:12:08 +0200 Subject: [PATCH 076/103] feat: small design changes to build window ui --- src/ui/build_window.rs | 57 +++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/ui/build_window.rs b/src/ui/build_window.rs index 759af9b..7b0ac69 100644 --- a/src/ui/build_window.rs +++ b/src/ui/build_window.rs @@ -88,43 +88,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) } }.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); + } + }, + // ^^^ + }, } } } From 2f5ec57a0a95bdf889094d459a4a3fcb4de2dd97 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Mon, 7 Apr 2025 08:00:35 +0200 Subject: [PATCH 077/103] feat: don't set openvrpaths as read only during profile startup --- src/file_builders/openvrpaths_vrpath.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/file_builders/openvrpaths_vrpath.rs b/src/file_builders/openvrpaths_vrpath.rs index 7bd431b..1b888c2 100644 --- a/src/file_builders/openvrpaths_vrpath.rs +++ b/src/file_builders/openvrpaths_vrpath.rs @@ -85,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(()) @@ -104,18 +105,17 @@ pub fn build_profile_openvrpaths(profile: &Profile) -> OpenVrPaths { 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() { From 7a02fcc5d109d2d06bb115f93811424bd88343aa Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Tue, 8 Apr 2025 15:38:20 +0200 Subject: [PATCH 078/103] fix: disable and blacklist wayvr dashboard plugin --- src/ui/app.rs | 4 ++++ src/ui/plugins/mod.rs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 9910d2d..7354cdc 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -249,6 +249,10 @@ impl App { .plugins .values() .filter_map(|cp| { + // disable potentially unsafe wayvr_dashboard + if cp.plugin.appid.contains("wayvr_dashboard") { + return None; + } if cp.enabled && cp.plugin.validate() { if let Err(e) = cp.plugin.mark_as_executable() { error!( diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index 8d2d792..7dcabd1 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -163,10 +163,10 @@ impl Plugin { /// 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] = [ +const MANIFESTS: [&str;2] = [ "https://github.com/galister/wlx-overlay-s/raw/refs/heads/meta/com.github.galister.wlx-overlay-s.json", - "https://github.com/olekolek1000/wayvr-dashboard/raw/refs/heads/meta/dev.oo8.wayvr_dashboard.json", "https://github.com/StardustXR/telescope/raw/refs/heads/main/envision/org.stardustxr.telescope.json", + // wayvr dashboard potentially unsafe ]; pub async fn refresh_plugins() -> anyhow::Result>> { From 139f72e2941c4e91091acef6c86712fec566425b Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Tue, 8 Apr 2025 15:49:55 +0200 Subject: [PATCH 079/103] chore: update version to 3.1.0 --- Cargo.toml | 2 +- data/org.gabmus.envision.metainfo.xml.in.in | 20 ++++++++++++++++++++ meson.build | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 575b302..600ce85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "envision" -version = "3.0.1" +version = "3.1.0" edition = "2021" authors = [ "Gabriele Musco ", diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 8925605..2584ddb 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -30,6 +30,26 @@ @REPO_URL@/issues + + +

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

diff --git a/meson.build b/meson.build index 7e406b2..25b7ecc 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'envision', 'rust', - version: '3.0.1', # version number row + version: '3.1.0', # version number row meson_version: '>= 0.59', license: 'AGPL-3.0-or-later', ) From 9ea754bb2e1b94dac333c423c28234ccfcfc5a08 Mon Sep 17 00:00:00 2001 From: Aleksander Date: Sun, 13 Apr 2025 19:59:15 +0200 Subject: [PATCH 080/103] fix: Revert "disable and blacklist wayvr dashboard plugin" --- src/ui/app.rs | 4 ---- src/ui/plugins/mod.rs | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 7354cdc..9910d2d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -249,10 +249,6 @@ impl App { .plugins .values() .filter_map(|cp| { - // disable potentially unsafe wayvr_dashboard - if cp.plugin.appid.contains("wayvr_dashboard") { - return None; - } if cp.enabled && cp.plugin.validate() { if let Err(e) = cp.plugin.mark_as_executable() { error!( diff --git a/src/ui/plugins/mod.rs b/src/ui/plugins/mod.rs index 7dcabd1..cb18440 100644 --- a/src/ui/plugins/mod.rs +++ b/src/ui/plugins/mod.rs @@ -163,10 +163,10 @@ impl Plugin { /// 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;2] = [ +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", - // wayvr dashboard potentially unsafe + "https://github.com/olekolek1000/wayvr-dashboard/raw/refs/heads/meta/dev.oo8.wayvr_dashboard.json", ]; pub async fn refresh_plugins() -> anyhow::Result>> { From e4d3980b1496df67ff456413bba48dcebc15ce9c Mon Sep 17 00:00:00 2001 From: Sapphire Date: Wed, 9 Apr 2025 23:42:24 -0500 Subject: [PATCH 081/103] fix: add libusb and libusb-dev deps --- src/depcheck/monado_deps.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index 8c555fc..5d98ebf 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -87,6 +87,30 @@ fn monado_deps() -> Vec { ]), }, 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".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, From 71a8223ce8b95fa6f37e6aa465044a5665056ef3 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Tue, 22 Apr 2025 08:14:23 +0200 Subject: [PATCH 082/103] chore: update version to 3.1.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/org.gabmus.envision.metainfo.xml.in.in | 9 +++++++++ meson.build | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc56ea6..74138cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,7 +563,7 @@ dependencies = [ [[package]] name = "envision" -version = "3.0.1" +version = "3.1.1" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index 600ce85..62cf1dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "envision" -version = "3.1.0" +version = "3.1.1" edition = "2021" authors = [ "Gabriele Musco ", diff --git a/data/org.gabmus.envision.metainfo.xml.in.in b/data/org.gabmus.envision.metainfo.xml.in.in index 2584ddb..76b4dd4 100644 --- a/data/org.gabmus.envision.metainfo.xml.in.in +++ b/data/org.gabmus.envision.metainfo.xml.in.in @@ -30,6 +30,15 @@ @REPO_URL@/issues + + +

Fixes

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

What's new

diff --git a/meson.build b/meson.build index 25b7ecc..d58f4ed 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'envision', 'rust', - version: '3.1.0', # version number row + version: '3.1.1', # version number row meson_version: '>= 0.59', license: 'AGPL-3.0-or-later', ) From 3e23073f4cb025e69d64b2be05c61d7091f3e41b Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Tue, 22 Apr 2025 09:01:00 +0200 Subject: [PATCH 083/103] feat: remove old logs on startup, keep a max of 1GB and 3 files --- src/main.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index bb72425..217434e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,9 +11,14 @@ use relm4::{ gtk::{self, gdk, gio, glib, prelude::*}, MessageBroker, RelmApp, }; -use std::env; +use std::{ + env, + fs::{read_dir, remove_file}, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, +}; use steam_linux_runtime_injector::restore_runtime_entrypoint; -use tracing::warn; +use tracing::{error, warn}; use tracing_subscriber::{ filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, }; @@ -66,11 +71,55 @@ fn restore_steam_xr_files() { restore_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<()> { if env::var("USER").unwrap_or_else(|_| env::var("USERNAME").unwrap_or_default()) == "root" { 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) = @@ -90,6 +139,10 @@ fn main() -> Result<()> { ) .init(); + if let Err(e) = old_logs_removal_res { + error!("Failed to remove old log files: {e}"); + } + // Prepare i18n gettextrs::setlocale(LocaleCategory::LcAll, ""); gettextrs::bindtextdomain(GETTEXT_PACKAGE, LOCALE_DIR).expect("Unable to bind the text domain"); From 9d85f1c24f5c6234e1672c96ea19b2dc990001e8 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Tue, 22 Apr 2025 09:04:13 +0200 Subject: [PATCH 084/103] fix: remove 2 thread limit when building basalt --- src/builders/build_basalt.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/builders/build_basalt.rs b/src/builders/build_basalt.rs index 10d6029..0d8130f 100644 --- a/src/builders/build_basalt.rs +++ b/src/builders/build_basalt.rs @@ -40,7 +40,6 @@ pub fn get_build_basalt_jobs(profile: &Profile, clean_build: bool) -> VecDeque = HashMap::new(); for (k, v) in [ - ("CMAKE_BUILD_PARALLEL_LEVEL", "2"), ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), ("BUILD_TESTS", "off"), ] { From c794037377bbddc014ce4a58b3662038fc6b056f Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Tue, 29 Apr 2025 14:22:27 +0200 Subject: [PATCH 085/103] feat: checkbox to delete profile dirs along with profile --- src/profile.rs | 25 ++++++++++++++++++++++++- src/ui/app.rs | 14 +++++++++++--- src/ui/main_view.rs | 23 ++++++++++++++++++++--- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/profile.rs b/src/profile.rs index 7433de9..a3344df 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, fmt::Display, - fs::File, + fs::{remove_dir_all, File}, io::BufReader, path::{Path, PathBuf}, slice::Iter, @@ -450,6 +450,29 @@ impl Profile { 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\"", diff --git a/src/ui/app.rs b/src/ui/app.rs index 9910d2d..c4f3b10 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -113,7 +113,8 @@ pub enum Msg { StartWithDebug, RestartXRService, ProfileSelected(Profile), - DeleteProfile, + /// bool param: delete files + DeleteProfile(bool), SaveProfile(Profile), RunSetCap, OpenLibsurviveSetup, @@ -658,9 +659,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(); @@ -1007,7 +1015,7 @@ impl AsyncComponent for App { 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), diff --git a/src/ui/main_view.rs b/src/ui/main_view.rs index ce5f7a6..09cd408 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -112,7 +112,8 @@ pub enum MainViewOutMsg { DoStartStopXRService, RestartXRService, ProfileSelected(Profile), - DeleteProfile, + /// bool param: delete files + DeleteProfile(bool), SaveProfile(Profile), OpenLibsurviveSetup, /// params: clean @@ -931,6 +932,13 @@ impl AsyncComponent for MainView { 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"); @@ -942,10 +950,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"); } } From 8ffac63e7e4a540cb9afaf54e15cafc613a6001e Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 2 May 2025 09:18:54 +0200 Subject: [PATCH 086/103] fix: account for opi packages for opensuse --- src/linux_distro.rs | 62 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/linux_distro.rs b/src/linux_distro.rs index 14cb0d0..d08c71b 100644 --- a/src/linux_distro.rs +++ b/src/linux_distro.rs @@ -150,7 +150,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 +216,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 +229,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" + ) + } } From 743dbfa3a17c79493cd2df1c36187a560ec9c3d6 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 2 May 2025 09:35:35 +0200 Subject: [PATCH 087/103] feat: account for getcap/setcap being found in /sbin and not in $PATH --- src/depcheck/common.rs | 16 ++++++++++++ src/util/file_utils.rs | 59 ++++++++++++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/depcheck/common.rs b/src/depcheck/common.rs index 56ac4ef..dcae582 100644 --- a/src/depcheck/common.rs +++ b/src/depcheck/common.rs @@ -303,3 +303,19 @@ 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()), + ]), + } +} diff --git a/src/util/file_utils.rs b/src/util/file_utils.rs index ff56926..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, @@ -79,9 +79,29 @@ pub fn set_file_readonly(path: &Path, readonly: bool) -> anyhow::Result<()> { 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 @@ -93,24 +113,29 @@ pub fn setcap_cap_sys_nice_eip_cmd(profile: &Profile) -> Vec { pub async fn verify_cap_sys_nice_eip(profile: &Profile) -> bool { let xrservice_binary = profile.xrservice_binary().to_string_lossy().to_string(); - match async_process("getcap", 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 - ); + 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 - } else { - out.stdout.to_lowercase().contains("cap_sys_nice=eip") + } + 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 } } From e0eae7c13a1fa699642f1361277f2ae1fc84abdf Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Thu, 1 May 2025 01:18:46 +0200 Subject: [PATCH 088/103] feat: theme manager --- Cargo.lock | 23 ++++++++++++++------ Cargo.toml | 1 + src/config.rs | 7 +++++++ src/ui/app.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++- src/ui/main_view.rs | 2 ++ 5 files changed, 77 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74138cb..cb2d639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -445,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" @@ -567,6 +577,7 @@ version = "3.1.1" dependencies = [ "anyhow", "ash", + "delicious-adwaita", "gettext-rs", "git2", "gtk4", @@ -1073,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", @@ -1106,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", @@ -1523,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", diff --git a/Cargo.toml b/Cargo.toml index 62cf1dc..4ecd109 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,3 +44,4 @@ 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/src/config.rs b/src/config.rs index 18cd864..80cf0f5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,6 +45,10 @@ 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, @@ -54,6 +58,8 @@ pub struct Config { pub win_size: [i32; 2], #[serde(default)] pub plugins: HashMap, + #[serde(default = "default_theme_name")] + pub theme_name: String, } impl Default for Config { @@ -65,6 +71,7 @@ impl Default for Config { user_profiles: Vec::default(), win_size: DEFAULT_WIN_SIZE, plugins: HashMap::default(), + theme_name: default_theme_name(), } } } diff --git a/src/ui/app.rs b/src/ui/app.rs index c4f3b10..4cc6520 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -51,6 +51,7 @@ use crate::{ xr_devices::XRDevice, }; use adw::{prelude::*, ResponseAppearance}; +use delicious_adwaita::{theme::Theme, ThemeEngine}; use gtk::glib::{self, clone}; use notify_rust::NotificationHandle; use relm4::{ @@ -96,6 +97,8 @@ pub struct App { inhibit_fail_notif: Option, pluginstore: Option>, + + theme_engine: ThemeEngine, } #[derive(Debug)] @@ -130,6 +133,8 @@ pub enum Msg { WivrnCheckPairMode, OpenPluginStore, UpdateConfigPlugins(HashMap), + ShowThemeManager, + SaveThemeConfig, NoOp, } @@ -367,7 +372,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())); @@ -391,6 +396,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 @@ -974,6 +1000,17 @@ impl AsyncComponent for App { } ) ); + 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 = { @@ -1042,6 +1079,17 @@ 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, @@ -1130,6 +1178,7 @@ 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/main_view.rs b/src/ui/main_view.rs index 09cd408..8a2c145 100644 --- a/src/ui/main_view.rs +++ b/src/ui/main_view.rs @@ -21,6 +21,7 @@ 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, @@ -159,6 +160,7 @@ impl AsyncComponent for MainView { "Configure _WiVRn" => ConfigureWivrnAction, }, section! { + "Change _Theme" => ThemeManagerAction, "_About" => AboutAction, }, }, From 99af59056d2f33baea201474bde28de4f9b0674b Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 2 May 2025 11:14:11 +0200 Subject: [PATCH 089/103] fix: opensuse dep SDL2-devel is now sdl2-compat-devel --- src/depcheck/monado_deps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index 5d98ebf..e962b89 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -83,7 +83,7 @@ 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(), From d0df943e483f1c768eb35ad60f06a7854e1b3a91 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Fri, 2 May 2025 11:16:49 +0200 Subject: [PATCH 090/103] fix: opensuse dep libusb-1_0 is now libusb-1_0-0 --- src/depcheck/monado_deps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index e962b89..b4894da 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -96,7 +96,7 @@ fn monado_deps() -> Vec { (LinuxDistro::Debian, "libusb-1.0-0".into()), (LinuxDistro::Fedora, "libusb1".into()), (LinuxDistro::Gentoo, "dev-libs/libusb".into()), - (LinuxDistro::Suse, "libusb-1_0".into()), + (LinuxDistro::Suse, "libusb-1_0-0".into()), ]), }, Dependency { From 4709a50483b44a3fe1638da209eab9d09e3184ea Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 5 May 2025 00:12:42 -0500 Subject: [PATCH 091/103] fix: xrizer deps --- src/depcheck/mod.rs | 1 + src/depcheck/xrizer_deps.rs | 57 +++++++++++++++++++++++++++++++++++++ src/profile.rs | 14 +++++++-- 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/depcheck/xrizer_deps.rs diff --git a/src/depcheck/mod.rs b/src/depcheck/mod.rs index 002a8c8..e46b1e7 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}; diff --git a/src/depcheck/xrizer_deps.rs b/src/depcheck/xrizer_deps.rs new file mode 100644 index 0000000..ec48b4b --- /dev/null +++ b/src/depcheck/xrizer_deps.rs @@ -0,0 +1,57 @@ +use super::{DepType, Dependency, DependencyCheckResult}; +use crate::linux_distro::LinuxDistro; +use std::collections::HashMap; + +fn xrizer_deps() -> Vec { + vec![ + 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, "glslc".into()), + (LinuxDistro::Gentoo, "dev-util/glslang".into()), + (LinuxDistro::Suse, "shaderc".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()), + ]), + }, + ] +} + +pub fn check_xrizer_deps() -> Vec { + Dependency::check_many(xrizer_deps()) +} + +pub fn get_missing_xrizer_deps() -> Vec { + check_xrizer_deps() + .iter() + .filter(|res| !res.found) + .map(|res| res.dependency.clone()) + .collect() +} diff --git a/src/profile.rs b/src/profile.rs index a3344df..a8cb283 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -2,7 +2,8 @@ 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}, @@ -282,6 +283,15 @@ 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 { @@ -737,7 +747,7 @@ 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 From 27d37198c7876cb4d91d305414200c61e5677ac9 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 10 May 2025 10:35:19 +0200 Subject: [PATCH 092/103] fix: refactor and optimize missing dependency filtering --- src/depcheck/basalt_deps.rs | 8 ++------ src/depcheck/libsurvive_deps.rs | 8 ++------ src/depcheck/mercury_deps.rs | 10 ++++------ src/depcheck/mod.rs | 18 ++++++++++++++++++ src/depcheck/monado_deps.rs | 8 ++------ src/depcheck/openhmd_deps.rs | 8 ++------ src/depcheck/wivrn_deps.rs | 8 ++------ src/depcheck/xrizer_deps.rs | 8 ++------ 8 files changed, 34 insertions(+), 42 deletions(-) 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/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..a7e59be 100644 --- a/src/depcheck/mercury_deps.rs +++ b/src/depcheck/mercury_deps.rs @@ -1,4 +1,6 @@ -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; @@ -39,9 +41,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 e46b1e7..92154bb 100644 --- a/src/depcheck/mod.rs +++ b/src/depcheck/mod.rs @@ -109,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(), diff --git a/src/depcheck/monado_deps.rs b/src/depcheck/monado_deps.rs index b4894da..e1cd9f9 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -4,7 +4,7 @@ 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_libxrandr, linux_distro::LinuxDistro}; use std::collections::HashMap; @@ -131,9 +131,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 acf9a35..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}, @@ -270,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 index ec48b4b..83134c1 100644 --- a/src/depcheck/xrizer_deps.rs +++ b/src/depcheck/xrizer_deps.rs @@ -1,4 +1,4 @@ -use super::{DepType, Dependency, DependencyCheckResult}; +use super::{DepType, DepcheckResultGetMissing, Dependency, DependencyCheckResult}; use crate::linux_distro::LinuxDistro; use std::collections::HashMap; @@ -49,9 +49,5 @@ pub fn check_xrizer_deps() -> Vec { } pub fn get_missing_xrizer_deps() -> Vec { - check_xrizer_deps() - .iter() - .filter(|res| !res.found) - .map(|res| res.dependency.clone()) - .collect() + check_xrizer_deps().filter_missing_deps() } From fc4a2d3993d4cfd363537114b491b5b3da937e3a Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 10 May 2025 10:39:53 +0200 Subject: [PATCH 093/103] fix: deduplicate glslc dependency --- src/depcheck/common.rs | 16 ++++++++++++++++ src/depcheck/monado_deps.rs | 19 +++++-------------- src/depcheck/xrizer_deps.rs | 16 ++-------------- 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/depcheck/common.rs b/src/depcheck/common.rs index dcae582..901d0ff 100644 --- a/src/depcheck/common.rs +++ b/src/depcheck/common.rs @@ -319,3 +319,19 @@ pub fn dep_getcap_setcap() -> Dependency { ]), } } + +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/monado_deps.rs b/src/depcheck/monado_deps.rs index e1cd9f9..f9e1b9b 100644 --- a/src/depcheck/monado_deps.rs +++ b/src/depcheck/monado_deps.rs @@ -6,7 +6,10 @@ use super::{ }, DepType, DepcheckResultGetMissing, Dependency, DependencyCheckResult, }; -use crate::{depcheck::common::dep_libxrandr, linux_distro::LinuxDistro}; +use crate::{ + depcheck::common::{dep_glslc, dep_libxrandr}, + linux_distro::LinuxDistro, +}; use std::collections::HashMap; fn monado_deps() -> Vec { @@ -60,19 +63,7 @@ fn monado_deps() -> Vec { 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(), diff --git a/src/depcheck/xrizer_deps.rs b/src/depcheck/xrizer_deps.rs index 83134c1..a43bd20 100644 --- a/src/depcheck/xrizer_deps.rs +++ b/src/depcheck/xrizer_deps.rs @@ -1,22 +1,10 @@ use super::{DepType, DepcheckResultGetMissing, Dependency, DependencyCheckResult}; -use crate::linux_distro::LinuxDistro; +use crate::{depcheck::common::dep_glslc, linux_distro::LinuxDistro}; use std::collections::HashMap; fn xrizer_deps() -> Vec { vec![ - 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, "glslc".into()), - (LinuxDistro::Gentoo, "dev-util/glslang".into()), - (LinuxDistro::Suse, "shaderc".into()), - ]), - }, + dep_glslc(), Dependency { name: "libxcb-glx".into(), dep_type: DepType::Include, From 93ea2501b441f6f2a1ce42579d36a52784ffee6a Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 10 May 2025 09:27:17 -0500 Subject: [PATCH 094/103] fix: add gentoo library paths for libclang to search paths --- src/depcheck/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/depcheck/mod.rs b/src/depcheck/mod.rs index 92154bb..6e484d2 100644 --- a/src/depcheck/mod.rs +++ b/src/depcheck/mod.rs @@ -136,6 +136,13 @@ fn shared_obj_paths() -> Vec { "/usr/local/lib64".into(), "/usr/lib/x86_64-linux-gnu".into(), "/usr/lib/aarch64-linux-gnu".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(), From b174fab6bf85eeb14af2ad3f0e7427f1b9fb4b4c Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 14 May 2025 07:25:26 +0200 Subject: [PATCH 095/103] fix: add wayland-dev to xrizer dependencies --- src/depcheck/mod.rs | 2 ++ src/depcheck/xrizer_deps.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/depcheck/mod.rs b/src/depcheck/mod.rs index 6e484d2..3364490 100644 --- a/src/depcheck/mod.rs +++ b/src/depcheck/mod.rs @@ -166,6 +166,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(), ] } diff --git a/src/depcheck/xrizer_deps.rs b/src/depcheck/xrizer_deps.rs index a43bd20..b433357 100644 --- a/src/depcheck/xrizer_deps.rs +++ b/src/depcheck/xrizer_deps.rs @@ -29,6 +29,18 @@ fn xrizer_deps() -> Vec { (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()), + ]), + }, ] } From d42de840a2d1f2bb973278c45a873db75b773e25 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sun, 18 May 2025 15:35:48 -0500 Subject: [PATCH 096/103] fix: add more libclang library paths --- src/depcheck/mod.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/depcheck/mod.rs b/src/depcheck/mod.rs index 3364490..f2565eb 100644 --- a/src/depcheck/mod.rs +++ b/src/depcheck/mod.rs @@ -136,6 +136,17 @@ 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(), From eed85abb2a3c14a78c386b7df4e20af3e806c865 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Wed, 4 Jun 2025 02:45:31 -0500 Subject: [PATCH 097/103] fix: detect cachyos as arch --- src/linux_distro.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/linux_distro.rs b/src/linux_distro.rs index d08c71b..2fcd76a 100644 --- a/src/linux_distro.rs +++ b/src/linux_distro.rs @@ -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); From 5139ed7ba339664527fce4a5b21d95e3cc51be71 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Thu, 29 May 2025 01:17:20 -0500 Subject: [PATCH 098/103] fix(builders/basalt): limit to at most 6 build processes The Basalt build is quite memory hungry, causing people's systems to lock up and trigger the OOM killer during build. --- src/builders/build_basalt.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/builders/build_basalt.rs b/src/builders/build_basalt.rs index 0d8130f..b2a55bd 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(); @@ -40,10 +43,23 @@ pub fn get_build_basalt_jobs(profile: &Profile, clean_build: bool) -> VecDeque = HashMap::new(); for (k, v) in [ - ("CMAKE_BUILD_TYPE", "RelWithDebInfo"), - ("BUILD_TESTS", "off"), + // 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()), + ("BUILD_TESTS", "off".into()), ] { - cmake_env.insert(k.to_string(), v.to_string()); + cmake_env.insert(k.to_string(), v); } cmake_env }), From 754395586e8c690343d9826b437dd0a98a34b154 Mon Sep 17 00:00:00 2001 From: micheal65536 Date: Thu, 12 Jun 2025 07:57:54 +0200 Subject: [PATCH 099/103] fix: properly build and install vapor Co-authored-by: Gabriele Musco --- src/builders/build_vapor.rs | 37 ++++++++++--------------------------- src/profile.rs | 5 ++--- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/src/builders/build_vapor.rs b/src/builders/build_vapor.rs index 10206fe..5a05932 100644 --- a/src/builders/build_vapor.rs +++ b/src/builders/build_vapor.rs @@ -2,12 +2,11 @@ use crate::{ build_tools::{cmake::Cmake, git::Git}, profile::Profile, termcolor::TermColor, - ui::job_worker::job::{FuncWorkerData, FuncWorkerOut, WorkerJob}, - util::file_utils::{copy_file, rm_rf}, + ui::job_worker::job::WorkerJob, + util::file_utils::rm_rf, }; use std::{ collections::{HashMap, VecDeque}, - fs::create_dir_all, path::Path, }; @@ -37,16 +36,22 @@ pub fn get_build_vapor_jobs(profile: &Profile, clean_build: bool) -> VecDeque = HashMap::new(); for (k, v) in [ - ("VAPOR_LOG_SILENT=ON", "ON"), + ("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(), @@ -57,29 +62,7 @@ pub fn get_build_vapor_jobs(profile: &Profile, clean_build: bool) -> VecDeque PathBuf { match self.mod_type { - OvrCompatibilityModuleType::Opencomposite | OvrCompatibilityModuleType::Vapor => { - self.path.join("build") - } + OvrCompatibilityModuleType::Opencomposite => self.path.join("build"), + OvrCompatibilityModuleType::Vapor => self.path.join("build/install_pfx/lib/VapoR"), OvrCompatibilityModuleType::Xrizer => self.path.join("target/release"), } } From 1cad5c4d1bdd83cdc74bdbe6b4797aacdbed31e0 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 14 Jun 2025 17:27:59 +0200 Subject: [PATCH 100/103] fix: add cargo as xrizer dependency fixes #218 --- src/depcheck/xrizer_deps.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/depcheck/xrizer_deps.rs b/src/depcheck/xrizer_deps.rs index b433357..9d3591f 100644 --- a/src/depcheck/xrizer_deps.rs +++ b/src/depcheck/xrizer_deps.rs @@ -5,6 +5,18 @@ 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, From bd0cc9e2b174033b3736eddf3253c858712c3083 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 14 Jun 2025 17:28:39 +0200 Subject: [PATCH 101/103] chore: format --- src/depcheck/xrizer_deps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/depcheck/xrizer_deps.rs b/src/depcheck/xrizer_deps.rs index 9d3591f..49d9354 100644 --- a/src/depcheck/xrizer_deps.rs +++ b/src/depcheck/xrizer_deps.rs @@ -15,7 +15,7 @@ fn xrizer_deps() -> Vec { (LinuxDistro::Fedora, "cargo".into()), (LinuxDistro::Alpine, "cargo".into()), (LinuxDistro::Suse, "cargo".into()), - ]) + ]), }, Dependency { name: "libxcb-glx".into(), From 8f3f9b8759eecca4768f4860cbf6dc878337fb82 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 14 Jun 2025 17:53:05 +0200 Subject: [PATCH 102/103] feat: add some messages related to setcap output to build window; add build completed message to build window --- src/ui/app.rs | 24 ++++++++++++++++++++++++ src/ui/build_window.rs | 16 ++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 4cc6520..6201648 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -43,6 +43,7 @@ use crate::{ steam_linux_runtime_injector::{ restore_runtime_entrypoint, set_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, }, @@ -653,6 +654,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!( @@ -734,6 +739,7 @@ impl AsyncComponent for App { } Msg::RunSetCap => { if !dep_pkexec().check() { + // 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(); @@ -751,8 +757,26 @@ impl AsyncComponent for App { 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") + ])); } } } diff --git a/src/ui/build_window.rs b/src/ui/build_window.rs index 7b0ac69..f6f8b21 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::*; @@ -164,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 { From 1a1d1682fe4978dca98c2811c5c8cb67114991b1 Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Sat, 14 Jun 2025 17:19:38 +0200 Subject: [PATCH 103/103] fix: specify cmake policy version in basalt build --- src/builders/build_basalt.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/builders/build_basalt.rs b/src/builders/build_basalt.rs index b2a55bd..291fdc0 100644 --- a/src/builders/build_basalt.rs +++ b/src/builders/build_basalt.rs @@ -57,6 +57,7 @@ pub fn get_build_basalt_jobs(profile: &Profile, clean_build: bool) -> VecDeque