Change HardwareWallet for (Un)Supported enum
This commit is contained in:
parent
d5baf1bd57
commit
2a9bcf92d0
8
gui/Cargo.lock
generated
8
gui/Cargo.lock
generated
@ -145,9 +145,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-hwi"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b6da8b5e813fa1393db34de31dd4ce826d7603e81867bccd64f5ee216209ee2"
|
||||
checksum = "5df769ed2ee66f45f290602647bfb48f7c652d6d8bb9dd8eb104fdfb0ac48919"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
@ -1629,9 +1629,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ledger_bitcoin_client"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7de8bb8c131c03c33df548b5c45ccc5c20e3f89aa973b4c8bdd539132af7f48f"
|
||||
checksum = "9a8f2e27af48417f8786467d4bd7e0e279c3320ab756391329450eedec59eab6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitcoin",
|
||||
|
||||
@ -14,7 +14,7 @@ name = "liana-gui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-hwi = "0.0.2"
|
||||
async-hwi = "0.0.3"
|
||||
liana = { git = "https://github.com/wizardsardine/liana", branch = "master", default-features = false }
|
||||
backtrace = "0.3"
|
||||
base64 = "0.13"
|
||||
|
||||
@ -297,13 +297,17 @@ impl Action for SignAction {
|
||||
) -> Command<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::Spend(view::SpendTxMessage::SelectHardwareWallet(i))) => {
|
||||
if let Some(hw) = self.hws.get(i) {
|
||||
let device = hw.device.clone();
|
||||
if let Some(HardwareWallet::Supported {
|
||||
fingerprint,
|
||||
device,
|
||||
..
|
||||
}) = self.hws.get(i)
|
||||
{
|
||||
self.chosen_hw = Some(i);
|
||||
self.processing = true;
|
||||
let psbt = tx.psbt.clone();
|
||||
return Command::perform(
|
||||
sign_psbt(device, hw.fingerprint, psbt),
|
||||
sign_psbt(device.clone(), *fingerprint, psbt),
|
||||
Message::Signed,
|
||||
);
|
||||
}
|
||||
@ -331,7 +335,11 @@ impl Action for SignAction {
|
||||
// We add the new hws without dropping the reference of the previous ones.
|
||||
Message::ConnectedHardwareWallets(hws) => {
|
||||
for h in hws {
|
||||
if !self.hws.iter().any(|hw| hw.fingerprint == h.fingerprint) {
|
||||
if !self
|
||||
.hws
|
||||
.iter()
|
||||
.any(|hw| hw.fingerprint() == hw.fingerprint() && hw.kind() == h.kind())
|
||||
{
|
||||
self.hws.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use iced::{
|
||||
widget::{Button, Column, Container, Row},
|
||||
widget::{tooltip, Button, Column, Container, Row},
|
||||
Alignment, Element, Length,
|
||||
};
|
||||
|
||||
@ -17,19 +17,49 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
pub fn hw_list_view<'a>(
|
||||
pub fn hw_list_view(
|
||||
i: usize,
|
||||
hw: &HardwareWallet,
|
||||
chosen: bool,
|
||||
processing: bool,
|
||||
signed: bool,
|
||||
) -> Element<'a, Message> {
|
||||
) -> Element<Message> {
|
||||
let mut bttn = Button::new(
|
||||
Row::new()
|
||||
.push(
|
||||
Column::new()
|
||||
.push(text(format!("{}", hw.kind)).bold())
|
||||
.push(text(format!("fingerprint: {}", hw.fingerprint)).small())
|
||||
.push(text(format!("{}", hw.kind())).bold())
|
||||
.push(match hw {
|
||||
HardwareWallet::Supported {
|
||||
fingerprint,
|
||||
version,
|
||||
..
|
||||
} => Row::new()
|
||||
.spacing(5)
|
||||
.push(text(format!("fingerprint: {}", fingerprint)).small())
|
||||
.push_maybe(
|
||||
version
|
||||
.as_ref()
|
||||
.map(|v| text(format!("version: {}", v)).small()),
|
||||
),
|
||||
HardwareWallet::Unsupported {
|
||||
version, message, ..
|
||||
} => Row::new()
|
||||
.spacing(5)
|
||||
.push_maybe(
|
||||
version
|
||||
.as_ref()
|
||||
.map(|v| text(format!("version: {}", v)).small()),
|
||||
)
|
||||
.push(
|
||||
tooltip::Tooltip::new(
|
||||
icon::warning_icon(),
|
||||
message,
|
||||
tooltip::Position::Bottom,
|
||||
)
|
||||
.style(card::SimpleCardStyle),
|
||||
),
|
||||
})
|
||||
.spacing(5)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
@ -60,7 +90,7 @@ pub fn hw_list_view<'a>(
|
||||
.padding(10)
|
||||
.style(button::Style::Border.into())
|
||||
.width(Length::Fill);
|
||||
if !processing {
|
||||
if !processing && hw.is_supported() {
|
||||
bttn = bttn.on_press(Message::Spend(SpendTxMessage::SelectHardwareWallet(i)));
|
||||
}
|
||||
Container::new(bttn)
|
||||
|
||||
@ -672,7 +672,7 @@ pub fn inputs_and_outputs_view<'a>(
|
||||
|
||||
pub fn sign_action<'a>(
|
||||
warning: Option<&Error>,
|
||||
hws: &[HardwareWallet],
|
||||
hws: &'a [HardwareWallet],
|
||||
processing: bool,
|
||||
chosen_hw: Option<usize>,
|
||||
signed: &[Fingerprint],
|
||||
@ -702,7 +702,9 @@ pub fn sign_action<'a>(
|
||||
hw,
|
||||
Some(i) == chosen_hw,
|
||||
processing,
|
||||
signed.contains(&hw.fingerprint),
|
||||
hw.fingerprint()
|
||||
.map(|f| signed.contains(&f))
|
||||
.unwrap_or(false),
|
||||
))
|
||||
},
|
||||
))
|
||||
|
||||
112
gui/src/hw.rs
112
gui/src/hw.rs
@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_hwi::{ledger, specter, DeviceKind, Error as HWIError, HWI};
|
||||
use async_hwi::{ledger, specter, DeviceKind, Error as HWIError, Version, HWI};
|
||||
use liana::miniscript::bitcoin::{
|
||||
hashes::hex::{FromHex, ToHex},
|
||||
util::bip32::Fingerprint,
|
||||
@ -9,22 +9,50 @@ use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HardwareWallet {
|
||||
pub device: Arc<dyn HWI + Send + Sync>,
|
||||
pub kind: DeviceKind,
|
||||
pub fingerprint: Fingerprint,
|
||||
pub enum HardwareWallet {
|
||||
Unsupported {
|
||||
kind: DeviceKind,
|
||||
version: Option<Version>,
|
||||
message: String,
|
||||
},
|
||||
Supported {
|
||||
device: Arc<dyn HWI + Send + Sync>,
|
||||
kind: DeviceKind,
|
||||
fingerprint: Fingerprint,
|
||||
version: Option<Version>,
|
||||
},
|
||||
}
|
||||
|
||||
impl HardwareWallet {
|
||||
async fn new(device: Arc<dyn HWI + Send + Sync>) -> Result<Self, HWIError> {
|
||||
let kind = device.device_kind();
|
||||
let fingerprint = device.get_master_fingerprint().await?;
|
||||
Ok(Self {
|
||||
let version = device.get_version().await.ok();
|
||||
Ok(Self::Supported {
|
||||
device,
|
||||
kind,
|
||||
fingerprint,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &DeviceKind {
|
||||
match self {
|
||||
Self::Unsupported { kind, .. } => kind,
|
||||
Self::Supported { kind, .. } => kind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fingerprint(&self) -> Option<Fingerprint> {
|
||||
match self {
|
||||
Self::Unsupported { .. } => None,
|
||||
Self::Supported { fingerprint, .. } => Some(*fingerprint),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_supported(&self) -> bool {
|
||||
matches!(self, Self::Supported { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@ -94,14 +122,28 @@ pub async fn list_hardware_wallets(
|
||||
.expect("Configuration must be correct");
|
||||
}
|
||||
|
||||
hws.push(HardwareWallet {
|
||||
kind: device.device_kind(),
|
||||
fingerprint,
|
||||
device: Arc::new(device),
|
||||
});
|
||||
let version = device.get_version().await.ok();
|
||||
if ledger_version_supported(version.as_ref()) {
|
||||
hws.push(HardwareWallet::Supported {
|
||||
kind: device.device_kind(),
|
||||
fingerprint,
|
||||
device: Arc::new(device),
|
||||
version,
|
||||
});
|
||||
} else {
|
||||
hws.push(HardwareWallet::Unsupported {
|
||||
kind: device.device_kind(),
|
||||
version,
|
||||
message: "Minimal supported app version is 2.1.0".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("{}", e);
|
||||
Err(_) => {
|
||||
hws.push(HardwareWallet::Unsupported {
|
||||
kind: device.device_kind(),
|
||||
version: None,
|
||||
message: "Minimal supported app version is 2.1.0".to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
Err(HWIError::DeviceNotFound) => {}
|
||||
@ -124,14 +166,28 @@ pub async fn list_hardware_wallets(
|
||||
.expect("Configuration must be correct");
|
||||
}
|
||||
|
||||
hws.push(HardwareWallet {
|
||||
kind: device.device_kind(),
|
||||
fingerprint,
|
||||
device: Arc::new(device),
|
||||
});
|
||||
let version = device.get_version().await.ok();
|
||||
if ledger_version_supported(version.as_ref()) {
|
||||
hws.push(HardwareWallet::Supported {
|
||||
kind: device.device_kind(),
|
||||
fingerprint,
|
||||
device: Arc::new(device),
|
||||
version,
|
||||
});
|
||||
} else {
|
||||
hws.push(HardwareWallet::Unsupported {
|
||||
kind: device.device_kind(),
|
||||
version,
|
||||
message: "Minimal supported app version is 2.1.0".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("{}", e);
|
||||
Err(_) => {
|
||||
hws.push(HardwareWallet::Unsupported {
|
||||
kind: device.device_kind(),
|
||||
version: None,
|
||||
message: "Minimal supported app version is 2.1.0".to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
Err(HWIError::DeviceNotFound) => {}
|
||||
@ -141,3 +197,19 @@ pub async fn list_hardware_wallets(
|
||||
}
|
||||
hws
|
||||
}
|
||||
|
||||
fn ledger_version_supported(version: Option<&Version>) -> bool {
|
||||
if let Some(version) = version {
|
||||
if version.major >= 2 {
|
||||
if version.major == 2 {
|
||||
version.minor >= 1
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@ -602,18 +602,27 @@ impl DescriptorKeyModal for EditXpubModal {
|
||||
fn update(&mut self, message: Message) -> Command<Message> {
|
||||
match message {
|
||||
Message::Select(i) => {
|
||||
if let Some(hw) = self.hws.get(i) {
|
||||
let device = hw.device.clone();
|
||||
if let Some(HardwareWallet::Supported {
|
||||
device,
|
||||
fingerprint,
|
||||
..
|
||||
}) = self.hws.get(i)
|
||||
{
|
||||
self.chosen_hw = Some(i);
|
||||
self.processing = true;
|
||||
// If another account n exists, the key is retrieved for the account n+1
|
||||
let account_index = self
|
||||
.account_indexes
|
||||
.get(&hw.fingerprint)
|
||||
.get(fingerprint)
|
||||
.map(|account_index| account_index.increment().unwrap())
|
||||
.unwrap_or_else(|| ChildNumber::from_hardened_idx(0).unwrap());
|
||||
return Command::perform(
|
||||
get_extended_pubkey(device, hw.fingerprint, self.network, account_index),
|
||||
get_extended_pubkey(
|
||||
device.clone(),
|
||||
*fingerprint,
|
||||
self.network,
|
||||
account_index,
|
||||
),
|
||||
|res| {
|
||||
Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported(
|
||||
res,
|
||||
@ -781,20 +790,29 @@ impl HardwareWalletXpubs {
|
||||
}
|
||||
|
||||
fn select(&mut self, i: usize, network: Network) -> Command<Message> {
|
||||
let device = self.hw.device.clone();
|
||||
self.processing = true;
|
||||
self.error = None;
|
||||
let fingerprint = self.hw.fingerprint;
|
||||
let next_account = self.next_account;
|
||||
Command::perform(
|
||||
async move {
|
||||
(
|
||||
i,
|
||||
get_extended_pubkey(device, fingerprint, network, next_account).await,
|
||||
)
|
||||
},
|
||||
|(i, res)| Message::ImportXpub(i, res),
|
||||
)
|
||||
if let HardwareWallet::Supported {
|
||||
device,
|
||||
fingerprint,
|
||||
..
|
||||
} = &self.hw
|
||||
{
|
||||
let device = device.clone();
|
||||
let fingerprint = *fingerprint;
|
||||
self.processing = true;
|
||||
self.error = None;
|
||||
let next_account = self.next_account;
|
||||
Command::perform(
|
||||
async move {
|
||||
(
|
||||
i,
|
||||
get_extended_pubkey(device, fingerprint, network, next_account).await,
|
||||
)
|
||||
},
|
||||
|(i, res)| Message::ImportXpub(i, res),
|
||||
)
|
||||
} else {
|
||||
Command::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(&self, i: usize) -> Element<Message> {
|
||||
@ -854,11 +872,9 @@ impl Step for ParticipateXpub {
|
||||
}
|
||||
Message::ConnectedHardwareWallets(hws) => {
|
||||
for hw in hws {
|
||||
if let Some(xpub_hw) = self
|
||||
.xpubs_hw
|
||||
.iter_mut()
|
||||
.find(|h| h.hw.fingerprint == hw.fingerprint)
|
||||
{
|
||||
if let Some(xpub_hw) = self.xpubs_hw.iter_mut().find(|h| {
|
||||
h.hw.fingerprint() == hw.fingerprint() && h.hw.kind() == hw.kind()
|
||||
}) {
|
||||
xpub_hw.hw = hw;
|
||||
} else {
|
||||
self.xpubs_hw.push(HardwareWalletXpubs::new(hw));
|
||||
@ -1021,15 +1037,23 @@ impl Step for RegisterDescriptor {
|
||||
fn update(&mut self, message: Message) -> Command<Message> {
|
||||
match message {
|
||||
Message::Select(i) => {
|
||||
if let Some((hw, hmac, _)) = self.hws.get(i) {
|
||||
if let Some((
|
||||
HardwareWallet::Supported {
|
||||
device,
|
||||
fingerprint,
|
||||
..
|
||||
},
|
||||
hmac,
|
||||
_,
|
||||
)) = self.hws.get(i)
|
||||
{
|
||||
if hmac.is_none() {
|
||||
let device = hw.device.clone();
|
||||
let descriptor = self.descriptor.as_ref().unwrap().to_string();
|
||||
self.chosen_hw = Some(i);
|
||||
self.processing = true;
|
||||
self.error = None;
|
||||
return Command::perform(
|
||||
register_wallet(device, hw.fingerprint, descriptor),
|
||||
register_wallet(device.clone(), *fingerprint, descriptor),
|
||||
Message::WalletRegistered,
|
||||
);
|
||||
}
|
||||
@ -1043,7 +1067,7 @@ impl Step for RegisterDescriptor {
|
||||
if let Some(hw_h) = self
|
||||
.hws
|
||||
.iter_mut()
|
||||
.find(|hw_h| hw_h.0.fingerprint == fingerprint)
|
||||
.find(|hw_h| hw_h.0.fingerprint() == Some(fingerprint))
|
||||
{
|
||||
hw_h.1 = hmac;
|
||||
hw_h.2 = true;
|
||||
@ -1054,11 +1078,9 @@ impl Step for RegisterDescriptor {
|
||||
}
|
||||
Message::ConnectedHardwareWallets(hws) => {
|
||||
for hw in hws {
|
||||
if !self
|
||||
.hws
|
||||
.iter()
|
||||
.any(|(h, _, _)| h.fingerprint == hw.fingerprint)
|
||||
{
|
||||
if !self.hws.iter().any(|(h, _, _)| {
|
||||
h.fingerprint() == hw.fingerprint() && h.kind() == hw.kind()
|
||||
}) {
|
||||
self.hws.push((hw, None, false));
|
||||
}
|
||||
}
|
||||
@ -1073,7 +1095,8 @@ impl Step for RegisterDescriptor {
|
||||
fn apply(&mut self, ctx: &mut Context) -> bool {
|
||||
for (hw, token, registered) in &self.hws {
|
||||
if *registered {
|
||||
ctx.hws.push((hw.kind, hw.fingerprint, *token));
|
||||
ctx.hws
|
||||
.push((*hw.kind(), hw.fingerprint().unwrap(), *token));
|
||||
}
|
||||
}
|
||||
true
|
||||
|
||||
@ -412,7 +412,7 @@ pub fn import_descriptor<'a>(
|
||||
pub fn hardware_wallet_xpubs<'a>(
|
||||
i: usize,
|
||||
xpubs: &'a Vec<String>,
|
||||
hw: &HardwareWallet,
|
||||
hw: &'a HardwareWallet,
|
||||
processing: bool,
|
||||
error: Option<&Error>,
|
||||
) -> Element<'a, Message> {
|
||||
@ -421,8 +421,38 @@ pub fn hardware_wallet_xpubs<'a>(
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
Column::new()
|
||||
.push(text(format!("{}", hw.kind)).bold())
|
||||
.push(text(format!("fingerprint: {}", hw.fingerprint)).small())
|
||||
.push(text(format!("{}", hw.kind())).bold())
|
||||
.push(match hw {
|
||||
HardwareWallet::Supported {
|
||||
fingerprint,
|
||||
version,
|
||||
..
|
||||
} => Row::new()
|
||||
.spacing(5)
|
||||
.push(text(format!("fingerprint: {}", fingerprint)).small())
|
||||
.push_maybe(
|
||||
version
|
||||
.as_ref()
|
||||
.map(|v| text(format!("version: {}", v)).small()),
|
||||
),
|
||||
HardwareWallet::Unsupported {
|
||||
version, message, ..
|
||||
} => Row::new()
|
||||
.spacing(5)
|
||||
.push_maybe(
|
||||
version
|
||||
.as_ref()
|
||||
.map(|v| text(format!("version: {}", v)).small()),
|
||||
)
|
||||
.push(
|
||||
iced::widget::tooltip::Tooltip::new(
|
||||
icon::warning_icon(),
|
||||
message,
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
)
|
||||
.style(card::SimpleCardStyle),
|
||||
),
|
||||
})
|
||||
.spacing(5)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
@ -442,7 +472,7 @@ pub fn hardware_wallet_xpubs<'a>(
|
||||
.padding(10)
|
||||
.style(button::Style::TransparentBorder.into())
|
||||
.width(Length::Fill);
|
||||
if !processing {
|
||||
if !processing && hw.is_supported() {
|
||||
bttn = bttn.on_press(Message::Select(i));
|
||||
}
|
||||
Container::new(
|
||||
@ -576,7 +606,7 @@ pub fn participate_xpub(
|
||||
pub fn register_descriptor<'a>(
|
||||
progress: (usize, usize),
|
||||
descriptor: String,
|
||||
hws: &[(HardwareWallet, Option<[u8; 32]>, bool)],
|
||||
hws: &'a [(HardwareWallet, Option<[u8; 32]>, bool)],
|
||||
error: Option<&Error>,
|
||||
processing: bool,
|
||||
chosen_hw: Option<usize>,
|
||||
@ -1039,7 +1069,7 @@ pub fn defined_descriptor_key(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn edit_key_modal<'a>(
|
||||
network: bitcoin::Network,
|
||||
hws: &[HardwareWallet],
|
||||
hws: &'a [HardwareWallet],
|
||||
error: Option<&Error>,
|
||||
processing: bool,
|
||||
chosen_hw: Option<usize>,
|
||||
@ -1187,19 +1217,49 @@ pub fn edit_key_modal<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn hw_list_view<'a>(
|
||||
fn hw_list_view(
|
||||
i: usize,
|
||||
hw: &HardwareWallet,
|
||||
chosen: bool,
|
||||
processing: bool,
|
||||
registered: bool,
|
||||
) -> Element<'a, Message> {
|
||||
) -> Element<Message> {
|
||||
let mut bttn = Button::new(
|
||||
Row::new()
|
||||
.push(
|
||||
Column::new()
|
||||
.push(text(format!("{}", hw.kind)).bold())
|
||||
.push(text(format!("fingerprint: {}", hw.fingerprint)).small())
|
||||
.push(text(format!("{}", hw.kind())).bold())
|
||||
.push(match hw {
|
||||
HardwareWallet::Supported {
|
||||
fingerprint,
|
||||
version,
|
||||
..
|
||||
} => Row::new()
|
||||
.spacing(5)
|
||||
.push(text(format!("fingerprint: {}", fingerprint)).small())
|
||||
.push_maybe(
|
||||
version
|
||||
.as_ref()
|
||||
.map(|v| text(format!("version: {}", v)).small()),
|
||||
),
|
||||
HardwareWallet::Unsupported {
|
||||
version, message, ..
|
||||
} => Row::new()
|
||||
.spacing(5)
|
||||
.push_maybe(
|
||||
version
|
||||
.as_ref()
|
||||
.map(|v| text(format!("version: {}", v)).small()),
|
||||
)
|
||||
.push(
|
||||
iced::widget::tooltip::Tooltip::new(
|
||||
icon::warning_icon(),
|
||||
message,
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
)
|
||||
.style(card::SimpleCardStyle),
|
||||
),
|
||||
})
|
||||
.spacing(5)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
@ -1223,7 +1283,7 @@ fn hw_list_view<'a>(
|
||||
.padding(10)
|
||||
.style(button::Style::TransparentBorder.into())
|
||||
.width(Length::Fill);
|
||||
if !processing {
|
||||
if !processing && hw.is_supported() {
|
||||
bttn = bttn.on_press(Message::Select(i));
|
||||
}
|
||||
Container::new(bttn)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user