use reqwest::Url;
-use std::fs::File;
-use std::io::Write;
-use super::auth::{AuthConfig, AuthError};
-use super::client::{Client, ClientError, AppTokenType};
+use super::auth::AuthConfig;
+use super::client::{AppTokenType, Client, ClientError};
+use super::coloured_string::*;
use super::config::ConfigLocation;
+use super::editor::EditableMenuLine;
+use super::text::*;
+use super::tui::{
+ ActivityState, CursorPosition, LogicalAction, OurKey, OurKey::*,
+};
use super::types::{Account, Application, Instance};
use super::TopLevelError;
-pub fn login(
- cfgloc: &ConfigLocation,
- instance_url: &str,
- logfile: Option<File>,
-) -> Result<(), TopLevelError> {
- // First, check we aren't logged in already, and give some
- // marginally useful advice on what to do if we are.
- match AuthConfig::load(cfgloc) {
- Err(AuthError::Nonexistent(..)) => Ok(()),
- Ok(auth) => Err(TopLevelError::new("", &format!(
- "you are already logged in as {0}@{1}! Use --config to specify a separate configuration directory for another login, or delete {2} to remove the existing login details",
- auth.username.as_deref().unwrap_or("<None>"), auth.instance_domain.as_deref().unwrap_or("<None>"), cfgloc.get_path("auth").display()))),
- Err(e) => Err(TopLevelError::from(e)),
- }?;
-
- // Ergonomics: parse the URL, adding a default https:// scheme if
- // it's just a bare hostname
- let urlstr = match instance_url.find('/') {
- Some(_) => instance_url.to_owned(),
- None => format!("https://{instance_url}"),
- };
- let url = match Url::parse(&urlstr) {
- Ok(url) => Ok(url),
- Err(e) => {
- Err(ClientError::UrlParseError(urlstr.clone(), e.to_string()))
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum LoginState {
+ Initial,
+ ServerValid,
+ LoginToken,
+ LoginTokenError,
+ LoginSuccess,
+ LoginFailure,
+}
+
+struct LoginMenu {
+ cfgloc: ConfigLocation,
+ title: FileHeader,
+ alignment_line: MenuKeypressLine,
+ para_intro: Paragraph,
+ para_server_id: Paragraph,
+ el_server: EditableMenuLine<String>,
+ ml_login: MenuKeypressLine,
+ para_login_instructions: Paragraph,
+ para_login_url: Paragraph,
+ el_logincode: EditableMenuLine<String>,
+ para_login_outcome: Paragraph,
+ state: LoginState,
+ application: Option<Application>,
+}
+
+impl LoginMenu {
+ fn new(cfgloc: ConfigLocation) -> Self {
+ let title = FileHeader::new(ColouredString::uniform(
+ &format!("Log in to a Mastodon server"),
+ 'H',
+ ));
+
+ let alignment_line = MenuKeypressLine::new(
+ Pr('X'),
+ ColouredString::plain(
+ "Authorisation code: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ ),
+ );
+ let para_intro = Paragraph::new().set_centred(true).set_indent(10, 10)
+ .add(ColouredString::general(
+ "To log in to a Mastodon server, enter the URL of its website. (Not always the same as the domain name that appears in its usernames.)",
+ "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=======HHHHHHH======HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH"));
+ let el_server = EditableMenuLine::new(
+ Pr('S'),
+ ColouredString::plain("Server URL: "),
+ "https://".to_owned(),
+ );
+ let ml_login = MenuKeypressLine::new(
+ Pr('L'),
+ ColouredString::uniform(
+ "Log in to this server as an existing user",
+ 'H',
+ ),
+ );
+
+ let para_server_id =
+ Paragraph::new().set_centred(true).set_indent(10, 10);
+ let para_login_instructions =
+ Paragraph::new().set_centred(true).set_indent(10, 10);
+ let para_login_url = Paragraph::new();
+
+ let el_logincode = EditableMenuLine::new(
+ Pr('C'),
+ ColouredString::plain("Authorisation code: "),
+ "".to_owned(),
+ );
+
+ let para_login_outcome =
+ Paragraph::new().set_centred(true).set_indent(10, 10);
+
+ let mut menu = LoginMenu {
+ cfgloc,
+ title,
+ alignment_line,
+ para_intro,
+ el_server,
+ ml_login,
+ para_server_id,
+ para_login_instructions,
+ para_login_url,
+ el_logincode,
+ para_login_outcome,
+ state: LoginState::Initial,
+ application: None,
+ };
+ menu.fix_widths();
+ menu.el_server.start_editing();
+ menu
+ }
+
+ fn fix_widths(&mut self) -> (usize, usize) {
+ let mut lmaxwid = 0;
+ let mut rmaxwid = 0;
+ self.alignment_line.check_widths(&mut lmaxwid, &mut rmaxwid);
+ self.el_server.check_widths(&mut lmaxwid, &mut rmaxwid);
+ self.ml_login.check_widths(&mut lmaxwid, &mut rmaxwid);
+ self.el_logincode.check_widths(&mut lmaxwid, &mut rmaxwid);
+
+ self.el_server.reset_widths();
+ self.ml_login.reset_widths();
+ self.el_logincode.reset_widths();
+
+ self.el_server.ensure_widths(lmaxwid, rmaxwid);
+ self.ml_login.ensure_widths(lmaxwid, rmaxwid);
+ self.el_logincode.ensure_widths(lmaxwid, rmaxwid);
+
+ (lmaxwid, rmaxwid)
+ }
+
+ fn verify_server(&mut self, client: &mut Client) -> LogicalAction {
+ self.para_server_id.clear();
+
+ let instance_url = self.el_server.get_data();
+ let urlstr = match instance_url.find('/') {
+ Some(_) => instance_url.to_owned(),
+ None => format!("https://{instance_url}"),
+ };
+ let url = match Url::parse(&urlstr) {
+ Ok(url) => url,
+ Err(e) => {
+ self.para_server_id.push_text(
+ ColouredString::uniform(
+ &format!("Error parsing the URL: {e}"),
+ 'r',
+ ),
+ false,
+ );
+ self.state = LoginState::Initial;
+ return LogicalAction::Beep;
+ }
+ };
+ let instance_url = url.as_str().trim_end_matches('/');
+ self.el_server.set_data(instance_url.to_owned());
+ client.auth = AuthConfig::default();
+ client.auth.instance_url = Some(instance_url.to_owned());
+
+ client.clear_caches();
+ let inst_fallible = client.instance();
+ client.clear_caches();
+ self.application = None;
+
+ let action = match inst_fallible {
+ Ok(instance) => {
+ self.para_server_id.push_text(
+ ColouredString::uniform(
+ "OK! This is the Mastodon instance ",
+ 'H',
+ ),
+ false,
+ );
+ self.para_server_id.push_text(
+ ColouredString::uniform(&instance.domain, 'K'),
+ false,
+ );
+ self.para_server_id
+ .push_text(ColouredString::uniform(".", 'H'), false);
+ self.state = LoginState::ServerValid;
+ LogicalAction::Nothing
+ }
+ Err(e) => {
+ self.para_server_id.push_text(
+ ColouredString::uniform(
+ &format!("Error confirming that instance: {e}"),
+ 'r',
+ ),
+ false,
+ );
+ client.auth = AuthConfig::default();
+ self.state = LoginState::Initial;
+ LogicalAction::Beep
+ }
+ };
+
+ self.fix_widths();
+ action
+ }
+
+ fn get_login_url_fallible(
+ &mut self,
+ client: &mut Client,
+ ) -> Result<String, ClientError> {
+ // Register the client and get its details
+ let app = client.register_client()?;
+ let app_token =
+ client.get_app_token(&app, AppTokenType::ClientCredentials)?;
+ client.auth.user_token = Some(app_token.access_token);
+ let _app: Application = client.verify_app_credentials()?;
+
+ // Get the URL the user will have to visit
+ let url = client.get_auth_url(&app)?;
+
+ self.application = Some(app);
+
+ Ok(url)
+ }
+
+ fn get_login_url(&mut self, client: &mut Client) -> LogicalAction {
+ match self.get_login_url_fallible(client) {
+ Ok(url) => {
+ self.para_login_instructions.clear();
+ self.para_login_instructions.push_text(
+ ColouredString::uniform(
+ "Log in to your user account on the instance website ",
+ 'H',
+ ),
+ false,
+ );
+ self.para_login_instructions.push_text(
+ ColouredString::uniform(
+ client.auth.instance_url.as_deref().expect(
+ "In this login state we should have a URL!",
+ ),
+ 'u',
+ ),
+ false,
+ );
+ self.para_login_instructions.push_text(
+ ColouredString::uniform(
+ " and then visit this URL to get an authorisation code for Mastodonochrome:",
+ 'H',
+ ),
+ false,
+ );
+ self.para_login_url.clear();
+ self.para_login_url
+ .push_text(ColouredString::uniform(&url, 'u'), false);
+ self.state = LoginState::LoginToken;
+ self.el_logincode.start_editing();
+ LogicalAction::Nothing
+ }
+ Err(e) => {
+ self.para_login_instructions.clear();
+ self.para_login_instructions.push_text(
+ ColouredString::uniform(
+ &format!("Error setting up the login: {e}"),
+ 'r',
+ ),
+ false,
+ );
+ self.para_login_url.clear();
+ self.state = LoginState::LoginTokenError;
+ LogicalAction::Beep
+ }
}
- }?;
- let instance_url = url.as_str().trim_end_matches('/');
-
- let mut client = Client::new(AuthConfig::default())?;
- client.set_logfile(logfile);
- client.set_writable(true);
- client.auth.instance_url = Some(instance_url.to_owned());
-
- // Register the client and get its details
- let app = client.register_client()?;
- let app_token = client.get_app_token(&app, AppTokenType::ClientCredentials)?;
- client.auth.user_token = Some(app_token.access_token);
- let _app: Application = client.verify_app_credentials()?;
-
- // Get the URL the user will have to visit
- let url = client.get_auth_url(&app)?;
-
- // Print it
- println!("Log in to the website {instance_url}/");
- println!("and then visit this URL to authorise Mastodonochrome:");
- println!();
- println!("{}", url);
- println!();
- println!("If you click \"Authorise\" on that page, you should receive a response code.");
- print!("Enter that code here: ");
- std::io::stdout().flush().unwrap(); // FIXME
-
- // Read the user's response
- let code = {
- let mut buf = String::new();
- std::io::stdin().read_line(&mut buf).unwrap(); // FIXME
- buf
- };
- let code = code.trim_end();
-
- // Use that code to get the final user access token
- let token = client.get_app_token(&app, AppTokenType::AuthorizationCode(code))?;
- client.auth.user_token = Some(token.access_token.clone());
- let account: Account = client.verify_account_credentials()?;
- let instance: Instance = client.instance()?;
-
- println!();
- println!(
- "Successfully logged in as {}@{}",
- account.username, instance.domain
- );
- println!("Now run 'mastodonochrome' without arguments to read and post!");
-
- // Save everything!
- let auth = AuthConfig {
- account_id: Some(account.id),
- username: Some(account.username),
- instance_url: Some(instance_url.to_owned()),
- instance_domain: Some(instance.domain),
- client_id: Some(app.client_id.unwrap()),
- client_secret: Some(app.client_secret.unwrap()),
- user_token: Some(token.access_token),
- };
-
- let mut json = serde_json::to_string_pretty(&auth).unwrap();
- json.push('\n');
- cfgloc.create_file("auth", &json)?;
-
- Ok(())
+ }
+
+ fn verify_login_code_fallible(
+ &mut self,
+ client: &mut Client,
+ ) -> Result<(), TopLevelError> {
+ let code = self.el_logincode.get_data().trim();
+ let app = self
+ .application
+ .as_ref()
+ .expect("Can't get here without registering an application");
+
+ // Use our code to get the final user access token and all the
+ // account details
+ let token = client
+ .get_app_token(app, AppTokenType::AuthorizationCode(code))?;
+ client.auth.user_token = Some(token.access_token.clone());
+ let account: Account = client.verify_account_credentials()?;
+ let instance: Instance = client.instance()?;
+
+ client.auth = AuthConfig {
+ account_id: Some(account.id),
+ username: Some(account.username),
+ instance_url: client.auth.instance_url.clone(),
+ instance_domain: Some(instance.domain),
+ client_id: Some(app.client_id.clone().unwrap()),
+ client_secret: Some(app.client_secret.clone().unwrap()),
+ user_token: Some(token.access_token),
+ };
+
+ let mut json = serde_json::to_string_pretty(&client.auth).unwrap();
+ json.push('\n');
+ self.cfgloc.create_file("auth", &json)?;
+
+ Ok(())
+ }
+
+ fn verify_login_code(&mut self, client: &mut Client) -> LogicalAction {
+ match self.verify_login_code_fallible(client) {
+ Ok(_) => {
+ self.para_login_outcome.clear();
+ self.para_login_outcome.push_text(
+ ColouredString::uniform("Success! Logged in as ", 'H'),
+ false,
+ );
+ self.para_login_outcome.push_text(
+ ColouredString::uniform(&client.our_account_fq(), 'K'),
+ false,
+ );
+ self.para_login_outcome.push_text(
+ ColouredString::general(
+ ". Now press [SPACE] to continue to the Main Menu.",
+ "HHHHHHHHHHHHHKKKKKHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH",
+ ),
+ false,
+ );
+ self.state = LoginState::LoginSuccess;
+ LogicalAction::Nothing
+ }
+ Err(e) => {
+ self.para_login_outcome.clear();
+ self.para_login_outcome.push_text(
+ ColouredString::uniform(
+ &format!("Error logging in: {e}"),
+ 'r',
+ ),
+ false,
+ );
+ self.state = LoginState::LoginFailure;
+ LogicalAction::Beep
+ }
+ }
+ }
+}
+
+impl ActivityState for LoginMenu {
+ fn draw(
+ &self,
+ w: usize,
+ h: usize,
+ ) -> (Vec<ColouredString>, CursorPosition) {
+ let push_split_lines =
+ |lines: &mut Vec<ColouredString>, output: Vec<ColouredString>| {
+ for line in output {
+ for frag in line.split(w.saturating_sub(1)) {
+ lines.push(frag.into());
+ }
+ }
+ };
+ let mut lines = Vec::new();
+ let mut cursorpos = CursorPosition::End;
+ lines.extend_from_slice(&self.title.render(w));
+ lines.extend_from_slice(&BlankLine::render_static());
+ push_split_lines(&mut lines, self.para_intro.render(w));
+ lines.extend_from_slice(&BlankLine::render_static());
+ lines.push(self.el_server.render(w, &mut cursorpos, lines.len()));
+ push_split_lines(&mut lines, self.para_server_id.render(w));
+ if self.state == LoginState::ServerValid {
+ lines.extend_from_slice(&BlankLine::render_static());
+ lines.extend_from_slice(&self.ml_login.render(w));
+ }
+ if self.state == LoginState::LoginToken
+ || self.state == LoginState::LoginTokenError
+ || self.state == LoginState::LoginSuccess
+ || self.state == LoginState::LoginFailure
+ {
+ lines.extend_from_slice(&BlankLine::render_static());
+ push_split_lines(
+ &mut lines,
+ self.para_login_instructions.render(w),
+ );
+ }
+ if self.state == LoginState::LoginToken
+ || self.state == LoginState::LoginSuccess
+ || self.state == LoginState::LoginFailure
+ {
+ lines.extend_from_slice(&BlankLine::render_static());
+ push_split_lines(&mut lines, self.para_login_url.render(w));
+ lines.extend_from_slice(&BlankLine::render_static());
+ lines.push(self.el_logincode.render(
+ w,
+ &mut cursorpos,
+ lines.len(),
+ ));
+ }
+ if self.state == LoginState::LoginSuccess
+ || self.state == LoginState::LoginFailure
+ {
+ lines.extend_from_slice(&BlankLine::render_static());
+ push_split_lines(&mut lines, self.para_login_outcome.render(w));
+ }
+
+ while lines.len() + 1 < h {
+ lines.extend_from_slice(&BlankLine::render_static());
+ }
+
+ let status = FileStatusLine::new();
+
+ let status = match self.state {
+ LoginState::Initial => {
+ if self.el_server.is_editing() {
+ status.message("Enter server URL and press Return")
+ } else {
+ status.add(Pr('Q'), "Quit", 100)
+ }
+ }
+ LoginState::LoginToken => {
+ if self.el_server.is_editing() {
+ status.message("Enter code from server and press Return")
+ } else {
+ status.add(Pr('Q'), "Quit", 100)
+ }
+ }
+ LoginState::LoginSuccess => status.add(Space, "Main Menu", 100),
+ _ => status.add(Pr('Q'), "Quit", 100),
+ }
+ .finalise();
+ lines.extend_from_slice(&status.render(w));
+
+ (lines, cursorpos)
+ }
+
+ fn handle_keypress(
+ &mut self,
+ key: OurKey,
+ client: &mut Client,
+ ) -> LogicalAction {
+ // Let editable menu lines have first crack at the keypress
+ if self.el_server.handle_keypress(key) {
+ self.fix_widths();
+ if !self.el_server.is_editing() {
+ return self.verify_server(client);
+ }
+ return LogicalAction::Nothing;
+ } else if self.el_logincode.handle_keypress(key) {
+ self.fix_widths();
+ if !self.el_logincode.is_editing() {
+ return self.verify_login_code(client);
+ }
+ return LogicalAction::Nothing;
+ }
+
+ match key {
+ Pr('s') | Pr('S') => match self.state {
+ LoginState::Initial | LoginState::ServerValid => {
+ self.state = LoginState::Initial;
+ self.el_server.start_editing()
+ }
+ _ => LogicalAction::Nothing,
+ },
+ Pr('l') | Pr('L') => {
+ if self.state == LoginState::ServerValid {
+ self.get_login_url(client)
+ } else {
+ LogicalAction::Nothing
+ }
+ }
+ Pr('c') | Pr('C') => {
+ if self.state == LoginState::LoginToken
+ || self.state == LoginState::LoginFailure
+ {
+ self.el_logincode.start_editing()
+ } else {
+ LogicalAction::Nothing
+ }
+ }
+ Space => {
+ if self.state == LoginState::LoginSuccess {
+ LogicalAction::FinishedLoggingIn
+ } else {
+ LogicalAction::Nothing
+ }
+ }
+
+ // In this initial screen we don't require ESC X X to get out
+ Pr('q') | Pr('Q') => LogicalAction::Exit,
+
+ _ => LogicalAction::Nothing,
+ }
+ }
+
+ fn resize(&mut self, w: usize, _h: usize) {
+ self.el_server.resize(w);
+ self.el_logincode.resize(w);
+ }
+}
+
+pub fn login_menu(cfgloc: ConfigLocation) -> Box<dyn ActivityState> {
+ Box::new(LoginMenu::new(cfgloc))
}
use super::config::ConfigLocation;
use super::editor::*;
use super::file::*;
+use super::login::login_menu;
use super::menu::*;
use super::options::*;
use super::posting::*;
// ~~~~~ underline in file headers
'~' => Style::default().fg(Color::Blue),
- // actual header text in file headers
+ // actual header text in file headers and other client-originated
+ // instructions or information
'H' => Style::default().fg(Color::Cyan),
// keypress / keypath names in file headers
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
+ // underlined text for emphasis in 'H' text
+ '=' => Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::UNDERLINED),
+
// keypresses in file status lines
'k' => Style::default().add_modifier(Modifier::BOLD),
Beep,
Refresh,
Exit,
+ MainSessionSetup,
Error(TuiError),
}
impl From<AuthError> for TuiError {
fn from(err: AuthError) -> Self {
TuiError {
- message: format!("unable to read authentication: {}", err)
+ message: format!("unable to read authentication: {}", err),
}
}
}
}
});
- let auth = AuthConfig::load(cfgloc)?;
+ let auth = match AuthConfig::load(cfgloc) {
+ // If we don't have any authentication at all, that's OK,
+ // and we put the TUI into login mode
+ Err(AuthError::Nonexistent(..)) => Ok(AuthConfig::default()),
+
+ // Pass through any other Ok or Err, the latter to be
+ // handled by the ?
+ x => x,
+ }?;
+
let mut client = Client::new(auth)?;
client.set_writable(!readonly);
client.set_logfile(logfile);
}
}
- fn run_inner(&mut self) -> Result<(), TuiError> {
+ fn main_session_setup(&mut self) -> Result<(), TuiError> {
self.start_streaming_subthread(StreamId::User)?;
self.start_timing_subthread(
Duration::from_secs(120),
Self::beep()?;
}
+ Ok(())
+ }
+
+ fn run_inner(&mut self) -> Result<(), TuiError> {
+ if self.client.auth.is_logged_in() {
+ self.main_session_setup()?;
+ }
+
self.main_loop()
}
fn main_loop(&mut self) -> Result<(), TuiError> {
'outer: loop {
- let state = &mut self.state;
-
- self.terminal.draw(|frame| {
- let area = frame.size();
- let buf = frame.buffer_mut();
- if let Some((x, y)) = state.draw_frame(area, buf) {
- if let (Ok(x), Ok(y)) = (x.try_into(), y.try_into()) {
- frame.set_cursor(x, y);
+ {
+ let state = &mut self.state;
+
+ self.terminal.draw(|frame| {
+ let area = frame.size();
+ let buf = frame.buffer_mut();
+ if let Some((x, y)) = state.draw_frame(area, buf) {
+ if let (Ok(x), Ok(y)) = (x.try_into(), y.try_into()) {
+ frame.set_cursor(x, y);
+ }
}
- }
- })?;
+ })?;
+ }
// One physical keypress can break down into multiple
// things we treat as logical keypresses. So we must do an
Ok(SubthreadEvent::TermEv(ev)) => match ev {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
- state.new_event();
+ self.state.new_event();
Self::translate_keypress(key)
.into_iter()
.map(|key| Todo::Keypress(key))
}
}
Ok(SubthreadEvent::LDBCheckpointTimer) => {
- state.checkpoint_ldb();
+ self.state.checkpoint_ldb();
Vec::new()
}
};
for todo in todos {
let physact = match todo {
Todo::Keypress(ourkey) => {
- state.handle_keypress(ourkey, &mut self.client)
+ self.state.handle_keypress(ourkey, &mut self.client)
}
- Todo::Stream(feeds_updated) => state
+ Todo::Stream(feeds_updated) => self.state
.handle_feed_updates(feeds_updated, &mut self.client),
};
PhysicalAction::Exit => break 'outer Ok(()),
PhysicalAction::Refresh => self.terminal.clear()?,
PhysicalAction::Error(err) => break 'outer Err(err),
+ PhysicalAction::MainSessionSetup => {
+ self.main_session_setup()?
+ }
PhysicalAction::Nothing => (),
}
}
PopOverlayBeep,
GotSearchExpression(SearchDirection, String),
Goto(Activity),
+ FinishedLoggingIn,
Exit,
Nothing,
Error(ClientError), // throw UI into the Error Log
impl TuiLogicalState {
fn new(client: &Client, cfgloc: ConfigLocation) -> Self {
- let activity_stack = ActivityStack::new(NonUtilityActivity::MainMenu);
- let activity_state = main_menu(client);
+ let (activity, activity_state) = if client.auth.is_logged_in() {
+ (NonUtilityActivity::MainMenu, main_menu(client))
+ } else {
+ (NonUtilityActivity::LoginMenu, login_menu(cfgloc.clone()))
+ };
+ let activity_stack = ActivityStack::new(activity);
TuiLogicalState {
activity_stack,
self.changed_activity(client, None, false);
break PhysicalAction::Nothing;
}
+ LogicalAction::FinishedLoggingIn => {
+ self.activity_stack
+ .goto(NonUtilityActivity::MainMenu.into());
+ self.changed_activity(client, None, false);
+ break PhysicalAction::MainSessionSetup;
+ }
LogicalAction::Pop => {
self.activity_stack.pop();
self.changed_activity(client, None, false);
Activity::NonUtil(NonUtilityActivity::MainMenu) => {
Ok(main_menu(client))
}
+ Activity::NonUtil(NonUtilityActivity::LoginMenu) => {
+ Ok(login_menu(self.cfgloc.clone()))
+ }
Activity::Util(UtilityActivity::UtilsMenu) => {
Ok(utils_menu(client))
}