#![cfg(feature = "client_base")]
pub mod colors;
pub mod tui;
use std::
{
iter,
process,
fs::File,
path::PathBuf,
sync::Arc,
io::{ Read, Seek },
};
use tokio::
{
task,
net::tcp::OwnedWriteHalf,
sync::
{
Mutex as MutexAsync,
mpsc::{ self, Sender },
},
};
use sha2::{ Sha256, Digest };
use unicode_width::UnicodeWidthStr;
use ratatui::text::{ Line, Span };
use tui::
{
theme,
palette,
App,
TerminalGuard,
settings::Devices,
};
#[cfg(feature = "client_voice")]
use tui::settings::DeviceEntry;
use why2_chat::
{
config,
consts,
misc,
role::{ self, Role },
options::{ self, LoginState },
command::
{
self,
Command,
Subcommand,
},
network::
{
self,
client::{ self, ClientEvent, image as client_image },
codes::
{
PacketCode,
Device,
},
},
};
#[cfg(feature = "client_voice")]
use why2_chat::network::voice::client as voice;
#[cfg(feature = "client_screen")]
use winit::event_loop::EventLoop;
#[cfg(feature = "client_screen")]
use why2_chat::network::screen::client::
{
self as screen,
UserEvent,
display::ScreenShareApp,
};
fn invalid_usage(app: &mut App, subject: Option<&str>) {
app.push_styled(format!("Invalid {}! Press Ctrl+H for help.", subject.unwrap_or("usage")), theme::ERROR);
}
async fn server_command(app: &mut App, write_stream: &Arc<MutexAsync<OwnedWriteHalf>>, parameters: Option<String>)
{
let Some(info) = command::COMMAND_LIST.iter().find(|info| info.command == Command::Server) else { return };
if !info.available(app.role) { return invalid_usage(app, Some("command")); }
let Some(parameters) = parameters else { return invalid_usage(app, None) };
let (action, tail) = match parameters.split_once(char::is_whitespace)
{
Some((action, tail)) => (action, tail.trim()),
None => (parameters.as_str(), ""),
};
let Some(sub) = info.action(action) else { return invalid_usage(app, Some("action")) };
if !sub.available(app.role) { return invalid_usage(app, Some("action")); }
if !sub.args.is_empty() && tail.is_empty() { return invalid_usage(app, None); }
let id = match sub.takes_id()
{
true => match tail.parse::<usize>()
{
Ok(id) => Some(id),
Err(_) => return invalid_usage(app, None),
},
false => None,
};
match sub.subcommand
{
Subcommand::Mute =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerMute
{
id: id.unwrap(),
}, options::get_keys().as_ref()).await;
},
Subcommand::Kick =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerKick
{
id: id.unwrap(),
}, options::get_keys().as_ref()).await;
},
Subcommand::Ban =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerBan
{
id: id.unwrap(),
}, options::get_keys().as_ref()).await;
},
Subcommand::BanIp =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerBanIp
{
id: id.unwrap(),
}, options::get_keys().as_ref()).await;
},
Subcommand::Bans =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerBansRequest,
options::get_keys().as_ref()).await;
},
Subcommand::Pardon =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerPardon
{
id: id.unwrap(),
}, options::get_keys().as_ref()).await;
},
Subcommand::PardonIp =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerPardonIp
{
id: id.unwrap(),
}, options::get_keys().as_ref()).await;
},
Subcommand::Say =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerSay
{
message: tail.to_owned(),
}, options::get_keys().as_ref()).await;
},
Subcommand::Role =>
{
let Some((target, role)) = tail.split_once(char::is_whitespace) else { return invalid_usage(app, None) };
let Ok(target) = target.parse::<usize>() else { return invalid_usage(app, None) };
let Ok(role) = role.trim().parse::<Role>() else { return invalid_usage(app, Some("role")) };
network::send(&mut *write_stream.lock().await, PacketCode::ServerRoleRequest
{
id: target,
role,
}, options::get_keys().as_ref()).await;
},
Subcommand::Settings =>
{
network::send(&mut *write_stream.lock().await, PacketCode::ServerSettingsRequest,
options::get_keys().as_ref()).await;
},
}
}
#[cfg(feature = "client_voice")]
fn mute(app: &mut App, parameters: Option<String>) {
let id = if let Some(parameters) = parameters
{
match parameters.parse::<usize>()
{
Ok(i) => Some(i),
Err(_) => return invalid_usage(app, None)
}
} else { None };
app.push_styled(format!
(
"Sucessfully {}muted{}.",
if options::toggle_mute(id) { "" } else { "un" },
if let Some(id) = id
{
format!(" ID {id}")
} else { String::new() }
), theme::OK);
}
fn to_color(color: &str) -> Option<u8>
{
let mut formatted_color = color.replace(" ", "_").to_lowercase();
if formatted_color.starts_with("dark") && !formatted_color.starts_with("dark_")
{
formatted_color = formatted_color.replacen("dark", "dark_", 1);
}
colors::code(&formatted_color)
}
fn share_device() -> Option<Device>
{
if config::read_config::<bool>("share_device") { Some(Device::TUI) } else { None }
}
async fn color_handler
(
app: &mut App,
write_stream: &Arc<MutexAsync<OwnedWriteHalf>>,
username: bool,
parameters: Option<String>,
)
{
let Some(parameters) = parameters else { return invalid_usage(app, None) };
let Some(code) = to_color(¶meters) else
{
return app.push_styled("Invalid color! Type the command again and pick one of the offered colors.",
theme::ERROR);
};
network::send(&mut *write_stream.lock().await, PacketCode::Colors { username, color: code },
options::get_keys().as_ref()).await;
}
async fn audio_devices() -> Devices
{
#[cfg(not(feature = "client_voice"))]
{
Devices::default()
}
#[cfg(feature = "client_voice")]
{
task::spawn_blocking(||
{
let (input, output) = voice::list_devices();
Devices
{
input: input.into_iter().map(device_entry).collect(),
output: output.into_iter().map(device_entry).collect(),
}
}).await.unwrap_or_default()
}
}
#[cfg(feature = "client_voice")]
fn device_entry(device: voice::AudioDevice) -> DeviceEntry
{
DeviceEntry { id: device.id, label: device.label }
}
#[tokio::main]
async fn main()
{
tui::install_panic_hook();
let (tx, rx) = mpsc::channel::<ClientEvent>(consts::EVENT_CHANNEL_BOUND);
config::init_config();
let version_tx = tx.clone();
tokio::spawn(async move { misc::check_version(&version_tx).await; });
#[cfg(feature = "client_screen")]
tokio::spawn(run_client(tx, rx));
#[cfg(not(feature = "client_screen"))]
run_client(tx, rx).await;
#[cfg(feature = "client_screen")]
{
let event_loop = EventLoop::<UserEvent>::with_user_event()
.build().expect("Failed to create event loop");
*screen::SCREEN_SHARE_PROXY.write().unwrap() = Some(event_loop.create_proxy());
let mut app = ScreenShareApp::new();
event_loop.run_app(&mut app).expect("Event loop terminated with error");
}
}
async fn run_client(tx: Sender<ClientEvent>, mut rx: mpsc::Receiver<ClientEvent>)
{
if config::read_config("socks5_enabled")
{
options::enable_socks5();
}
let mut app = App::new();
let guard = TerminalGuard::enter().expect("Entering the alternate screen failed");
let mut terminal = tui::init().expect("Creating the terminal backend failed");
app.init_picker();
tui::run(&mut terminal, &mut app, &mut rx, &tx).await;
drop(guard);
if let Some(message) = app.quit_message.take() { println!("{message}"); }
process::exit(app.exit_code);
}
#[derive(Clone, Copy, PartialEq)]
pub enum Upload
{
File,
Image,
Avatar,
}
pub fn upload(write_stream: &Arc<MutexAsync<OwnedWriteHalf>>, path: &str, kind: Upload, tx: Option<Sender<ClientEvent>>)
-> Result<(), String>
{
let (file, path) = check_upload(path, kind)?;
let write_stream = write_stream.clone();
let keys = options::get_keys();
tokio::spawn(async move
{
let prepared = task::spawn_blocking(move || match kind
{
Upload::Avatar => cut_avatar(file),
_ => hash_file(file).map(|hash| (hash, path)).ok_or_else(|| String::from("Reading the file failed!")),
}).await.expect("Hashing file failed");
let (hash, path) = match prepared
{
Ok(prepared) => prepared,
Err(error) =>
{
if let Some(tx) = tx { tx.send(ClientEvent::AvatarFailed(error)).await.ok(); }
return;
},
};
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("unnamed_file").to_string();
client::ACTIVE_UPLOADS.lock().unwrap().insert(hash, path.canonicalize().unwrap_or(path));
let request = match kind
{
Upload::Avatar => PacketCode::AvatarRequest { hash: Some(hash) },
Upload::Image => PacketCode::ImageRequest { hash, filename },
Upload::File => PacketCode::UploadRequest { hash },
};
network::send(&mut *write_stream.lock().await, request, keys.as_ref()).await;
});
Ok(())
}
fn hash_file(mut file: File) -> Option<[u8; 32]>
{
let mut hasher = Sha256::new();
let mut buffer = vec![0; consts::UPLOAD_CHUNK_SIZE];
loop
{
match file.read(&mut buffer)
{
Ok(0) => break Some(hasher.finalize().into()),
Ok(bytes) => hasher.update(&buffer[..bytes]),
Err(_) => break None,
}
}
}
fn cut_avatar(mut file: File) -> Result<([u8; 32], PathBuf), String>
{
let mut data = Vec::new();
file.read_to_end(&mut data).map_err(|_| String::from("Reading the file failed!"))?;
let (avatar, extension) = client_image::make_avatar(&data).ok_or_else(|| String::from("That image could not be read!"))?;
if avatar.len() > consts::MAX_AVATAR_SIZE
{
return Err(format!("Avatar is too large even cut down! (limit is {}MB)", consts::MAX_AVATAR_SIZE / consts::MEGABYTE));
}
let hash: [u8; 32] = Sha256::digest(&avatar).into();
let path = misc::avatar_temp(&hash, extension);
std::fs::write(&path, &avatar).map_err(|_| String::from("Writing the cut avatar failed!"))?;
Ok((hash, path))
}
pub fn check_upload(path: &str, kind: Upload) -> Result<(File, PathBuf), String>
{
let path = palette::expand_home(path.trim());
let Ok(mut file) = File::open(&path) else { return Err(String::from("File not found!")) };
if !path.is_file() || path.file_name().and_then(|n| n.to_str()).is_none()
{
return Err(String::from("File not found!"));
}
if kind == Upload::File { return Ok((file, path)); }
let mut header = Vec::new();
file.by_ref().take(consts::IMAGE_HEADER_SIZE as u64).read_to_end(&mut header).ok();
file.rewind().ok();
if path.metadata().map(|m| m.len()).unwrap_or(0) > consts::MAX_IMAGE_SIZE as u64
{
return Err(format!("Image is too large! (limit is {}MB)", consts::MAX_IMAGE_SIZE / consts::MEGABYTE));
}
if !misc::is_image(&header) { return Err(String::from("Not an image!")); }
Ok((file, path))
}
pub async fn submit(app: &mut App, write_stream: &Arc<MutexAsync<OwnedWriteHalf>>, input: String)
{
let input = if options::get_asking_password() { input } else { input.trim().to_string() };
if options::get_sending_messages()
{
if input.is_empty() { return; }
let mut command_used = false;
if let (Some(command), parameters) = command::get_command(&input)
{
let sent = command::send_command_code(&mut *write_stream.lock().await, &command, ¶meters).await;
if sent == Some(true)
{
match command
{
Command::List => app.list_requested = true,
#[cfg(feature = "client_screen")]
Command::Screens => app.screens_requested = true,
Command::Exit => app.leaving = true,
Command::Logout => app.logging_out = true,
_ => {},
}
}
match sent
{
Some(true) => {}
Some(false) => invalid_usage(app, None),
None =>
{
match command
{
Command::Help =>
{
let commands = command::COMMAND_LIST.iter()
.filter(|info| info.available(app.role))
.flat_map(|info| -> Box<dyn Iterator<Item = palette::Entry>>
{
match info.subcommands.is_empty()
{
true => Box::new(iter::once(palette::Entry::command(info))),
false => Box::new(info.actions(app.role).map(|sub| palette::Entry::action(info, sub))),
}
}).collect::<Vec<palette::Entry>>();
let signature_width = commands.iter().map(palette::Entry::width).max().unwrap_or(0);
let description_width = commands.iter()
.filter(|entry| !entry.shortcut().is_empty())
.map(|entry| entry.description().width()).max().unwrap_or(0);
let last = commands.len().saturating_sub(1);
app.push_styled("Commands:", theme::TITLE);
for (index, entry) in commands.into_iter().enumerate() {
let shortcut = entry.shortcut();
let padding = signature_width - entry.width();
let mut spans = vec![Span::styled(tui::branch(index == last), theme::BORDER)];
spans.extend(entry.spans(None));
spans.push(Span::raw(" ".repeat(padding + 2)));
spans.push(Span::styled(format!
(
"{description:<width$}",
description = entry.description(),
width = if shortcut.is_empty() { 0 } else { description_width },
), theme::DIM));
if !shortcut.is_empty() { spans.push(Span::styled(format!(" [{shortcut}]"), theme::ACCENT)); }
app.push(Line::from(spans));
}
},
Command::Info =>
{
let mut valid = false;
if let Some(parameters) = parameters
{
let (word, action) = match parameters.split_once(char::is_whitespace)
{
Some((word, action)) => (word, Some(action.trim())),
None => (parameters.as_str(), None),
};
if let Some(info) = command::COMMAND_LIST.iter()
.find(|c| c.available(app.role) && c.triggers.iter().any(|t| t.eq_ignore_ascii_case(word)))
&& let Some(entry) = match action
{
Some(action) => info.action(action).filter(|sub| sub.available(app.role))
.map(|sub| palette::Entry::action(info, sub)),
None => Some(palette::Entry::command(info)),
}
{
let shortcut = entry.shortcut();
let triggers = entry.sub.map_or(info.triggers, |sub| sub.triggers);
app.push(Line::from(entry.spans(None)));
let fields =
[
("Aliases", if triggers.len() > 1 { triggers[1..].join(", ") } else { String::from("None") }),
("Shortcut", if shortcut.is_empty() { String::from("None") } else { shortcut }),
("Description", entry.description().to_string()),
];
let last = fields.len() - 1;
for (index, (label, value)) in fields.into_iter().enumerate()
{
app.push(Line::from(vec!
[
Span::styled(tui::branch(index == last), theme::BORDER),
Span::styled(format!("{label:<12}"), theme::DIM),
Span::raw(value),
]));
}
valid = true;
}
}
if !valid { invalid_usage(app, None); }
},
Command::Upload | Command::Image => match parameters
{
Some(path) =>
{
let kind = if command == Command::Image { Upload::Image } else { Upload::File };
if let Err(error) = upload(write_stream, &path, kind, None) { app.push_styled(error, theme::ERROR); }
},
None => invalid_usage(app, None),
},
Command::Settings => app.settings.open(audio_devices().await),
Command::Server => server_command(app, write_stream, parameters).await,
Command::UsernameColor => color_handler(app, write_stream, true, parameters).await,
Command::MessageColor => color_handler(app, write_stream, false, parameters).await,
#[cfg(feature = "client_voice")]
Command::Mute => mute(app, parameters),
#[cfg(feature = "client_screen")]
Command::Screen => app.push_styled(match screen::capture::current_monitor()
{
Some(monitor) => format!("Sharing {monitor} now."),
None => String::from("Swapped the shared monitor."),
}, theme::OK),
Command::Invalid => invalid_usage(app, Some("command")),
_ => panic!("Invalid command")
}
}
}
command_used = true;
}
if !options::get_asking_password()
{
app.input.push_history(&input);
}
if command_used { return }; }
options::set_asking_password(false);
let packet = match options::get_login_state()
{
LoginState::Username =>
{
app.username = input.clone();
PacketCode::Username
{
username: input,
device: share_device(),
}
},
LoginState::Login => PacketCode::Login { password: input },
LoginState::Register => PacketCode::Register { password: input },
LoginState::None => PacketCode::MessageRequest { text: input },
};
network::send(&mut *write_stream.lock().await, packet, options::get_keys().as_ref()).await;
}