mirror of
https://gitlab.com/gabmus/envision.git
synced 2025-04-19 19:14:53 +00:00
Initial work on Flatpak support
This commit is contained in:
parent
af5c57f0f8
commit
2cd30019d1
10 changed files with 249 additions and 71 deletions
96
dist/flatpak/org.gabmus.envision.json
vendored
Normal file
96
dist/flatpak/org.gabmus.envision.json
vendored
Normal file
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"id": "org.gabmus.envision",
|
||||
"branch": "47",
|
||||
"runtime": "org.gnome.Sdk",
|
||||
"runtime-version": "47",
|
||||
"sdk": "org.gnome.Sdk",
|
||||
"sdk-extensions": [
|
||||
"org.freedesktop.Sdk.Extension.rust-stable",
|
||||
"org.freedesktop.Sdk.Extension.llvm19"
|
||||
],
|
||||
"command": "envision",
|
||||
"build-options": {
|
||||
"append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm19/bin",
|
||||
"env": {
|
||||
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang",
|
||||
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold",
|
||||
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "clang",
|
||||
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold"
|
||||
},
|
||||
"build-args": [
|
||||
"--share=network"
|
||||
]
|
||||
},
|
||||
"cleanup": [
|
||||
"/share/doc",
|
||||
"/share/man"
|
||||
],
|
||||
"finish-args": [
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=pulseaudio",
|
||||
"--device=all",
|
||||
"--filesystem=xdg-run/pipewire-0",
|
||||
"--filesystem=xdg-run/monado_comp_ipc",
|
||||
"--filesystem=~/.steam",
|
||||
"--filesystem=~/.var/app/com.valvesoftware.Steam",
|
||||
"--talk-name=org.freedesktop.Flatpak"
|
||||
],
|
||||
"modules": [
|
||||
{
|
||||
"name": "OpenXR-SDK",
|
||||
"buildsystem": "cmake",
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://github.com/KhronosGroup/OpenXR-SDK-Source.git",
|
||||
"tag": "release-1.1.42"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vte",
|
||||
"buildsystem": "meson",
|
||||
"config-opts": [
|
||||
"-Dgtk4=true",
|
||||
"-Dgtk3=false"
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://gitlab.gnome.org/GNOME/vte/-/archive/0.78.0/vte-0.78.0.tar.gz",
|
||||
"sha256": "82e19d11780fed4b66400f000829ce5ca113efbbfb7975815f26ed93e4c05f2d"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "eigen",
|
||||
"buildsystem": "cmake",
|
||||
"builddir": true,
|
||||
"build-options": {
|
||||
"config-opts": [
|
||||
"-DEIGEN_BUILD_CMAKE_PACKAGE=NO"
|
||||
]
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://gitlab.com/libeigen/eigen.git",
|
||||
"tag": "3.4.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Envision",
|
||||
"buildsystem": "meson",
|
||||
"sources": [
|
||||
{
|
||||
"type": "dir",
|
||||
"path": "../../"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -125,6 +125,8 @@ fn include_paths() -> Vec<String> {
|
|||
vec![
|
||||
"/usr/include".into(),
|
||||
"/usr/local/include".into(),
|
||||
// flatpak applications use /app
|
||||
"/app/include".into(),
|
||||
// fedora puts avcodec here
|
||||
"/usr/include/ffmpeg".into(),
|
||||
"/usr/include/x86_64-linux-gnu".into(),
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
use crate::{
|
||||
paths::{get_backup_dir, SYSTEM_PREFIX},
|
||||
profile::Profile,
|
||||
util::file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly},
|
||||
xdg::XDG,
|
||||
is_flatpak::IS_FLATPAK, paths::{get_backup_dir, get_home_dir, SYSTEM_PREFIX}, profile::Profile, util::file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly}, xdg::XDG
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
|
@ -138,17 +135,28 @@ fn relativize_active_runtime_lib_path(ar: &ActiveRuntime, path: &Path) -> Active
|
|||
}
|
||||
|
||||
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);
|
||||
let mut dests = vec![
|
||||
get_active_runtime_json_path(),
|
||||
get_home_dir().join(".var/app/com.valvesoftware.Steam/config/openxr/1/active_runtime.json"),
|
||||
];
|
||||
if *IS_FLATPAK {
|
||||
dests.push(get_home_dir().join(".config/openxr/1/active_runtime.json"));
|
||||
}
|
||||
backup_steam_active_runtime();
|
||||
let pfx: PathBuf = profile.clone().prefix;
|
||||
for dest in dests {
|
||||
if !dest.is_file() {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
}
|
||||
set_file_readonly(&dest, false)?;
|
||||
dump_active_runtime_to_path(&ar, &dest)?;
|
||||
set_file_readonly(&dest, true)?;
|
||||
}
|
||||
dump_current_active_runtime(&ar)?;
|
||||
set_file_readonly(&dest, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{
|
||||
paths::get_backup_dir,
|
||||
profile::Profile,
|
||||
util::file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly},
|
||||
xdg::XDG,
|
||||
is_flatpak::IS_FLATPAK, paths::{get_home_dir, get_backup_dir}, profile::Profile, util::file_utils::{copy_file, deserialize_file, get_writer, set_file_readonly}, xdg::XDG
|
||||
};
|
||||
use serde::{ser::Error, Deserialize, Serialize};
|
||||
|
||||
|
@ -23,9 +20,19 @@ pub fn get_openvr_conf_dir() -> PathBuf {
|
|||
}
|
||||
|
||||
fn get_openvrpaths_vrpath_path() -> PathBuf {
|
||||
if *IS_FLATPAK {
|
||||
return get_home_dir().join(".config/openvr/openvrpaths.vrpath")
|
||||
}
|
||||
get_openvr_conf_dir().join("openvrpaths.vrpath")
|
||||
}
|
||||
|
||||
fn get_openvrpaths_vrpaths() -> Vec<PathBuf> {
|
||||
vec![
|
||||
get_openvrpaths_vrpath_path(),
|
||||
get_home_dir().join(".var/app/com.valvesoftware.Steam/config/openvr/openvrpaths.vrpath"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn is_steam(ovr_paths: &OpenVrPaths) -> bool {
|
||||
ovr_paths.runtime.iter().any(|rt| {
|
||||
rt.to_string_lossy()
|
||||
|
@ -104,11 +111,15 @@ 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();
|
||||
set_file_readonly(&dest, false)?;
|
||||
backup_steam_openvrpaths();
|
||||
dump_current_openvrpaths(&build_profile_openvrpaths(profile))?;
|
||||
set_file_readonly(&dest, true)?;
|
||||
for dest in get_openvrpaths_vrpaths() {
|
||||
if !dest.is_file() {
|
||||
continue;
|
||||
}
|
||||
set_file_readonly(&dest, false)?;
|
||||
dump_openvrpaths_to_path(&build_profile_openvrpaths(profile), &dest)?;
|
||||
set_file_readonly(&dest, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
6
src/is_flatpak.rs
Normal file
6
src/is_flatpak.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
use lazy_static::lazy_static;
|
||||
use std::path::Path;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref IS_FLATPAK: bool = Path::new("/.flatpak-info").is_file();
|
||||
}
|
|
@ -30,6 +30,7 @@ pub mod env_var_descriptions;
|
|||
pub mod file_builders;
|
||||
pub mod gpu_profile;
|
||||
pub mod is_appimage;
|
||||
pub mod is_flatpak;
|
||||
pub mod linux_distro;
|
||||
pub mod log_parser;
|
||||
pub mod openxr_prober;
|
||||
|
|
|
@ -20,34 +20,21 @@ use crate::{
|
|||
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,
|
||||
},
|
||||
config::Config,
|
||||
constants::APP_NAME,
|
||||
depcheck::{
|
||||
}, config::Config, constants::APP_NAME, depcheck::{
|
||||
basalt_deps::get_missing_basalt_deps, common::dep_pkexec,
|
||||
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,
|
||||
},
|
||||
file_builders::{
|
||||
}, file_builders::{
|
||||
active_runtime_json::{
|
||||
set_current_active_runtime_to_profile, set_current_active_runtime_to_steam,
|
||||
},
|
||||
openvrpaths_vrpath::{
|
||||
set_current_openvrpaths_to_profile, set_current_openvrpaths_to_steam,
|
||||
},
|
||||
},
|
||||
linux_distro::LinuxDistro,
|
||||
openxr_prober::is_openxr_ready,
|
||||
paths::get_data_dir,
|
||||
profile::{Profile, XRServiceType},
|
||||
stateless_action,
|
||||
steam_linux_runtime_injector::{
|
||||
}, is_flatpak::IS_FLATPAK, linux_distro::LinuxDistro, openxr_prober::is_openxr_ready, paths::get_data_dir, profile::{Profile, XRServiceType}, stateless_action, 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},
|
||||
vulkaninfo::VulkanInfo,
|
||||
xr_devices::XRDevice,
|
||||
}, util::file_utils::{setcap_cap_sys_nice_eip, setcap_cap_sys_nice_eip_cmd}, vulkaninfo::VulkanInfo, xr_devices::XRDevice
|
||||
};
|
||||
use adw::{prelude::*, ResponseAppearance};
|
||||
use gtk::glib::{self, clone};
|
||||
|
@ -56,7 +43,7 @@ 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, process::Command, time::Duration};
|
||||
|
||||
pub struct App {
|
||||
application: adw::Application,
|
||||
|
@ -175,9 +162,20 @@ 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"));
|
||||
if *IS_FLATPAK {
|
||||
Command::new("flatpak-spawn")
|
||||
.args(vec![
|
||||
"--host",
|
||||
"rm",
|
||||
prof.xrservice_type.ipc_file_path().to_str().unwrap()
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to remove xrservice IPC file");
|
||||
} else {
|
||||
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(),
|
||||
|
@ -218,11 +216,28 @@ impl App {
|
|||
let prof = self.get_selected_profile();
|
||||
if let Some(autostart_cmd) = &prof.autostart_command {
|
||||
let mut jobs = VecDeque::new();
|
||||
jobs.push_back(WorkerJob::new_cmd(
|
||||
Some(prof.environment.clone()),
|
||||
"sh".into(),
|
||||
Some(vec!["-c".into(), autostart_cmd.clone()]),
|
||||
));
|
||||
|
||||
if *IS_FLATPAK {
|
||||
let mut args = vec![String::from("--host")];
|
||||
for (key, value) in &prof.environment {
|
||||
args.push(format!("--env={}={}", key, value));
|
||||
}
|
||||
args.push(String::from("sh"));
|
||||
args.push(String::from("-c"));
|
||||
args.push(autostart_cmd.clone());
|
||||
|
||||
jobs.push_back(WorkerJob::new_cmd(
|
||||
Some(HashMap::new()),
|
||||
"flatpak-spawn".into(),
|
||||
Some(args),
|
||||
));
|
||||
} else {
|
||||
jobs.push_back(WorkerJob::new_cmd(
|
||||
Some(prof.environment.clone()),
|
||||
"sh".into(),
|
||||
Some(vec!["-c".into(), autostart_cmd.clone()]),
|
||||
));
|
||||
}
|
||||
let autostart_worker = JobWorker::new(jobs, sender.input_sender(), |msg| match msg {
|
||||
JobWorkerOut::Log(rows) => Msg::OnServiceLog(rows),
|
||||
JobWorkerOut::Exit(code) => Msg::OnAutostartExit(code),
|
||||
|
@ -533,7 +548,7 @@ impl AsyncComponent for App {
|
|||
.sender()
|
||||
.emit(BuildWindowMsg::UpdateBuildStatus(BuildStatus::Done));
|
||||
let profile = self.get_selected_profile();
|
||||
if dep_pkexec().check() {
|
||||
if dep_pkexec().check() || *IS_FLATPAK {
|
||||
self.setcap_confirm_dialog.present(Some(&self.app_win));
|
||||
} else {
|
||||
alert_w_widget(
|
||||
|
@ -606,11 +621,11 @@ impl AsyncComponent for App {
|
|||
.emit(DebugViewMsg::UpdateSelectedProfile(prof.clone()));
|
||||
}
|
||||
Msg::RunSetCap => {
|
||||
if !dep_pkexec().check() {
|
||||
println!("pkexec not found, skipping setcap");
|
||||
} else {
|
||||
if dep_pkexec().check() || *IS_FLATPAK {
|
||||
let profile = self.get_selected_profile();
|
||||
setcap_cap_sys_nice_eip(&profile).await;
|
||||
} else {
|
||||
println!("pkexec not found, skipping setcap");
|
||||
}
|
||||
}
|
||||
Msg::ProfileSelected(prof) => {
|
||||
|
@ -698,7 +713,7 @@ impl AsyncComponent for App {
|
|||
let worker = JobWorker::new_with_timer(
|
||||
Duration::from_millis(500),
|
||||
WorkerJob::new_func(Box::new(move || {
|
||||
let ready = is_openxr_ready();
|
||||
let ready = !*IS_FLATPAK && is_openxr_ready();
|
||||
FuncWorkerOut {
|
||||
success: ready,
|
||||
..Default::default()
|
||||
|
|
|
@ -3,19 +3,12 @@ use super::{
|
|||
state::JobWorkerState,
|
||||
};
|
||||
use crate::{
|
||||
profile::{LighthouseDriver, Profile},
|
||||
ui::SENDER_IO_ERR_MSG,
|
||||
is_flatpak::IS_FLATPAK, profile::{LighthouseDriver, Profile}, ui::SENDER_IO_ERR_MSG
|
||||
};
|
||||
use nix::unistd::Pid;
|
||||
use relm4::{prelude::*, Worker};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{BufRead, BufReader},
|
||||
mem,
|
||||
os::unix::process::ExitStatusExt,
|
||||
process::{Command, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
collections::{HashMap, VecDeque}, io::{BufRead, BufReader}, mem, os::unix::process::ExitStatusExt, process::{Command, Stdio}, sync::{Arc, Mutex}, thread
|
||||
};
|
||||
|
||||
macro_rules! logger_thread {
|
||||
|
@ -217,13 +210,23 @@ impl InternalJobWorker {
|
|||
vec![],
|
||||
),
|
||||
};
|
||||
let data = CmdWorkerData {
|
||||
environment: env,
|
||||
command,
|
||||
args,
|
||||
let mut data = CmdWorkerData {
|
||||
environment: env.clone(),
|
||||
command: command.clone(),
|
||||
args: args.clone(),
|
||||
};
|
||||
if *IS_FLATPAK {
|
||||
data.environment = HashMap::new();
|
||||
data.command = String::from("flatpak-spawn");
|
||||
data.args = vec![String::from("--host")];
|
||||
for (key, value) in env {
|
||||
data.args.push(format!("--env={}={}", key, value));
|
||||
}
|
||||
data.args.push(command);
|
||||
data.args.extend(args);
|
||||
}
|
||||
let mut jobs = VecDeque::new();
|
||||
jobs.push_back(WorkerJob::Cmd(data));
|
||||
jobs.push_back(WorkerJob::Cmd(data));
|
||||
Self::builder().detach_worker(JobWorkerInit { jobs, state })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ use crate::{
|
|||
config::Config,
|
||||
depcheck::common::dep_pkexec,
|
||||
gpu_profile::{get_amd_gpu_power_profile, GpuPowerProfile},
|
||||
is_flatpak::IS_FLATPAK,
|
||||
paths::{get_data_dir, get_home_dir},
|
||||
profile::{LighthouseDriver, Profile, XRServiceType},
|
||||
stateless_action,
|
||||
|
@ -270,7 +271,7 @@ impl SimpleComponent for MainView {
|
|||
add_css_class: "card",
|
||||
add_css_class: "padded",
|
||||
#[track = "model.changed(Self::selected_profile())"]
|
||||
set_visible: match mount_has_nosuid(&model.selected_profile.prefix) {
|
||||
set_visible: !*IS_FLATPAK && match mount_has_nosuid(&model.selected_profile.prefix) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
|
@ -302,7 +303,34 @@ impl SimpleComponent for MainView {
|
|||
add_css_class: "card",
|
||||
add_css_class: "padded",
|
||||
#[track = "model.changed(Self::selected_profile())"]
|
||||
set_visible: !dep_pkexec().check(),
|
||||
set_visible: *IS_FLATPAK,
|
||||
warning_heading(),
|
||||
gtk::Label {
|
||||
set_label: concat!(
|
||||
"Envision is currently running as a Flatpak.\n",
|
||||
"If Steam is running as a Flatpak, it will need to be granted certain ",
|
||||
"permissions to run VR applications. Run the following command on your host ",
|
||||
"terminal to grant the Steam Flatpak access to Envision's Monado socket:\n\n",
|
||||
"<tt>flatpak --user override --filesystem=xdg-run/monado_comp_ipc com.valvesoftware.Steam</tt>\n\n",
|
||||
"Run the following to also grant the Steam Flatpak access to Envision's data:\n\n",
|
||||
"<tt>flatpak --user override --filesystem=~/.var/app/org.gabmus.envision com.valvesoftware.Steam</tt>\n\n",
|
||||
),
|
||||
set_use_markup: true,
|
||||
add_css_class: "warning",
|
||||
set_xalign: 0.0,
|
||||
set_wrap: true,
|
||||
set_wrap_mode: gtk::pango::WrapMode::Word,
|
||||
}
|
||||
},
|
||||
gtk::Box {
|
||||
set_orientation: gtk::Orientation::Vertical,
|
||||
set_hexpand: true,
|
||||
set_vexpand: false,
|
||||
set_spacing: 12,
|
||||
add_css_class: "card",
|
||||
add_css_class: "padded",
|
||||
#[track = "model.changed(Self::selected_profile())"]
|
||||
set_visible: !*IS_FLATPAK && !dep_pkexec().check(),
|
||||
warning_heading(),
|
||||
gtk::Label {
|
||||
set_label: &format!(
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::{async_process::async_process, profile::Profile};
|
||||
use crate::{async_process::async_process, is_flatpak::IS_FLATPAK, profile::Profile};
|
||||
use anyhow::bail;
|
||||
use nix::{
|
||||
errno::Errno,
|
||||
|
@ -81,7 +81,15 @@ pub fn setcap_cap_sys_nice_eip_cmd(profile: &Profile) -> Vec<String> {
|
|||
}
|
||||
|
||||
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
|
||||
let setcap_cmd = setcap_cap_sys_nice_eip_cmd(profile);
|
||||
if *IS_FLATPAK {
|
||||
let mut flatpak_cmd = vec!["--host".into(), "pkexec".into()];
|
||||
flatpak_cmd.extend(setcap_cmd);
|
||||
if let Err(e) = async_process("flatpak-spawn", Some(&flatpak_cmd), None).await
|
||||
{
|
||||
eprintln!("Error: failed running setcap: {e}");
|
||||
}
|
||||
} else if let Err(e) = async_process("pkexec", Some(&setcap_cmd), None).await
|
||||
{
|
||||
eprintln!("Error: failed running setcap: {e}");
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue