add rudimentary calling of methods on objects
This commit is contained in:
parent
f9561f2883
commit
780be584e6
147
Cargo.lock
generated
147
Cargo.lock
generated
@ -375,6 +375,21 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@ -898,6 +913,12 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@ -1300,6 +1321,21 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "line-wrap"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9"
|
||||||
|
dependencies = [
|
||||||
|
"safemem",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linked-hash-map"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@ -1576,6 +1612,28 @@ version = "1.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
|
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "onig"
|
||||||
|
version = "6.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"onig_sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "onig_sys"
|
||||||
|
version = "69.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.83"
|
version = "0.9.83"
|
||||||
@ -1704,6 +1762,20 @@ version = "0.3.26"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160"
|
checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plist"
|
||||||
|
version = "1.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"indexmap",
|
||||||
|
"line-wrap",
|
||||||
|
"quick-xml",
|
||||||
|
"serde",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.17.7"
|
version = "0.17.7"
|
||||||
@ -1757,6 +1829,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5c1a97b1bc42b1d550bfb48d4262153fe400a12bab1511821736f7eac76d7e2"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.26"
|
version = "1.0.26"
|
||||||
@ -1882,6 +1963,12 @@ version = "1.0.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
|
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "safemem"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@ -2147,6 +2234,29 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syntect"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6c454c27d9d7d9a84c7803aaa3c50cd088d2906fe3c6e42da3209aa623576a8"
|
||||||
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
|
"bitflags",
|
||||||
|
"flate2",
|
||||||
|
"fnv",
|
||||||
|
"lazy_static",
|
||||||
|
"once_cell",
|
||||||
|
"onig",
|
||||||
|
"plist",
|
||||||
|
"regex-syntax",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"walkdir",
|
||||||
|
"yaml-rust",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@ -2180,6 +2290,33 @@ dependencies = [
|
|||||||
"syn 2.0.10",
|
"syn 2.0.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd80a657e71da814b8e5d60d3374fc6d35045062245d80224748ae522dd76f36"
|
||||||
|
dependencies = [
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tiny-skia"
|
name = "tiny-skia"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@ -2338,6 +2475,7 @@ dependencies = [
|
|||||||
"shell-escape",
|
"shell-escape",
|
||||||
"smol",
|
"smol",
|
||||||
"ssh2",
|
"ssh2",
|
||||||
|
"syntect",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
@ -2843,6 +2981,15 @@ version = "0.8.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3"
|
checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yaml-rust"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
|
||||||
|
dependencies = [
|
||||||
|
"linked-hash-map",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "3.11.1"
|
version = "3.11.1"
|
||||||
|
@ -18,6 +18,7 @@ serde_json = "1.0.94"
|
|||||||
shell-escape = "0.1.5"
|
shell-escape = "0.1.5"
|
||||||
smol = "1.3.0"
|
smol = "1.3.0"
|
||||||
ssh2 = "0.9.4"
|
ssh2 = "0.9.4"
|
||||||
|
syntect = "5.0.0"
|
||||||
thiserror = "1.0.40"
|
thiserror = "1.0.40"
|
||||||
tokio = { version = "1.26.0", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.26.0", features = ["macros", "rt-multi-thread"] }
|
||||||
toml = "0.7.3"
|
toml = "0.7.3"
|
||||||
|
360
src/app.rs
360
src/app.rs
@ -1,17 +1,22 @@
|
|||||||
use std::{
|
use std::{
|
||||||
default,
|
net::{SocketAddr, SocketAddrV4},
|
||||||
net::{SocketAddr, SocketAddrV4, ToSocketAddrs},
|
|
||||||
sync::mpsc::{Receiver, Sender},
|
sync::mpsc::{Receiver, Sender},
|
||||||
|
vec, rc::Rc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Error, Result};
|
use anyhow::Result;
|
||||||
use async_ssh2_lite::{AsyncIoTcpStream, AsyncSession};
|
use async_ssh2_lite::{AsyncIoTcpStream, AsyncSession};
|
||||||
use eframe::CreationContext;
|
use eframe::CreationContext;
|
||||||
use serde::de::IntoDeserializer;
|
use egui::TextEdit;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::ubus;
|
||||||
|
|
||||||
pub enum AsyncEvent {
|
pub enum AsyncEvent {
|
||||||
Connect(Result<AsyncSession<AsyncIoTcpStream>>),
|
Connect(Result<AsyncSession<AsyncIoTcpStream>>),
|
||||||
Disconnect(Result<()>)
|
Disconnect(Result<()>),
|
||||||
|
ListObjects(Result<Vec<ubus::Object>>),
|
||||||
|
Call(Result<Value>)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@ -21,6 +26,13 @@ pub struct App {
|
|||||||
password: String,
|
password: String,
|
||||||
session: Option<AsyncSession<AsyncIoTcpStream>>,
|
session: Option<AsyncSession<AsyncIoTcpStream>>,
|
||||||
|
|
||||||
|
selected_object: Option<Rc<ubus::Object>>,
|
||||||
|
selected_method: Option<String>,
|
||||||
|
object_filter: String,
|
||||||
|
objects: Vec<Rc<ubus::Object>>,
|
||||||
|
payload: String,
|
||||||
|
response: Option<Value>,
|
||||||
|
|
||||||
is_connecting: bool,
|
is_connecting: bool,
|
||||||
is_disconnecting: bool,
|
is_disconnecting: bool,
|
||||||
|
|
||||||
@ -39,6 +51,13 @@ impl Default for App {
|
|||||||
password: "admin01".to_owned(),
|
password: "admin01".to_owned(),
|
||||||
session: None,
|
session: None,
|
||||||
|
|
||||||
|
object_filter: "".into(),
|
||||||
|
selected_object: None,
|
||||||
|
selected_method: None,
|
||||||
|
payload: "".into(),
|
||||||
|
response: None,
|
||||||
|
objects: vec![],
|
||||||
|
|
||||||
is_connecting: false,
|
is_connecting: false,
|
||||||
is_disconnecting: false,
|
is_disconnecting: false,
|
||||||
|
|
||||||
@ -48,7 +67,11 @@ impl Default for App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connect<A>(socket_addr: A, username: String, password: String) -> Result<AsyncSession<AsyncIoTcpStream>>
|
async fn connect<A>(
|
||||||
|
socket_addr: A,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<AsyncSession<AsyncIoTcpStream>>
|
||||||
where
|
where
|
||||||
A: Into<SocketAddr>,
|
A: Into<SocketAddr>,
|
||||||
{
|
{
|
||||||
@ -59,36 +82,52 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn disconnect(session: AsyncSession<AsyncIoTcpStream>) -> Result<()> {
|
async fn disconnect(session: AsyncSession<AsyncIoTcpStream>) -> Result<()> {
|
||||||
session.disconnect(Some(ssh2::DisconnectCode::ByApplication), "Disconnect", Some("en")).await?;
|
session
|
||||||
|
.disconnect(
|
||||||
|
Some(ssh2::DisconnectCode::ByApplication),
|
||||||
|
"Disconnect",
|
||||||
|
Some("en"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn init(&mut self, _cc: &CreationContext) {}
|
pub fn init(&mut self, _cc: &CreationContext) {}
|
||||||
|
|
||||||
fn handle_connect_event(&mut self, result: Result<AsyncSession<AsyncIoTcpStream>>) {
|
fn handle_events(&mut self, _ctx: &egui::Context) {
|
||||||
self.is_connecting = false;
|
use AsyncEvent::*;
|
||||||
match result {
|
|
||||||
Ok(session) => {
|
|
||||||
self.session = Some(session)
|
|
||||||
},
|
|
||||||
Err(_) => todo!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_disconnect_event(&mut self, result: Result<()>) {
|
|
||||||
self.is_disconnecting = false;
|
|
||||||
|
|
||||||
if let Err(err) = result {
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_events(&mut self, ctx: &egui::Context) {
|
|
||||||
if let Ok(event) = self.rx.try_recv() {
|
if let Ok(event) = self.rx.try_recv() {
|
||||||
match event {
|
match event {
|
||||||
AsyncEvent::Connect(result) => self.handle_connect_event(result),
|
Connect(result) => {
|
||||||
AsyncEvent::Disconnect(result) => self.handle_disconnect_event(result),
|
self.is_connecting = false;
|
||||||
|
match result {
|
||||||
|
Ok(session) => {
|
||||||
|
self.session = Some(session);
|
||||||
|
self.start_list_objects()
|
||||||
|
}
|
||||||
|
Err(err) => todo!("{}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Disconnect(result) => {
|
||||||
|
self.is_disconnecting = false;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
todo!("{}", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListObjects(result) => match result {
|
||||||
|
Ok(objects) => self.objects = objects.into_iter().map(Rc::new).collect(),
|
||||||
|
Err(err) => todo!("{}", err),
|
||||||
|
},
|
||||||
|
|
||||||
|
Call(result) => match result {
|
||||||
|
Ok(response) => self.response = Some(response),
|
||||||
|
Err(err) => todo!("{}", err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,19 +136,24 @@ impl App {
|
|||||||
where
|
where
|
||||||
A: Into<SocketAddr>,
|
A: Into<SocketAddr>,
|
||||||
{
|
{
|
||||||
if self.session.is_some() { return; }
|
if self.session.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.is_connecting = true;
|
self.is_connecting = true;
|
||||||
let tx = self.tx.clone();
|
let tx = self.tx.clone();
|
||||||
let socket_addr = socket_addr.into();
|
let socket_addr = socket_addr.into();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = connect(socket_addr, username, password).await;
|
let result = connect(socket_addr, username, password).await;
|
||||||
tx.send(AsyncEvent::Connect(result)).expect("Failed to send event");
|
tx.send(AsyncEvent::Connect(result))
|
||||||
|
.expect("Failed to send event");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_disconnect(&mut self) {
|
fn start_disconnect(&mut self) {
|
||||||
if self.session.is_none() { return; }
|
if self.session.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.is_disconnecting = true;
|
self.is_disconnecting = true;
|
||||||
let tx = self.tx.clone();
|
let tx = self.tx.clone();
|
||||||
@ -117,67 +161,231 @@ impl App {
|
|||||||
self.session = None;
|
self.session = None;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result = disconnect(session).await;
|
let result = disconnect(session).await;
|
||||||
tx.send(AsyncEvent::Disconnect(result)).expect("Failed to send event");
|
tx.send(AsyncEvent::Disconnect(result))
|
||||||
|
.expect("Failed to send event");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_call(&mut self, object: String, method: String, message: Option<Value>) {
|
||||||
|
if self.session.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
let session = self.session.clone().unwrap();
|
||||||
|
self.session = None;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = ubus::call(&session, &object, &method, message.as_ref()).await;
|
||||||
|
tx.send(AsyncEvent::Call(result)).expect("Failed to send event");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_list_objects(&self) {
|
||||||
|
if self.session.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let session = self.session.clone().unwrap();
|
||||||
|
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = ubus::list_verbose(&session, None).await;
|
||||||
|
tx.send(AsyncEvent::ListObjects(result))
|
||||||
|
.expect("Failed to send event");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_left_panel(&mut self, ui: &mut egui::Ui) {
|
||||||
|
use egui::*;
|
||||||
|
|
||||||
|
ui.text_edit_singleline(&mut self.object_filter);
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
for obj in &self.objects {
|
||||||
|
CollapsingHeader::new(&obj.name).show(ui, |ui| {
|
||||||
|
for (name, _) in &obj.methods {
|
||||||
|
if ui.button(name).clicked() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_right_panel(&mut self, ui: &mut egui::Ui) {
|
||||||
|
use egui::*;
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.text_edit_singleline(&mut self.address);
|
||||||
|
// TODO: ui.text_edit_singleline(&mut self.port);
|
||||||
|
ui.text_edit_singleline(&mut self.username);
|
||||||
|
ui.text_edit_singleline(&mut self.password);
|
||||||
|
if self.is_connecting {
|
||||||
|
ui.add_enabled(false, Button::new("Connecting..."));
|
||||||
|
} else if self.session.is_none() {
|
||||||
|
if ui.button("Connect").clicked() {
|
||||||
|
let socket_addr = SocketAddrV4::new(self.address.parse().unwrap(), self.port);
|
||||||
|
self.start_connect(socket_addr, self.username.clone(), self.password.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ui.button("Disconnect").clicked() {
|
||||||
|
self.start_disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_default_payload(params: &[(&str, ubus::UbusParamType)]) -> String {
|
||||||
|
let mut lines = vec![];
|
||||||
|
for (param_name, param_type) in params {
|
||||||
|
use ubus::UbusParamType::*;
|
||||||
|
let param_value = match param_type {
|
||||||
|
Unknown => "\"<unknown>\"",
|
||||||
|
Integer => "0",
|
||||||
|
Boolean => "false",
|
||||||
|
Table => "{}",
|
||||||
|
String => "\"\"",
|
||||||
|
Array => "[]",
|
||||||
|
Double => "0.00",
|
||||||
|
};
|
||||||
|
lines.push(format!("\t\"{}\": {}", ¶m_name, ¶m_value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return format!("{{\n{}\n}}", lines.join(",\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_central_panel(&mut self, ui: &mut egui::Ui) {
|
||||||
|
// Object dropdown
|
||||||
|
{
|
||||||
|
let object_name = self.selected_object.as_ref().map(|obj| obj.name.as_ref()).unwrap_or("");
|
||||||
|
let object_combobox = egui::ComboBox::from_id_source("selected_object")
|
||||||
|
.selected_text(object_name)
|
||||||
|
.width(200.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
let mut selection = None;
|
||||||
|
for object in &self.objects {
|
||||||
|
ui.selectable_value(&mut selection, Some(object.clone()), &object.name);
|
||||||
|
}
|
||||||
|
return selection;
|
||||||
|
});
|
||||||
|
|
||||||
|
match object_combobox.inner {
|
||||||
|
Some(Some(object)) => {
|
||||||
|
self.selected_method = None;
|
||||||
|
self.selected_object = Some(object);
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method dropdown
|
||||||
|
{
|
||||||
|
let selected_method_name = self.selected_method.as_deref().unwrap_or("");
|
||||||
|
let method_combobox = ui.add_enabled_ui(self.selected_object.is_some(), |ui| {
|
||||||
|
let mut selection = None;
|
||||||
|
egui::ComboBox::from_id_source("selected_method")
|
||||||
|
.selected_text(selected_method_name)
|
||||||
|
.width(200.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
if let Some(object) = &self.selected_object {
|
||||||
|
for method in &object.methods {
|
||||||
|
let method_name = &method.0;
|
||||||
|
let mut label_response = ui.selectable_label(selected_method_name == method_name, method_name);
|
||||||
|
if label_response.clicked() && selected_method_name != method_name {
|
||||||
|
selection = Some(method_name.clone());
|
||||||
|
label_response.mark_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return selection;
|
||||||
|
});
|
||||||
|
|
||||||
|
match (method_combobox.inner, &self.selected_object) {
|
||||||
|
(Some(method), Some(object)) => {
|
||||||
|
let method_params = object.methods.iter()
|
||||||
|
.find(|(name, _)| name.eq(&method))
|
||||||
|
.map(|(_, params)| params)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(param_name, param_type)| (param_name.as_str(), *param_type))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
self.payload = App::create_default_payload(&method_params);
|
||||||
|
self.selected_method = Some(method);
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let call_enabled = self.selected_object.is_some() && self.selected_method.is_some();
|
||||||
|
ui.add_enabled_ui(call_enabled, |ui| {
|
||||||
|
if ui.button("call").clicked() {
|
||||||
|
let object_name = self.selected_object.as_ref().unwrap().name.clone();
|
||||||
|
let method_name = self.selected_method.as_ref().unwrap().clone();
|
||||||
|
let message = serde_json::from_str(&self.payload).unwrap(); // TODO: handle parsing error
|
||||||
|
self.start_call(object_name, method_name, Some(message));
|
||||||
|
// TODO: Block sending other requests
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| {
|
||||||
|
let mut layout_job = crate::syntax_highlighting::highlight(ui.ctx(), string, "json");
|
||||||
|
layout_job.wrap.max_width = wrap_width;
|
||||||
|
ui.fonts(|f| f.layout_job(layout_job))
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(response) = &self.response {
|
||||||
|
egui::TopBottomPanel::bottom("bottom_panel")
|
||||||
|
.resizable(true)
|
||||||
|
.show_inside(ui, |ui| {
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::multiline(&mut serde_json::to_string_pretty(response).unwrap())
|
||||||
|
.font(egui::TextStyle::Monospace) // for cursor height
|
||||||
|
.code_editor()
|
||||||
|
.desired_rows(10)
|
||||||
|
.lock_focus(true)
|
||||||
|
.desired_width(f32::INFINITY)
|
||||||
|
.layouter(&mut layouter),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::CentralPanel::default()
|
||||||
|
.show_inside(ui, |ui| {
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::multiline(&mut self.payload)
|
||||||
|
.font(egui::TextStyle::Monospace) // for cursor height
|
||||||
|
.code_editor()
|
||||||
|
.desired_rows(10)
|
||||||
|
.lock_focus(true)
|
||||||
|
.desired_width(f32::INFINITY)
|
||||||
|
.layouter(&mut layouter),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
use egui::*;
|
|
||||||
|
|
||||||
self.handle_events(ctx);
|
self.handle_events(ctx);
|
||||||
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
egui::SidePanel::left("left_panel")
|
egui::SidePanel::left("left_panel")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.default_width(150.0)
|
.width_range(100.0..=300.0)
|
||||||
.width_range(80.0..=200.0)
|
.show_inside(ui, |ui| self.show_left_panel(ui));
|
||||||
.show_inside(ui, |ui| {
|
|
||||||
ui.vertical_centered(|ui| {
|
|
||||||
ui.heading("Left Panel");
|
|
||||||
});
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
ui.label("Foo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
egui::SidePanel::right("right_panel")
|
egui::SidePanel::right("right_panel")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.default_width(150.0)
|
.width_range(100.0..=200.0)
|
||||||
.width_range(80.0..=200.0)
|
.show_inside(ui, |ui| self.show_right_panel(ui));
|
||||||
.show_inside(ui, |ui| {
|
|
||||||
ui.vertical_centered(|ui| {
|
|
||||||
ui.heading("Right Panel");
|
|
||||||
});
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
ui.text_edit_singleline(&mut self.address);
|
|
||||||
// TODO: ui.text_edit_singleline(&mut self.port);
|
|
||||||
ui.text_edit_singleline(&mut self.username);
|
|
||||||
ui.text_edit_singleline(&mut self.password);
|
|
||||||
if self.is_connecting {
|
|
||||||
ui.button("Connecting...");
|
|
||||||
} else if self.session.is_none() {
|
|
||||||
if ui.button("Connect").clicked() {
|
|
||||||
let socket_addr = SocketAddrV4::new(self.address.parse().unwrap(), self.port);
|
|
||||||
self.start_connect(socket_addr, self.username.clone(), self.password.clone());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ui.button("Disconnect").clicked() {
|
|
||||||
self.start_disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
egui::CentralPanel::default().show_inside(ui, |ui| {
|
egui::CentralPanel::default()
|
||||||
ui.vertical_centered(|ui| {
|
.show_inside(ui, |ui| self.show_central_panel(ui));
|
||||||
ui.heading("Central Panel");
|
|
||||||
});
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
ui.label("foo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
46
src/main.rs
46
src/main.rs
@ -1,66 +1,26 @@
|
|||||||
#[cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
|
#[cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
|
||||||
|
|
||||||
use std::{net::ToSocketAddrs};
|
|
||||||
use anyhow::Result;
|
|
||||||
use smol::channel;
|
|
||||||
use async_ssh2_lite::{AsyncSession};
|
|
||||||
use app::App;
|
use app::App;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use ubus::MonitorEvent;
|
|
||||||
|
|
||||||
use crate::ubus::Ubus;
|
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod ubus;
|
mod ubus;
|
||||||
mod async_line_reader;
|
mod async_line_reader;
|
||||||
|
mod syntax_highlighting;
|
||||||
|
|
||||||
// TODO: Save config to file
|
// TODO: Save config to file
|
||||||
|
// TODO: call history
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let rt = Runtime::new().expect("Unable to create Runtime");
|
let rt = Runtime::new().expect("Unable to create Runtime");
|
||||||
|
|
||||||
let _enter = rt.enter();
|
let _enter = rt.enter();
|
||||||
|
|
||||||
/*
|
|
||||||
let address = "172.24.224.1:22";
|
|
||||||
let username = "root";
|
|
||||||
let password = "admin01";
|
|
||||||
|
|
||||||
let mut session = AsyncSession::<async_ssh2_lite::AsyncIoTcpStream>::connect(
|
|
||||||
address.to_socket_addrs()?.next().unwrap(),
|
|
||||||
None,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
session.handshake().await?;
|
|
||||||
session.userauth_password(username, password).await?;
|
|
||||||
|
|
||||||
let ubus = Ubus::new(session);
|
|
||||||
let (tx, rx) = channel::unbounded::<MonitorEvent>();
|
|
||||||
let listener = {
|
|
||||||
let tx = tx.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
println!("before listen");
|
|
||||||
if let Err(err) = ubus.monitor(None, &[], tx).await {
|
|
||||||
dbg!(err);
|
|
||||||
};
|
|
||||||
println!("after listen");
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let e = rx.recv().await?;
|
|
||||||
dbg!(e);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
let mut native_options = eframe::NativeOptions::default();
|
|
||||||
native_options.decorated = true;
|
|
||||||
native_options.resizable = true;
|
|
||||||
let mut app = App::default();
|
let mut app = App::default();
|
||||||
|
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"ubusman",
|
"ubusman",
|
||||||
native_options,
|
eframe::NativeOptions::default(),
|
||||||
Box::new(move |cc| {
|
Box::new(move |cc| {
|
||||||
app.init(cc);
|
app.init(cc);
|
||||||
Box::new(app)
|
Box::new(app)
|
||||||
|
107
src/syntax_highlighting.rs
Normal file
107
src/syntax_highlighting.rs
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
use egui::text::LayoutJob;
|
||||||
|
|
||||||
|
const THEME: &str = "base16-mocha.dark";
|
||||||
|
|
||||||
|
/// Memoized Code highlighting
|
||||||
|
pub fn highlight(ctx: &egui::Context, code: &str, language: &str) -> LayoutJob {
|
||||||
|
impl egui::util::cache::ComputerMut<(&str, &str), LayoutJob> for Highlighter {
|
||||||
|
fn compute(&mut self, (code, lang): (&str, &str)) -> LayoutJob {
|
||||||
|
self.highlight(code, lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HighlightCache = egui::util::cache::FrameCache<LayoutJob, Highlighter>;
|
||||||
|
|
||||||
|
ctx.memory_mut(|mem| {
|
||||||
|
mem.caches
|
||||||
|
.cache::<HighlightCache>()
|
||||||
|
.get((code, language))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Highlighter {
|
||||||
|
ps: syntect::parsing::SyntaxSet,
|
||||||
|
ts: syntect::highlighting::ThemeSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Highlighter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ps: syntect::parsing::SyntaxSet::load_defaults_newlines(),
|
||||||
|
ts: syntect::highlighting::ThemeSet::load_defaults(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Highlighter {
|
||||||
|
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
|
||||||
|
fn highlight(&self, code: &str, lang: &str) -> LayoutJob {
|
||||||
|
self.highlight_impl(code, lang).unwrap_or_else(|| {
|
||||||
|
// Fallback:
|
||||||
|
LayoutJob::simple(
|
||||||
|
code.into(),
|
||||||
|
egui::FontId::monospace(12.0),
|
||||||
|
egui::Color32::DARK_GRAY,
|
||||||
|
f32::INFINITY,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_impl(&self, text: &str, language: &str) -> Option<LayoutJob> {
|
||||||
|
use syntect::easy::HighlightLines;
|
||||||
|
use syntect::highlighting::FontStyle;
|
||||||
|
use syntect::util::LinesWithEndings;
|
||||||
|
|
||||||
|
let syntax = self
|
||||||
|
.ps
|
||||||
|
.find_syntax_by_name(language)
|
||||||
|
.or_else(|| self.ps.find_syntax_by_extension(language))?;
|
||||||
|
|
||||||
|
let mut h = HighlightLines::new(syntax, &self.ts.themes[THEME]);
|
||||||
|
|
||||||
|
use egui::text::{LayoutSection, TextFormat};
|
||||||
|
|
||||||
|
let mut job = LayoutJob {
|
||||||
|
text: text.into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in LinesWithEndings::from(text) {
|
||||||
|
for (style, range) in h.highlight_line(line, &self.ps).ok()? {
|
||||||
|
let fg = style.foreground;
|
||||||
|
let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b);
|
||||||
|
let italics = style.font_style.contains(FontStyle::ITALIC);
|
||||||
|
let underline = style.font_style.contains(FontStyle::ITALIC);
|
||||||
|
let underline = if underline {
|
||||||
|
egui::Stroke::new(1.0, text_color)
|
||||||
|
} else {
|
||||||
|
egui::Stroke::NONE
|
||||||
|
};
|
||||||
|
job.sections.push(LayoutSection {
|
||||||
|
leading_space: 0.0,
|
||||||
|
byte_range: as_byte_range(text, range),
|
||||||
|
format: TextFormat {
|
||||||
|
font_id: egui::FontId::monospace(12.0),
|
||||||
|
color: text_color,
|
||||||
|
italics,
|
||||||
|
underline,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_byte_range(whole: &str, range: &str) -> std::ops::Range<usize> {
|
||||||
|
let whole_start = whole.as_ptr() as usize;
|
||||||
|
let range_start = range.as_ptr() as usize;
|
||||||
|
assert!(whole_start <= range_start);
|
||||||
|
assert!(range_start + range.len() <= whole_start + whole.len());
|
||||||
|
let offset = range_start - whole_start;
|
||||||
|
offset..(offset + range.len())
|
||||||
|
}
|
477
src/ubus.rs
477
src/ubus.rs
@ -1,23 +1,21 @@
|
|||||||
use std::{borrow::Cow, net::TcpStream, str::FromStr};
|
use std::{borrow::Cow, net::TcpStream, str::FromStr};
|
||||||
|
|
||||||
use async_ssh2_lite::AsyncSession;
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use async_ssh2_lite::{AsyncIoTcpStream, AsyncSession};
|
||||||
|
use hex::FromHex;
|
||||||
use lazy_regex::regex_captures;
|
use lazy_regex::regex_captures;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use shell_escape::unix::escape;
|
use shell_escape::unix::escape;
|
||||||
use hex::FromHex;
|
use smol::{channel::Sender, io::AsyncReadExt, Async};
|
||||||
use anyhow::{Result, bail, anyhow};
|
|
||||||
use smol::{Async, io::AsyncReadExt, channel::Sender};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::async_line_reader::AsyncLineReader;
|
use crate::async_line_reader::AsyncLineReader;
|
||||||
|
|
||||||
|
type Session = AsyncSession<AsyncIoTcpStream>;
|
||||||
|
|
||||||
// TODO: Add tests
|
// TODO: Add tests
|
||||||
|
|
||||||
pub struct Ubus {
|
#[derive(Debug, Clone, Copy)]
|
||||||
session: AsyncSession<Async<TcpStream>>
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum UbusParamType {
|
pub enum UbusParamType {
|
||||||
Unknown,
|
Unknown,
|
||||||
Integer,
|
Integer,
|
||||||
@ -25,7 +23,7 @@ pub enum UbusParamType {
|
|||||||
Table,
|
Table,
|
||||||
String,
|
String,
|
||||||
Array,
|
Array,
|
||||||
Double
|
Double,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
@ -58,17 +56,25 @@ pub enum UbusError {
|
|||||||
SystemError,
|
SystemError,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
pub type Method = (String, Vec<(String, UbusParamType)>);
|
||||||
pub struct UbusObject {
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Object {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub methods: Vec<(String, Vec<(String, UbusParamType)>)>
|
pub methods: Vec<Method>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Object {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.name == other.name && self.id == other.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ListenEvent {
|
pub struct ListenEvent {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub value: Value
|
pub value: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@ -88,19 +94,21 @@ pub enum MonitorEventType {
|
|||||||
|
|
||||||
impl ToString for MonitorEventType {
|
impl ToString for MonitorEventType {
|
||||||
fn to_string(&self) -> String {
|
fn to_string(&self) -> String {
|
||||||
|
use MonitorEventType::*;
|
||||||
match self {
|
match self {
|
||||||
MonitorEventType::Hello => "hello",
|
Hello => "hello",
|
||||||
MonitorEventType::Status => "status",
|
Status => "status",
|
||||||
MonitorEventType::Data => "data",
|
Data => "data",
|
||||||
MonitorEventType::Ping => "ping",
|
Ping => "ping",
|
||||||
MonitorEventType::Lookup => "lookup",
|
Lookup => "lookup",
|
||||||
MonitorEventType::Invoke => "invoke",
|
Invoke => "invoke",
|
||||||
MonitorEventType::AddObject => "add_object",
|
AddObject => "add_object",
|
||||||
MonitorEventType::RemoveObject => "remove_object",
|
RemoveObject => "remove_object",
|
||||||
MonitorEventType::Subscribe => "subscribe",
|
Subscribe => "subscribe",
|
||||||
MonitorEventType::Unsubscribe => "unsubscribe",
|
Unsubscribe => "unsubscribe",
|
||||||
MonitorEventType::Notify => "notify",
|
Notify => "notify",
|
||||||
}.into()
|
}
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,36 +116,37 @@ impl FromStr for MonitorEventType {
|
|||||||
type Err = anyhow::Error;
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||||
|
use MonitorEventType::*;
|
||||||
match s {
|
match s {
|
||||||
"hello" => Ok(MonitorEventType::Hello),
|
"hello" => Ok(Hello),
|
||||||
"status" => Ok(MonitorEventType::Status),
|
"status" => Ok(Status),
|
||||||
"data" => Ok(MonitorEventType::Data),
|
"data" => Ok(Data),
|
||||||
"ping" => Ok(MonitorEventType::Ping),
|
"ping" => Ok(Ping),
|
||||||
"lookup" => Ok(MonitorEventType::Lookup),
|
"lookup" => Ok(Lookup),
|
||||||
"invoke" => Ok(MonitorEventType::Invoke),
|
"invoke" => Ok(Invoke),
|
||||||
"add_object" => Ok(MonitorEventType::AddObject),
|
"add_object" => Ok(AddObject),
|
||||||
"remove_object" => Ok(MonitorEventType::RemoveObject),
|
"remove_object" => Ok(RemoveObject),
|
||||||
"subscribe" => Ok(MonitorEventType::Subscribe),
|
"subscribe" => Ok(Subscribe),
|
||||||
"unsubscribe" => Ok(MonitorEventType::Unsubscribe),
|
"unsubscribe" => Ok(Unsubscribe),
|
||||||
"notify" => Ok(MonitorEventType::Notify),
|
"notify" => Ok(Notify),
|
||||||
_ => Err(anyhow!("Unknown event type '{}'", s))
|
_ => Err(anyhow!("Unknown event type '{}'", s)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct MonitorEvent {
|
pub struct MonitorEvent {
|
||||||
direction: MonitorDir,
|
direction: MonitorDir,
|
||||||
client: u32,
|
client: u32,
|
||||||
peer: u32,
|
peer: u32,
|
||||||
kind: MonitorEventType,
|
kind: MonitorEventType,
|
||||||
data: Value // TODO: Figure out the possible values for every `MonitorEventType`, to make this more safe.
|
data: Value, // TODO: Figure out the possible values for every `MonitorEventType`, to make this more safe.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum MonitorDir {
|
pub enum MonitorDir {
|
||||||
Rx,
|
Rx,
|
||||||
Tx
|
Tx,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_parameter_type(param: &str) -> Option<UbusParamType> {
|
fn parse_parameter_type(param: &str) -> Option<UbusParamType> {
|
||||||
@ -150,7 +159,7 @@ fn parse_parameter_type(param: &str) -> Option<UbusParamType> {
|
|||||||
"Table" => Some(Table),
|
"Table" => Some(Table),
|
||||||
"Array" => Some(Array),
|
"Array" => Some(Array),
|
||||||
"(unknown)" => Some(Unknown),
|
"(unknown)" => Some(Unknown),
|
||||||
_ => None
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,7 +181,7 @@ fn parse_error_code(code: i32) -> Option<UbusError> {
|
|||||||
11 => Some(NoMemory),
|
11 => Some(NoMemory),
|
||||||
12 => Some(ParseError),
|
12 => Some(ParseError),
|
||||||
13 => Some(SystemError),
|
13 => Some(SystemError),
|
||||||
_ => Some(UnknownError)
|
_ => Some(UnknownError),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,203 +199,217 @@ fn escape_json(json: &Value) -> String {
|
|||||||
escape(Cow::from(json.to_string())).into()
|
escape(Cow::from(json.to_string())).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ubus {
|
async fn exec_cmd(session: &Session, cmd: &str) -> Result<String> {
|
||||||
pub fn new(session: AsyncSession<Async<TcpStream>>) -> Ubus {
|
let mut channel = session.channel_session().await?;
|
||||||
Ubus {
|
channel.exec(cmd).await?;
|
||||||
session
|
channel.close().await?;
|
||||||
}
|
if let Some(err) = parse_error_code(channel.exit_status()?) {
|
||||||
|
return Err(err.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn exec_cmd(&self, cmd: &str) -> Result<String> {
|
let mut output = String::new();
|
||||||
let mut channel = self.session.channel_session().await?;
|
channel.read_to_string(&mut output).await?;
|
||||||
channel.exec(cmd).await?;
|
Ok(output)
|
||||||
channel.close().await?;
|
}
|
||||||
if let Some(err) = parse_error_code(channel.exit_status()?) {
|
|
||||||
return Err(err.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut output = String::new();
|
pub async fn list(session: &Session, path: Option<&str>) -> Result<Vec<String>> {
|
||||||
channel.read_to_string(&mut output).await?;
|
let output = match path {
|
||||||
Ok(output)
|
Some(path) => exec_cmd(session, &format!("ubus -S list {}", path)).await?,
|
||||||
}
|
None => exec_cmd(session, "ubus -S list").await?,
|
||||||
|
};
|
||||||
|
Ok(output.lines().map(ToOwned::to_owned).collect::<Vec<_>>())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list(&self, path: Option<&str>) -> Result<Vec<String>> {
|
pub async fn list_verbose(session: &Session, path: Option<&str>) -> Result<Vec<Object>> {
|
||||||
let output = match path {
|
let output = match path {
|
||||||
Some(path) => self.exec_cmd(&format!("ubus -S list {}", path)).await?,
|
Some(path) => exec_cmd(session, &format!("ubus -v list {}", path)).await?,
|
||||||
None => self.exec_cmd("ubus -S list").await?,
|
None => exec_cmd(session, "ubus -v list").await?,
|
||||||
};
|
};
|
||||||
Ok(output.lines().map(ToOwned::to_owned).collect::<Vec<_>>())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_verbose(&self, path: Option<&str>) -> Result<Vec<UbusObject>> {
|
let mut cur_name = None;
|
||||||
let output = match path {
|
let mut cur_id = None;
|
||||||
Some(path) => self.exec_cmd(&format!("ubus -v list {}", path)).await?,
|
let mut cur_methods = vec![];
|
||||||
None => self.exec_cmd("ubus -v list").await?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut cur_name = None;
|
let mut objects = vec![];
|
||||||
let mut cur_id = None;
|
for line in output.lines() {
|
||||||
let mut cur_methods = vec![];
|
if let Some((_, name, id)) = regex_captures!(r"^'([\w.-]+)' @([0-9a-zA-Z]{8})$", line) {
|
||||||
|
if cur_name.is_some() && cur_id.is_some() {
|
||||||
let mut objects = vec![];
|
objects.push(Object {
|
||||||
for line in output.lines() {
|
id: cur_id.unwrap(),
|
||||||
if let Some((_, name, id)) = regex_captures!(r"^'([\w.-]+)' @([0-9a-zA-Z]{8})$", line) {
|
name: cur_name.unwrap(),
|
||||||
if cur_name.is_some() && cur_id.is_some() {
|
methods: cur_methods,
|
||||||
objects.push(UbusObject {
|
});
|
||||||
id: cur_id.unwrap(),
|
cur_methods = vec![];
|
||||||
name: cur_name.unwrap(),
|
|
||||||
methods: cur_methods
|
|
||||||
});
|
|
||||||
cur_methods = vec![];
|
|
||||||
}
|
|
||||||
|
|
||||||
cur_name = Some(name.into());
|
|
||||||
cur_id = Some(parse_hex_id(id)?);
|
|
||||||
} else if let Some((_, name, params_body)) = regex_captures!(r#"^\s+"([\w-]+)":\{(.*)}$"#, line) {
|
|
||||||
let mut params = vec![];
|
|
||||||
if !params_body.is_empty() {
|
|
||||||
for param in params_body.split(",") {
|
|
||||||
let (_, name, param_type_name) = regex_captures!(r#"^"([\w-]+)":"(\w+)"$"#, param)
|
|
||||||
.ok_or(anyhow!("Failed to parse parameter '{}' in line '{}'", param, line))?;
|
|
||||||
|
|
||||||
let param_type = parse_parameter_type(param_type_name)
|
|
||||||
.ok_or(anyhow!("Unknown parameter type '{}'", param_type_name))?;
|
|
||||||
|
|
||||||
params.push((name.into(), param_type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cur_methods.push((name.into(), params));
|
|
||||||
} else {
|
|
||||||
bail!("Failed to parse line '{}'", line);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(objects)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn call(&self, path: &str, method: &str, message: Option<&Value>) -> Result<Value> {
|
cur_name = Some(name.into());
|
||||||
let cmd = match message {
|
cur_id = Some(parse_hex_id(id)?);
|
||||||
Some(msg) => format!("ubus -S call {} {} {}", path, method, escape_json(msg)),
|
} else if let Some((_, name, params_body)) =
|
||||||
None => format!("ubus -S call {} {}", path, method),
|
regex_captures!(r#"^\s+"([\w-]+)":\{(.*)}$"#, line)
|
||||||
};
|
{
|
||||||
|
let mut params = vec![];
|
||||||
|
if !params_body.is_empty() {
|
||||||
|
for param in params_body.split(",") {
|
||||||
|
let (_, name, param_type_name) =
|
||||||
|
regex_captures!(r#"^"([\w-]+)":"(\w+)"$"#, param).ok_or(anyhow!(
|
||||||
|
"Failed to parse parameter '{}' in line '{}'",
|
||||||
|
param,
|
||||||
|
line
|
||||||
|
))?;
|
||||||
|
|
||||||
let output = self.exec_cmd(&cmd).await?;
|
let param_type = parse_parameter_type(param_type_name)
|
||||||
let value = serde_json::from_str::<Value>(&output)?;
|
.ok_or(anyhow!("Unknown parameter type '{}'", param_type_name))?;
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send(&self, event_type: &str, message: Option<&Value>) -> Result<()> {
|
params.push((name.into(), param_type));
|
||||||
let cmd = match message {
|
}
|
||||||
Some(msg) => format!("ubus -S send {} {}", event_type, escape_json(msg)),
|
|
||||||
None => format!("ubus -S send {}", event_type),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.exec_cmd(&cmd).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wait_for(&self, objects: &[&str]) -> Result<()> {
|
|
||||||
if objects.len() < 1 {
|
|
||||||
bail!("At least 1 object is required")
|
|
||||||
}
|
|
||||||
let cmd = format!("ubus -S wait_for {}", objects.join(" "));
|
|
||||||
let mut channel = self.session.channel_session().await?;
|
|
||||||
channel.exec(&cmd).await?;
|
|
||||||
channel.close().await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_listen_event(bytes: &[u8]) -> Result<ListenEvent> {
|
|
||||||
let event_value: Value = serde_json::from_slice(bytes)?;
|
|
||||||
let event_map = event_value.as_object()
|
|
||||||
.ok_or(anyhow!("Expected event to be an object"))?;
|
|
||||||
|
|
||||||
if event_map.keys().len() != 1 {
|
|
||||||
bail!("Expected event object to only contain one key");
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = event_map.keys().next().unwrap().clone();
|
|
||||||
let value = event_map.get(&path).unwrap().clone();
|
|
||||||
|
|
||||||
Ok(ListenEvent { path, value })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn listen(&self, paths: &[&str], sender: Sender<ListenEvent>) -> Result<()> {
|
|
||||||
let cmd = format!("ubus -S listen {}", paths.join(" "));
|
|
||||||
let mut channel = self.session.channel_session().await?;
|
|
||||||
channel.exec(&cmd).await?;
|
|
||||||
// TODO: Handle error? 'channel.exit_status()', idk if needed
|
|
||||||
|
|
||||||
let mut line_reader = AsyncLineReader::new(channel.stream(0));
|
|
||||||
loop {
|
|
||||||
let line = line_reader.read_line().await?;
|
|
||||||
let event = Ubus::parse_listen_event(&line)?;
|
|
||||||
sender.send(event).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn subscribe(&self, paths: &[&str]) -> Result<()> {
|
|
||||||
if paths.len() < 1 {
|
|
||||||
bail!("At least 1 object is required")
|
|
||||||
}
|
|
||||||
let cmd = format!("ubus -S subscribe {}", paths.join(" "));
|
|
||||||
let mut channel = self.session.channel_session().await?;
|
|
||||||
channel.exec(&cmd).await?;
|
|
||||||
|
|
||||||
// TODO: Haven't figured out how to test subscribe event using default objects on ubus.
|
|
||||||
todo!();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_monitor_event(bytes: &[u8]) -> Result<MonitorEvent> {
|
|
||||||
let line = bytes.iter()
|
|
||||||
.map(|c| char::from_u32(*c as u32).unwrap())
|
|
||||||
.collect::<String>();
|
|
||||||
|
|
||||||
let (_,
|
|
||||||
direction_str,
|
|
||||||
client_str,
|
|
||||||
peer_str,
|
|
||||||
kind_str,
|
|
||||||
data_str
|
|
||||||
) = regex_captures!(r"^([<\->]{2}) ([0-9a-zA-Z]{8}) #([0-9a-zA-Z]{8})\s+([a-z_]+): (\{.*\})$", &line)
|
|
||||||
.ok_or(anyhow!("Unknown pattern of monitor message '{}'", line))?;
|
|
||||||
|
|
||||||
let direction = match direction_str {
|
|
||||||
"->" => MonitorDir::Tx,
|
|
||||||
"<-" => MonitorDir::Rx,
|
|
||||||
_ => bail!("Unknown monitor message direction '{}'", direction_str)
|
|
||||||
};
|
|
||||||
let client = parse_hex_id(client_str)?;
|
|
||||||
let peer = parse_hex_id(peer_str)?;
|
|
||||||
let kind = MonitorEventType::from_str(kind_str)?;
|
|
||||||
let data = serde_json::from_str(data_str)?;
|
|
||||||
|
|
||||||
Ok(MonitorEvent { direction, client, peer, kind, data })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn monitor(&self, dir: Option<MonitorDir>, filter: &[MonitorEventType], sender: Sender<MonitorEvent>) -> Result<()> {
|
|
||||||
let mut cmd = vec!["ubus -S".into()];
|
|
||||||
if let Some(dir) = dir {
|
|
||||||
if dir == MonitorDir::Rx {
|
|
||||||
cmd.push("-M r".into());
|
|
||||||
} else if dir == MonitorDir::Tx {
|
|
||||||
cmd.push("-M t".into());
|
|
||||||
}
|
}
|
||||||
|
cur_methods.push((name.into(), params));
|
||||||
|
} else {
|
||||||
|
bail!("Failed to parse line '{}'", line);
|
||||||
}
|
}
|
||||||
cmd.extend(filter.iter().map(|e| format!("-m {}", e.to_string())));
|
}
|
||||||
cmd.push("monitor".into());
|
Ok(objects)
|
||||||
|
}
|
||||||
|
|
||||||
let mut channel = self.session.channel_session().await?;
|
pub async fn call(
|
||||||
println!("{}", cmd.join(" "));
|
session: &Session,
|
||||||
channel.exec(&cmd.join(" ")).await?;
|
path: &str,
|
||||||
// TODO: Handle error? 'channel.exit_status()', idk if needed
|
method: &str,
|
||||||
|
message: Option<&Value>,
|
||||||
|
) -> Result<Value> {
|
||||||
|
let cmd = match message {
|
||||||
|
Some(msg) => format!("ubus -S call {} {} {}", path, method, escape_json(msg)),
|
||||||
|
None => format!("ubus -S call {} {}", path, method),
|
||||||
|
};
|
||||||
|
|
||||||
let mut line_reader = AsyncLineReader::new(channel.stream(0));
|
// TODO: handle cases where output is empty? ("")
|
||||||
loop {
|
let output = exec_cmd(session, &cmd).await?;
|
||||||
let line = line_reader.read_line().await?;
|
let value = serde_json::from_str::<Value>(&output)?;
|
||||||
let event = Ubus::parse_monitor_event(&line)?;
|
Ok(value)
|
||||||
sender.send(event).await?;
|
}
|
||||||
}
|
|
||||||
|
pub async fn send(session: &Session, event_type: &str, message: Option<&Value>) -> Result<()> {
|
||||||
|
let cmd = match message {
|
||||||
|
Some(msg) => format!("ubus -S send {} {}", event_type, escape_json(msg)),
|
||||||
|
None => format!("ubus -S send {}", event_type),
|
||||||
|
};
|
||||||
|
|
||||||
|
exec_cmd(session, &cmd).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait_for(session: &Session, objects: &[&str]) -> Result<()> {
|
||||||
|
if objects.len() < 1 {
|
||||||
|
bail!("At least 1 object is required")
|
||||||
|
}
|
||||||
|
let cmd = format!("ubus -S wait_for {}", objects.join(" "));
|
||||||
|
let mut channel = session.channel_session().await?;
|
||||||
|
channel.exec(&cmd).await?;
|
||||||
|
channel.close().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listen_event(bytes: &[u8]) -> Result<ListenEvent> {
|
||||||
|
let event_value: Value = serde_json::from_slice(bytes)?;
|
||||||
|
let event_map = event_value
|
||||||
|
.as_object()
|
||||||
|
.ok_or(anyhow!("Expected event to be an object"))?;
|
||||||
|
|
||||||
|
if event_map.keys().len() != 1 {
|
||||||
|
bail!("Expected event object to only contain one key");
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = event_map.keys().next().unwrap().clone();
|
||||||
|
let value = event_map.get(&path).unwrap().clone();
|
||||||
|
|
||||||
|
Ok(ListenEvent { path, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn listen(session: &Session, paths: &[&str], sender: Sender<ListenEvent>) -> Result<()> {
|
||||||
|
let cmd = format!("ubus -S listen {}", paths.join(" "));
|
||||||
|
let mut channel = session.channel_session().await?;
|
||||||
|
channel.exec(&cmd).await?;
|
||||||
|
// TODO: Handle error? 'channel.exit_status()', idk if needed
|
||||||
|
|
||||||
|
let mut line_reader = AsyncLineReader::new(channel.stream(0));
|
||||||
|
loop {
|
||||||
|
let line = line_reader.read_line().await?;
|
||||||
|
let event = parse_listen_event(&line)?;
|
||||||
|
sender.send(event).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn subscribe(session: &Session, paths: &[&str]) -> Result<()> {
|
||||||
|
if paths.len() < 1 {
|
||||||
|
bail!("At least 1 object is required")
|
||||||
|
}
|
||||||
|
let cmd = format!("ubus -S subscribe {}", paths.join(" "));
|
||||||
|
let mut channel = session.channel_session().await?;
|
||||||
|
channel.exec(&cmd).await?;
|
||||||
|
|
||||||
|
// TODO: Haven't figured out how to test subscribe event using default objects on ubus.
|
||||||
|
todo!();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_monitor_event(bytes: &[u8]) -> Result<MonitorEvent> {
|
||||||
|
let line = bytes
|
||||||
|
.iter()
|
||||||
|
.map(|c| char::from_u32(*c as u32).unwrap())
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
let (_, direction_str, client_str, peer_str, kind_str, data_str) = regex_captures!(
|
||||||
|
r"^([<\->]{2}) ([0-9a-zA-Z]{8}) #([0-9a-zA-Z]{8})\s+([a-z_]+): (\{.*\})$",
|
||||||
|
&line
|
||||||
|
)
|
||||||
|
.ok_or(anyhow!("Unknown pattern of monitor message '{}'", line))?;
|
||||||
|
|
||||||
|
let direction = match direction_str {
|
||||||
|
"->" => MonitorDir::Tx,
|
||||||
|
"<-" => MonitorDir::Rx,
|
||||||
|
_ => bail!("Unknown monitor message direction '{}'", direction_str),
|
||||||
|
};
|
||||||
|
let client = parse_hex_id(client_str)?;
|
||||||
|
let peer = parse_hex_id(peer_str)?;
|
||||||
|
let kind = MonitorEventType::from_str(kind_str)?;
|
||||||
|
let data = serde_json::from_str(data_str)?;
|
||||||
|
|
||||||
|
Ok(MonitorEvent {
|
||||||
|
direction,
|
||||||
|
client,
|
||||||
|
peer,
|
||||||
|
kind,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn monitor(
|
||||||
|
session: &Session,
|
||||||
|
dir: Option<MonitorDir>,
|
||||||
|
filter: &[MonitorEventType],
|
||||||
|
sender: Sender<MonitorEvent>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut cmd = vec!["ubus -S".into()];
|
||||||
|
if let Some(dir) = dir {
|
||||||
|
if dir == MonitorDir::Rx {
|
||||||
|
cmd.push("-M r".into());
|
||||||
|
} else if dir == MonitorDir::Tx {
|
||||||
|
cmd.push("-M t".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.extend(filter.iter().map(|e| format!("-m {}", e.to_string())));
|
||||||
|
cmd.push("monitor".into());
|
||||||
|
|
||||||
|
let mut channel = session.channel_session().await?;
|
||||||
|
println!("{}", cmd.join(" "));
|
||||||
|
channel.exec(&cmd.join(" ")).await?;
|
||||||
|
// TODO: Handle error? 'channel.exit_status()', idk if needed
|
||||||
|
|
||||||
|
let mut line_reader = AsyncLineReader::new(channel.stream(0));
|
||||||
|
loop {
|
||||||
|
let line = line_reader.read_line().await?;
|
||||||
|
let event = parse_monitor_event(&line)?;
|
||||||
|
sender.send(event).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user