mirror of
https://github.com/sxyazi/yazi.git
synced 2024-09-11 10:26:35 +03:00
feat!: DDS client-server version check (#1111)
This commit is contained in:
parent
add801f28e
commit
e4d67121f8
17
Cargo.lock
generated
17
Cargo.lock
generated
@ -1698,9 +1698,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.202"
|
||||
version = "1.0.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395"
|
||||
checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@ -1717,9 +1717,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.202"
|
||||
version = "1.0.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838"
|
||||
checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -2052,9 +2052,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.37.0"
|
||||
version = "1.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
|
||||
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
@ -2071,9 +2071,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
|
||||
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -2807,6 +2807,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"uzers",
|
||||
"vergen",
|
||||
"yazi-boot",
|
||||
"yazi-shared",
|
||||
]
|
||||
|
@ -25,7 +25,7 @@ imagesize = "0.12.0"
|
||||
kamadak-exif = "0.5.5"
|
||||
ratatui = "0.26.3"
|
||||
scopeguard = "1.2.0"
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
|
||||
# Logging
|
||||
tracing = { version = "0.1.40", features = [ "max_level_debug", "release_max_level_warn" ] }
|
||||
|
@ -15,7 +15,7 @@ yazi-shared = { path = "../yazi-shared", version = "0.2.5" }
|
||||
|
||||
# External dependencies
|
||||
clap = { version = "4.5.4", features = [ "derive" ] }
|
||||
serde = { version = "1.0.202", features = [ "derive" ] }
|
||||
serde = { version = "1.0.203", features = [ "derive" ] }
|
||||
|
||||
[build-dependencies]
|
||||
clap = { version = "4.5.4", features = [ "derive" ] }
|
||||
|
@ -18,7 +18,7 @@ clap = { version = "4.5.4", features = [ "derive" ] }
|
||||
crossterm = "0.27.0"
|
||||
md-5 = "0.10.6"
|
||||
serde_json = "1.0.117"
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
toml_edit = "0.22.13"
|
||||
|
||||
[build-dependencies]
|
||||
|
@ -26,12 +26,12 @@ pub(super) enum Command {
|
||||
|
||||
#[derive(clap::Args)]
|
||||
pub(super) struct CommandPub {
|
||||
/// The receiver ID.
|
||||
#[arg(index = 1)]
|
||||
pub(super) receiver: u64,
|
||||
/// The kind of message.
|
||||
#[arg(index = 2)]
|
||||
#[arg(index = 1)]
|
||||
pub(super) kind: String,
|
||||
/// The receiver ID.
|
||||
#[arg(index = 2)]
|
||||
pub(super) receiver: Option<u64>,
|
||||
/// Send the message with a string body.
|
||||
#[arg(long)]
|
||||
pub(super) str: Option<String>,
|
||||
@ -41,6 +41,17 @@ pub(super) struct CommandPub {
|
||||
}
|
||||
|
||||
impl CommandPub {
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn receiver(&self) -> Result<u64> {
|
||||
if let Some(receiver) = self.receiver {
|
||||
Ok(receiver)
|
||||
} else if let Ok(s) = std::env::var("YAZI_ID") {
|
||||
Ok(s.parse()?)
|
||||
} else {
|
||||
bail!("No receiver ID provided, also no YAZI_ID environment variable found.")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn body(&self) -> Result<Cow<str>> {
|
||||
if let Some(json) = &self.json {
|
||||
@ -48,19 +59,19 @@ impl CommandPub {
|
||||
} else if let Some(str) = &self.str {
|
||||
Ok(serde_json::to_string(str)?.into())
|
||||
} else {
|
||||
bail!("No body provided");
|
||||
Ok("".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(clap::Args)]
|
||||
pub(super) struct CommandPubStatic {
|
||||
/// The severity of the message.
|
||||
#[arg(index = 1)]
|
||||
pub(super) severity: u16,
|
||||
/// The kind of message.
|
||||
#[arg(index = 2)]
|
||||
#[arg(index = 1)]
|
||||
pub(super) kind: String,
|
||||
/// The severity of the message.
|
||||
#[arg(index = 2)]
|
||||
pub(super) severity: u16,
|
||||
/// Send the message with a string body.
|
||||
#[arg(long)]
|
||||
pub(super) str: Option<String>,
|
||||
@ -77,7 +88,7 @@ impl CommandPubStatic {
|
||||
} else if let Some(str) = &self.str {
|
||||
Ok(serde_json::to_string(str)?.into())
|
||||
} else {
|
||||
bail!("No body provided");
|
||||
Ok("".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
match Args::parse().command {
|
||||
Command::Pub(cmd) => {
|
||||
yazi_dds::init();
|
||||
if let Err(e) = yazi_dds::Client::shot(&cmd.kind, cmd.receiver, None, &cmd.body()?).await {
|
||||
if let Err(e) = yazi_dds::Client::shot(&cmd.kind, cmd.receiver()?, None, &cmd.body()?).await {
|
||||
eprintln!("Cannot send message: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ crossterm = "0.27.0"
|
||||
globset = "0.4.14"
|
||||
indexmap = "2.2.6"
|
||||
ratatui = "0.26.3"
|
||||
serde = { version = "1.0.202", features = [ "derive" ] }
|
||||
serde = { version = "1.0.203", features = [ "derive" ] }
|
||||
shell-words = "1.1.0"
|
||||
toml = { version = "0.8.13", features = [ "preserve_order" ] }
|
||||
validator = { version = "0.18.1", features = [ "derive" ] }
|
||||
|
@ -29,9 +29,9 @@ parking_lot = "0.12.3"
|
||||
ratatui = "0.26.3"
|
||||
regex = "1.10.4"
|
||||
scopeguard = "1.2.0"
|
||||
serde = "1.0.202"
|
||||
serde = "1.0.203"
|
||||
shell-words = "1.1.0"
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
tokio-stream = "0.1.15"
|
||||
tokio-util = "0.7.11"
|
||||
unicode-width = "0.1.12"
|
||||
|
@ -8,8 +8,8 @@ bitflags! {
|
||||
pub struct Opt: u8 {
|
||||
const FIND = 0b00001;
|
||||
const VISUAL = 0b00010;
|
||||
const SELECT = 0b00100;
|
||||
const FILTER = 0b01000;
|
||||
const FILTER = 0b00100;
|
||||
const SELECT = 0b01000;
|
||||
const SEARCH = 0b10000;
|
||||
}
|
||||
}
|
||||
@ -21,8 +21,8 @@ impl From<Cmd> for Opt {
|
||||
("all", true) => Self::all(),
|
||||
("find", true) => acc | Self::FIND,
|
||||
("visual", true) => acc | Self::VISUAL,
|
||||
("select", true) => acc | Self::SELECT,
|
||||
("filter", true) => acc | Self::FILTER,
|
||||
("select", true) => acc | Self::SELECT,
|
||||
("search", true) => acc | Self::SEARCH,
|
||||
_ => acc,
|
||||
}
|
||||
@ -36,8 +36,8 @@ impl Tab {
|
||||
if opt.is_empty() {
|
||||
_ = self.escape_find()
|
||||
|| self.escape_visual()
|
||||
|| self.escape_select()
|
||||
|| self.escape_filter()
|
||||
|| self.escape_select()
|
||||
|| self.escape_search();
|
||||
return;
|
||||
}
|
||||
@ -48,12 +48,12 @@ impl Tab {
|
||||
if opt.contains(Opt::VISUAL) {
|
||||
self.escape_visual();
|
||||
}
|
||||
if opt.contains(Opt::SELECT) {
|
||||
self.escape_select();
|
||||
}
|
||||
if opt.contains(Opt::FILTER) {
|
||||
self.escape_filter();
|
||||
}
|
||||
if opt.contains(Opt::SELECT) {
|
||||
self.escape_select();
|
||||
}
|
||||
if opt.contains(Opt::SEARCH) {
|
||||
self.escape_search();
|
||||
}
|
||||
@ -70,6 +70,15 @@ impl Tab {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn escape_filter(&mut self) -> bool {
|
||||
if self.current.files.filter().is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.filter_do(super::filter::Opt::default());
|
||||
render_and!(true)
|
||||
}
|
||||
|
||||
pub fn escape_select(&mut self) -> bool {
|
||||
if self.selected.is_empty() {
|
||||
return false;
|
||||
@ -82,15 +91,6 @@ impl Tab {
|
||||
render_and!(true)
|
||||
}
|
||||
|
||||
pub fn escape_filter(&mut self) -> bool {
|
||||
if self.current.files.filter().is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.filter_do(super::filter::Opt::default());
|
||||
render_and!(true)
|
||||
}
|
||||
|
||||
pub fn escape_search(&mut self) -> bool {
|
||||
if !self.current.cwd.is_search() {
|
||||
return false;
|
||||
|
@ -20,11 +20,14 @@ yazi-shared = { path = "../yazi-shared", version = "0.2.5" }
|
||||
anyhow = "1.0.86"
|
||||
mlua = { version = "0.9.8", features = [ "lua54" ] }
|
||||
parking_lot = "0.12.3"
|
||||
serde = { version = "1.0.202", features = [ "derive" ] }
|
||||
serde = { version = "1.0.203", features = [ "derive" ] }
|
||||
serde_json = "1.0.117"
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
tokio-stream = "0.1.15"
|
||||
tokio-util = "0.7.11"
|
||||
|
||||
[build-dependencies]
|
||||
vergen = { version = "8.3.1", features = [ "build", "git", "gitcl" ] }
|
||||
|
||||
[target."cfg(unix)".dependencies]
|
||||
uzers = "0.12.0"
|
||||
|
9
yazi-dds/build.rs
Normal file
9
yazi-dds/build.rs
Normal file
@ -0,0 +1,9 @@
|
||||
use std::error::Error;
|
||||
|
||||
use vergen::EmitBuilder;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
EmitBuilder::builder().git_sha(true).emit()?;
|
||||
|
||||
Ok(())
|
||||
}
|
@ -4,11 +4,11 @@ use serde::{Deserialize, Serialize};
|
||||
use super::Body;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BodyBye {}
|
||||
pub struct BodyBye;
|
||||
|
||||
impl BodyBye {
|
||||
#[inline]
|
||||
pub fn borrowed() -> Body<'static> { Self {}.into() }
|
||||
pub fn owned() -> Body<'static> { Self.into() }
|
||||
}
|
||||
|
||||
impl<'a> From<BodyBye> for Body<'a> {
|
||||
|
@ -3,12 +3,20 @@ use std::collections::HashMap;
|
||||
use mlua::{ExternalResult, IntoLua, Lua, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Body;
|
||||
use super::{Body, BodyHi};
|
||||
use crate::Peer;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BodyHey {
|
||||
pub peers: HashMap<u64, Peer>,
|
||||
pub peers: HashMap<u64, Peer>,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl BodyHey {
|
||||
#[inline]
|
||||
pub fn owned(peers: HashMap<u64, Peer>) -> Body<'static> {
|
||||
Self { peers, version: BodyHi::version() }.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BodyHey> for Body<'_> {
|
||||
|
@ -8,13 +8,21 @@ use super::Body;
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BodyHi<'a> {
|
||||
pub abilities: HashSet<Cow<'a, String>>,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl<'a> BodyHi<'a> {
|
||||
#[inline]
|
||||
pub fn borrowed(abilities: HashSet<&'a String>) -> Body<'a> {
|
||||
Self { abilities: abilities.into_iter().map(Cow::Borrowed).collect() }.into()
|
||||
Self {
|
||||
abilities: abilities.into_iter().map(Cow::Borrowed).collect(),
|
||||
version: Self::version(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn version() -> String { format!("{} {}", env!("CARGO_PKG_VERSION"), env!("VERGEN_GIT_SHA")) }
|
||||
}
|
||||
|
||||
impl<'a> From<BodyHi<'a>> for Body<'a> {
|
||||
|
@ -1,6 +1,6 @@
|
||||
use std::{collections::{HashMap, HashSet}, mem, str::FromStr};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{bail, Result};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{io::AsyncWriteExt, select, sync::mpsc, task::JoinHandle, time};
|
||||
@ -69,7 +69,7 @@ impl Client {
|
||||
let payload = format!(
|
||||
"{}\n{kind},{receiver},{sender},{body}\n{}\n",
|
||||
Payload::new(BodyHi::borrowed(Default::default())),
|
||||
Payload::new(BodyBye::borrowed())
|
||||
Payload::new(BodyBye::owned())
|
||||
);
|
||||
|
||||
let (mut lines, mut writer) = Stream::connect().await?;
|
||||
@ -77,12 +77,26 @@ impl Client {
|
||||
writer.flush().await?;
|
||||
drop(writer);
|
||||
|
||||
while let Ok(Some(s)) = lines.next_line().await {
|
||||
if matches!(s.split(',').next(), Some(kind) if kind == "bye") {
|
||||
break;
|
||||
let mut version = None;
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
match line.split(',').next() {
|
||||
Some("hey") if version.is_none() => {
|
||||
if let Ok(Body::Hey(hey)) = Payload::from_str(&line).map(|p| p.body) {
|
||||
version = Some(hey.version);
|
||||
}
|
||||
}
|
||||
Some("bye") => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if version != Some(BodyHi::version()) {
|
||||
bail!(
|
||||
"Incompatible version (Ya {}, Yazi {})",
|
||||
BodyHi::version(),
|
||||
version.as_deref().unwrap_or("Unknown")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -2,10 +2,10 @@ use std::{collections::HashMap, str::FromStr, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, select, sync::mpsc, task::JoinHandle, time};
|
||||
use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, select, sync::mpsc::{self, UnboundedReceiver}, task::JoinHandle, time};
|
||||
use yazi_shared::RoCell;
|
||||
|
||||
use crate::{body::{Body, BodyBye, BodyHey}, Client, Payload, Peer, Stream, STATE};
|
||||
use crate::{body::{Body, BodyBye, BodyHey}, Client, ClientWriter, Payload, Peer, Stream, STATE};
|
||||
|
||||
pub(super) static CLIENTS: RoCell<RwLock<HashMap<u64, Client>>> = RoCell::new();
|
||||
|
||||
@ -44,7 +44,7 @@ impl Server {
|
||||
|
||||
let Some(id) = id else { continue };
|
||||
if line.starts_with("bye,") {
|
||||
writer.write_all(BodyBye::borrowed().with_receiver(id).with_sender(0).to_string().as_bytes()).await.ok();
|
||||
Self::handle_bye(id, rx, writer).await;
|
||||
break;
|
||||
}
|
||||
|
||||
@ -77,7 +77,11 @@ impl Server {
|
||||
else => break
|
||||
}
|
||||
}
|
||||
Self::handle_bye(id);
|
||||
|
||||
let mut clients = CLIENTS.write();
|
||||
if id.and_then(|id| clients.remove(&id)).is_some() {
|
||||
Self::handle_hey(&clients);
|
||||
}
|
||||
});
|
||||
}
|
||||
}))
|
||||
@ -111,18 +115,24 @@ impl Server {
|
||||
fn handle_hey(clients: &HashMap<u64, Client>) {
|
||||
let payload = format!(
|
||||
"{}\n",
|
||||
Payload::new(
|
||||
BodyHey { peers: clients.values().map(|c| (c.id, Peer::new(&c.abilities))).collect() }
|
||||
.into()
|
||||
)
|
||||
Payload::new(BodyHey::owned(
|
||||
clients.values().map(|c| (c.id, Peer::new(&c.abilities))).collect()
|
||||
))
|
||||
);
|
||||
clients.values().for_each(|c| _ = c.tx.send(payload.clone()));
|
||||
}
|
||||
|
||||
fn handle_bye(id: Option<u64>) {
|
||||
let mut clients = CLIENTS.write();
|
||||
if id.and_then(|id| clients.remove(&id)).is_some() {
|
||||
Self::handle_hey(&clients);
|
||||
async fn handle_bye(id: u64, mut rx: UnboundedReceiver<String>, mut writer: ClientWriter) {
|
||||
while let Ok(payload) = rx.try_recv() {
|
||||
if writer.write_all(payload.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_ = writer
|
||||
.write_all(BodyBye::owned().with_receiver(id).with_sender(0).to_string().as_bytes())
|
||||
.await;
|
||||
|
||||
writer.flush().await.ok();
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ mlua = { version = "0.9.8", features = [ "lua54" ] }
|
||||
ratatui = "0.26.3"
|
||||
scopeguard = "1.2.0"
|
||||
syntect = { version = "5.2.0", default-features = false, features = [ "parsing", "plist-load", "regex-onig" ] }
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
tokio-util = "0.7.11"
|
||||
|
||||
# Logging
|
||||
|
@ -30,12 +30,12 @@ md-5 = "0.10.6"
|
||||
mlua = { version = "0.9.8", features = [ "lua54", "serialize", "macros", "async" ] }
|
||||
parking_lot = "0.12.3"
|
||||
ratatui = "0.26.3"
|
||||
serde = "1.0.202"
|
||||
serde = "1.0.203"
|
||||
serde_json = "1.0.117"
|
||||
shell-escape = "0.1.5"
|
||||
shell-words = "1.1.0"
|
||||
syntect = { version = "5.2.0", default-features = false, features = [ "parsing", "plist-load", "regex-onig" ] }
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
tokio-stream = "0.1.15"
|
||||
tokio-util = "0.7.11"
|
||||
unicode-width = "0.1.12"
|
||||
|
@ -19,4 +19,4 @@ yazi-shared = { path = "../yazi-shared", version = "0.2.5" }
|
||||
# External dependencies
|
||||
anyhow = "1.0.86"
|
||||
mlua = { version = "0.9.8", features = [ "lua54" ] }
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
|
@ -21,7 +21,7 @@ async-priority-channel = "0.2.0"
|
||||
futures = "0.3.30"
|
||||
parking_lot = "0.12.3"
|
||||
scopeguard = "1.2.0"
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
|
||||
# Logging
|
||||
tracing = { version = "0.1.40", features = [ "max_level_debug", "release_max_level_warn" ] }
|
||||
|
@ -20,9 +20,9 @@ parking_lot = "0.12.3"
|
||||
percent-encoding = "2.3.1"
|
||||
ratatui = "0.26.3"
|
||||
regex = "1.10.4"
|
||||
serde = { version = "1.0.202", features = [ "derive" ] }
|
||||
serde = { version = "1.0.203", features = [ "derive" ] }
|
||||
shell-words = "1.1.0"
|
||||
tokio = { version = "1.37.0", features = [ "full" ] }
|
||||
tokio = { version = "1.38.0", features = [ "full" ] }
|
||||
|
||||
# Logging
|
||||
tracing = { version = "0.1.40", features = [ "max_level_debug", "release_max_level_warn" ] }
|
||||
|
Loading…
Reference in New Issue
Block a user