Implement retained virtual terminal presentation
This commit is contained in:
parent
aef24440a5
commit
f468592d95
3 changed files with 811 additions and 71 deletions
|
|
@ -645,6 +645,12 @@ impl<E> From<io::Error> for ClientError<E> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<E> From<ControlClientError> for ClientError<E> {
|
||||
fn from(error: ControlClientError) -> Self {
|
||||
Self::Control(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -671,9 +677,3 @@ mod tests {
|
|||
));
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<ControlClientError> for ClientError<E> {
|
||||
fn from(error: ControlClientError) -> Self {
|
||||
Self::Control(error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::collections::VecDeque;
|
|||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::num::NonZeroU16;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lazaret_core::records::{
|
||||
DeveloperDecisionKind, InteractiveProtocolVersion, InteractiveSessionBatchV2,
|
||||
|
|
@ -14,11 +14,18 @@ use nix::sys::signal::{SigSet, SigmaskHow, Signal};
|
|||
use nix::sys::signalfd::{SfdFlags, SignalFd};
|
||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||
use rustix::termios::{
|
||||
OptionalActions, SpecialCodeIndex, Termios, tcgetattr, tcgetwinsize, tcsetattr,
|
||||
OptionalActions, QueueSelector, SpecialCodeIndex, Termios, tcflush, tcgetattr, tcgetwinsize,
|
||||
tcsetattr,
|
||||
};
|
||||
|
||||
use crate::{ClientError, ClientSessionIdentity};
|
||||
|
||||
mod prompt;
|
||||
|
||||
use prompt::{
|
||||
PromptChoice, PromptInput, PromptInputDecoder, display_argv, display_bytes, prompt_outcome,
|
||||
};
|
||||
|
||||
pub trait InteractiveSessionTransport {
|
||||
type Error;
|
||||
|
||||
|
|
@ -83,6 +90,8 @@ pub struct PromptOutcome {
|
|||
pub resumed_size: Option<TerminalResize>,
|
||||
}
|
||||
|
||||
const PROMPT_ESCAPE_INTERVAL: Duration = Duration::from_millis(40);
|
||||
|
||||
pub struct SystemTerminal {
|
||||
terminal: File,
|
||||
original: Termios,
|
||||
|
|
@ -92,8 +101,100 @@ pub struct SystemTerminal {
|
|||
events: VecDeque<HostTerminalEvent>,
|
||||
line_start: bool,
|
||||
escape_pending: bool,
|
||||
key_translator: KeyTranslator,
|
||||
}
|
||||
|
||||
struct KeyTranslator {
|
||||
mappings: &'static [(&'static [u8], &'static [u8])],
|
||||
pending: Vec<u8>,
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl KeyTranslator {
|
||||
fn for_profile(profile: Option<&str>) -> Self {
|
||||
let mappings = match profile {
|
||||
Some("xterm-256color" | "alacritty" | "foot") => XTERM_KEY_MAPPINGS,
|
||||
Some("linux") => LINUX_KEY_MAPPINGS,
|
||||
_ => &[],
|
||||
};
|
||||
Self {
|
||||
mappings,
|
||||
pending: Vec::new(),
|
||||
deadline: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn feed(&mut self, bytes: &[u8], now: Instant) -> Vec<u8> {
|
||||
if self.mappings.is_empty() {
|
||||
return bytes.to_vec();
|
||||
}
|
||||
let mut translated = if self.deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.deadline = None;
|
||||
std::mem::take(&mut self.pending)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for &byte in bytes {
|
||||
self.pending.push(byte);
|
||||
loop {
|
||||
if let Some((_, target)) = self
|
||||
.mappings
|
||||
.iter()
|
||||
.find(|(source, _)| *source == self.pending)
|
||||
{
|
||||
translated.extend_from_slice(target);
|
||||
self.pending.clear();
|
||||
self.deadline = None;
|
||||
break;
|
||||
}
|
||||
if self
|
||||
.mappings
|
||||
.iter()
|
||||
.any(|(source, _)| source.starts_with(&self.pending))
|
||||
{
|
||||
self.deadline = Some(now + PROMPT_ESCAPE_INTERVAL);
|
||||
break;
|
||||
}
|
||||
translated.push(self.pending.remove(0));
|
||||
if self.pending.is_empty() {
|
||||
self.deadline = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
translated
|
||||
}
|
||||
|
||||
fn expire(&mut self, now: Instant) -> Option<Vec<u8>> {
|
||||
if self.deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.deadline = None;
|
||||
Some(std::mem::take(&mut self.pending))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.pending.clear();
|
||||
self.deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
const XTERM_KEY_MAPPINGS: &[(&[u8], &[u8])] = &[(b"\x1bOH", b"\x1b[1~"), (b"\x1bOF", b"\x1b[4~")];
|
||||
|
||||
const LINUX_KEY_MAPPINGS: &[(&[u8], &[u8])] = &[
|
||||
(b"\x1b[A", b"\x1bOA"),
|
||||
(b"\x1b[B", b"\x1bOB"),
|
||||
(b"\x1b[D", b"\x1bOD"),
|
||||
(b"\x1b[C", b"\x1bOC"),
|
||||
(b"\x1b\t", b"\x1b[Z"),
|
||||
(b"\x1b[[A", b"\x1bOP"),
|
||||
(b"\x1b[[B", b"\x1bOQ"),
|
||||
(b"\x1b[[C", b"\x1bOR"),
|
||||
(b"\x1b[[D", b"\x1bOS"),
|
||||
(b"\x1b[[E", b"\x1b[15~"),
|
||||
];
|
||||
|
||||
impl SystemTerminal {
|
||||
/// Opens the controlling terminal and captures its exact initial mode.
|
||||
///
|
||||
|
|
@ -110,6 +211,7 @@ impl SystemTerminal {
|
|||
events: VecDeque::new(),
|
||||
line_start: true,
|
||||
escape_pending: false,
|
||||
key_translator: KeyTranslator::for_profile(std::env::var("TERM").ok().as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +258,10 @@ impl SystemTerminal {
|
|||
self.line_start = matches!(byte, b'\r' | b'\n');
|
||||
}
|
||||
if !forwarded.is_empty() {
|
||||
events.push_back(HostTerminalEvent::Input(forwarded));
|
||||
let translated = self.key_translator.feed(&forwarded, Instant::now());
|
||||
if !translated.is_empty() {
|
||||
events.push_back(HostTerminalEvent::Input(translated));
|
||||
}
|
||||
}
|
||||
self.events.extend(events);
|
||||
}
|
||||
|
|
@ -209,6 +314,9 @@ impl HostTerminal for SystemTerminal {
|
|||
if let Some(event) = self.events.pop_front() {
|
||||
return Ok(event);
|
||||
}
|
||||
if let Some(bytes) = self.key_translator.expire(Instant::now()) {
|
||||
return Ok(HostTerminalEvent::Input(bytes));
|
||||
}
|
||||
if let Some(event) = self.signals.next_event()? {
|
||||
return Ok(event);
|
||||
}
|
||||
|
|
@ -234,39 +342,40 @@ impl HostTerminal for SystemTerminal {
|
|||
}
|
||||
|
||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
|
||||
self.write_output(
|
||||
format!(
|
||||
"\r\nLazaret host request\r\nprofile: {}\r\nsession: {}\r\ncommand: {}\r\njustification: {}\r\nApprove? [y/N] ",
|
||||
prompt.agent_profile,
|
||||
hex(prompt.session_id.as_bytes()),
|
||||
display_argv(&prompt.command_argv),
|
||||
prompt.justification
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
self.events.clear();
|
||||
self.escape_pending = false;
|
||||
self.key_translator.clear();
|
||||
tcflush(&self.terminal, QueueSelector::IFlush)?;
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
let mut resumed_size = None;
|
||||
loop {
|
||||
match self.next_event()? {
|
||||
HostTerminalEvent::Input(bytes) => {
|
||||
let approve = bytes
|
||||
.first()
|
||||
.is_some_and(|byte| matches!(byte, b'y' | b'Y'));
|
||||
self.write_output(if approve { b"y\r\n" } else { b"n\r\n" })?;
|
||||
return Ok(PromptOutcome {
|
||||
decision: if approve {
|
||||
DeveloperDecisionKind::Approve
|
||||
} else {
|
||||
DeveloperDecisionKind::Deny
|
||||
},
|
||||
resumed_size,
|
||||
});
|
||||
let previous = decoder.choice;
|
||||
if let PromptInput::Decide(choice) = decoder.feed(&bytes, Instant::now()) {
|
||||
return Ok(prompt_outcome(choice, resumed_size));
|
||||
}
|
||||
if decoder.choice != previous {
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
}
|
||||
}
|
||||
HostTerminalEvent::LocalSuspend => {
|
||||
self.suspend_and_resume()?;
|
||||
resumed_size = Some(self.size(prompt.session_id)?);
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
}
|
||||
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
|
||||
HostTerminalEvent::Idle | HostTerminalEvent::Resize | HostTerminalEvent::Break => {}
|
||||
HostTerminalEvent::Resize => {
|
||||
resumed_size = Some(self.size(prompt.session_id)?);
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
}
|
||||
HostTerminalEvent::Idle => {
|
||||
if let PromptInput::Decide(choice) = decoder.expire(Instant::now()) {
|
||||
return Ok(prompt_outcome(choice, resumed_size));
|
||||
}
|
||||
}
|
||||
HostTerminalEvent::Break => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -278,6 +387,33 @@ impl HostTerminal for SystemTerminal {
|
|||
}
|
||||
}
|
||||
|
||||
impl SystemTerminal {
|
||||
fn render_prompt(&mut self, prompt: &TerminalPrompt, choice: PromptChoice) -> io::Result<()> {
|
||||
let approve = if choice == PromptChoice::Approve {
|
||||
"[ Approve ]"
|
||||
} else {
|
||||
" Approve "
|
||||
};
|
||||
let deny = if choice == PromptChoice::Deny {
|
||||
"[ Deny ]"
|
||||
} else {
|
||||
" Deny "
|
||||
};
|
||||
self.write_output(
|
||||
format!(
|
||||
"\x1b[?2026h\x1b[2J\x1b[HLazaret host request\r\nrequester: {} / {}\r\ncommand: {}\r\njustification: {}\r\n\r\n{} {}\r\n\x1b[?2026l",
|
||||
display_bytes(prompt.agent_profile.as_bytes()),
|
||||
hex(prompt.session_id.as_bytes()),
|
||||
display_argv(&prompt.command_argv),
|
||||
display_bytes(prompt.justification.as_bytes()),
|
||||
approve,
|
||||
deny,
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemTerminal {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.restore();
|
||||
|
|
@ -580,13 +716,6 @@ fn require_session<E>(
|
|||
}
|
||||
}
|
||||
|
||||
fn display_argv(argv: &[Vec<u8>]) -> String {
|
||||
argv.iter()
|
||||
.map(|argument| String::from_utf8_lossy(argument))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
|
|
@ -601,6 +730,7 @@ mod tests {
|
|||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io::Write as _;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
|
|
@ -608,7 +738,10 @@ mod tests {
|
|||
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
|
||||
use rustix::termios::{LocalModes, tcgetattr};
|
||||
|
||||
use super::{HostTerminal, HostTerminalEvent, SignalQueue, SystemTerminal};
|
||||
use super::{
|
||||
HostTerminal, HostTerminalEvent, PromptChoice, PromptInput, PromptInputDecoder,
|
||||
SignalQueue, SystemTerminal, display_argv, display_bytes,
|
||||
};
|
||||
|
||||
fn terminal() -> (SystemTerminal, File) {
|
||||
let master =
|
||||
|
|
@ -634,6 +767,7 @@ mod tests {
|
|||
events: VecDeque::new(),
|
||||
line_start: true,
|
||||
escape_pending: false,
|
||||
key_translator: super::KeyTranslator::for_profile(Some("xterm-256color")),
|
||||
},
|
||||
File::from(master),
|
||||
)
|
||||
|
|
@ -674,6 +808,105 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_decoder_is_fragmentation_independent_and_fails_closed() {
|
||||
for interval in [
|
||||
Duration::from_millis(0),
|
||||
Duration::from_millis(5),
|
||||
Duration::from_millis(20),
|
||||
] {
|
||||
let start = Instant::now();
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\x1b[", start),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert!(matches!(
|
||||
decoder.feed(b"D", start + interval),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert_eq!(decoder.choice, PromptChoice::Approve);
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\r", start + interval),
|
||||
PromptInput::Decide(PromptChoice::Approve)
|
||||
));
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\x1b", start),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert!(matches!(
|
||||
decoder.expire(start + Duration::from_millis(41)),
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
));
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\x1b[", start),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert!(matches!(
|
||||
decoder.feed(b"D", start + Duration::from_millis(41)),
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
));
|
||||
for bytes in [b"\r".as_slice(), b"\x03", b"unexpected", b"\x1b[Z"] {
|
||||
assert!(matches!(
|
||||
PromptInputDecoder::new().feed(bytes, start),
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
PromptInputDecoder::new().feed(b"y", start),
|
||||
PromptInput::Decide(PromptChoice::Approve)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_fields_are_inert_and_arguments_remain_separate() {
|
||||
assert_eq!(display_bytes(b"ok\x1b[2J\n"), "ok\\x1b[2J\\x0a");
|
||||
assert_eq!(
|
||||
display_argv(&[b"one two".to_vec(), b"three\x1b[H".to_vec()]),
|
||||
"[\"one two\", \"three\\x1b[H\"]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_host_keys_translate_to_the_controlled_guest_profile() {
|
||||
let start = Instant::now();
|
||||
for (profile, source, target) in [
|
||||
(
|
||||
"xterm-256color",
|
||||
b"\x1bOH".as_slice(),
|
||||
b"\x1b[1~".as_slice(),
|
||||
),
|
||||
("alacritty", b"\x1bOF", b"\x1b[4~"),
|
||||
("foot", b"\x1bOH", b"\x1b[1~"),
|
||||
("linux", b"\x1b[[A", b"\x1bOP"),
|
||||
("linux", b"\x1b[A", b"\x1bOA"),
|
||||
] {
|
||||
let mut translator = super::KeyTranslator::for_profile(Some(profile));
|
||||
let mut translated = Vec::new();
|
||||
for (index, byte) in source.iter().enumerate() {
|
||||
translated.extend(translator.feed(
|
||||
std::slice::from_ref(byte),
|
||||
start + Duration::from_millis(u64::try_from(index).unwrap() * 5),
|
||||
));
|
||||
}
|
||||
assert_eq!(translated, target);
|
||||
}
|
||||
for profile in ["screen-256color", "tmux-256color"] {
|
||||
let mut translator = super::KeyTranslator::for_profile(Some(profile));
|
||||
assert_eq!(translator.feed(b"\x1bOA", start), b"\x1bOA");
|
||||
}
|
||||
let mut translator = super::KeyTranslator::for_profile(Some("linux"));
|
||||
assert_eq!(
|
||||
translator.feed(b"\x1b[200~paste\x1b[201~", start),
|
||||
b"\x1b[200~paste\x1b[201~"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn signal_scope_restores_mask_and_default_termination() {
|
||||
|
|
|
|||
123
crates/lazaret-client/src/terminal/prompt.rs
Normal file
123
crates/lazaret-client/src/terminal/prompt.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use lazaret_core::records::{DeveloperDecisionKind, TerminalResize};
|
||||
|
||||
use super::{PROMPT_ESCAPE_INTERVAL, PromptOutcome};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum PromptChoice {
|
||||
Approve,
|
||||
Deny,
|
||||
}
|
||||
|
||||
pub(super) enum PromptInput {
|
||||
Continue,
|
||||
Decide(PromptChoice),
|
||||
}
|
||||
|
||||
pub(super) struct PromptInputDecoder {
|
||||
pub(super) choice: PromptChoice,
|
||||
escape: Vec<u8>,
|
||||
escape_deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl PromptInputDecoder {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
choice: PromptChoice::Deny,
|
||||
escape: Vec::new(),
|
||||
escape_deadline: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn feed(&mut self, bytes: &[u8], now: Instant) -> PromptInput {
|
||||
if self.escape_deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.clear_escape();
|
||||
return PromptInput::Decide(PromptChoice::Deny);
|
||||
}
|
||||
for &byte in bytes {
|
||||
if !self.escape.is_empty() || byte == 0x1b {
|
||||
self.escape.push(byte);
|
||||
self.escape_deadline = Some(now + PROMPT_ESCAPE_INTERVAL);
|
||||
match self.escape.as_slice() {
|
||||
b"\x1b[D" | b"\x1bOD" => {
|
||||
self.choice = PromptChoice::Approve;
|
||||
self.clear_escape();
|
||||
}
|
||||
b"\x1b[C" | b"\x1bOC" => {
|
||||
self.choice = PromptChoice::Deny;
|
||||
self.clear_escape();
|
||||
}
|
||||
sequence if supported_prompt_key_prefix(sequence) => {}
|
||||
_ => return PromptInput::Decide(PromptChoice::Deny),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match byte {
|
||||
b'y' | b'Y' => return PromptInput::Decide(PromptChoice::Approve),
|
||||
b'n' | b'N' | 0x03 | 0x04 | 0x1a => {
|
||||
return PromptInput::Decide(PromptChoice::Deny);
|
||||
}
|
||||
b'\r' | b'\n' => return PromptInput::Decide(self.choice),
|
||||
_ => return PromptInput::Decide(PromptChoice::Deny),
|
||||
}
|
||||
}
|
||||
PromptInput::Continue
|
||||
}
|
||||
|
||||
pub(super) fn expire(&mut self, now: Instant) -> PromptInput {
|
||||
if self.escape_deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.clear_escape();
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
} else {
|
||||
PromptInput::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_escape(&mut self) {
|
||||
self.escape.clear();
|
||||
self.escape_deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_prompt_key_prefix(bytes: &[u8]) -> bool {
|
||||
[b"\x1b[D".as_slice(), b"\x1b[C", b"\x1bOD", b"\x1bOC"]
|
||||
.iter()
|
||||
.any(|sequence| sequence.starts_with(bytes))
|
||||
}
|
||||
|
||||
pub(super) fn prompt_outcome(
|
||||
choice: PromptChoice,
|
||||
resumed_size: Option<TerminalResize>,
|
||||
) -> PromptOutcome {
|
||||
PromptOutcome {
|
||||
decision: match choice {
|
||||
PromptChoice::Approve => DeveloperDecisionKind::Approve,
|
||||
PromptChoice::Deny => DeveloperDecisionKind::Deny,
|
||||
},
|
||||
resumed_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn display_argv(argv: &[Vec<u8>]) -> String {
|
||||
let arguments = argv
|
||||
.iter()
|
||||
.map(|argument| format!("\"{}\"", display_bytes(argument)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("[{arguments}]")
|
||||
}
|
||||
|
||||
pub(super) fn display_bytes(bytes: &[u8]) -> String {
|
||||
bytes.iter().fold(String::new(), |mut output, &byte| {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
match byte {
|
||||
b'\\' => output.push_str("\\\\"),
|
||||
b'\"' => output.push_str("\\\""),
|
||||
0x20..=0x7e => output.push(char::from(byte)),
|
||||
_ => write!(output, "\\x{byte:02x}").expect("writing to a String cannot fail"),
|
||||
}
|
||||
output
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue