Initial commit.

This commit is contained in:
2025-03-24 12:52:25 +01:00
commit 1ba9b13f8d
33 changed files with 8871 additions and 0 deletions

29
mlc-client/Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
name = "mlc-client"
version.workspace = true
edition.workspace = true
authors.workspace = true
description.workspace = true
[dependencies]
mls-rs.workspace = true
mls-rs-core.workspace = true
mls-rs-crypto-openssl.workspace = true
anyhow.workspace = true
reqwest.workspace = true
thiserror.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
# Crate-specific dependencies
rusqlite = { version = "^0", features = ["bundled", "chrono"] }
rusqlite_migration = "^1.3.1"
chrono = "0.4.40"
eframe = "0.31.1"
egui = "0.31.1"
rfd = "0.15.3"
egui_extras = { version = "0.31.1", features = ["image"] }
image = "0.25.5"
egui_inbox = "0.8.0"
directories = "6.0.0"
machine-uid = "0.5.3"

38
mlc-client/src/ctx.rs Normal file
View File

@ -0,0 +1,38 @@
use std::sync::{Arc, Mutex};
use crate::{MlsClient, screens::Screens};
/// Context which is valid only when logged in.
#[derive(Clone)]
pub struct MlsContext {
/// Client for mls operations.
/// It has ciphers, crypto provider, etc.
/// It is used to create groups, send messages, etc.
pub mls_client: MlsClient,
/// ID of the user from the server.
pub user_id: String,
/// Current group which user is in.
pub current_group: Option<String>,
}
pub struct ClientCTX {
pub email: String,
pub password: String,
pub machine_id: String,
pub current_screen: Screens,
pub sqlite_connection: Arc<Mutex<rusqlite::Connection>>,
pub mls_context: Option<MlsContext>,
}
impl ClientCTX {
pub fn new(sqlite_connection: Arc<Mutex<rusqlite::Connection>>) -> Self {
Self {
email: String::new(),
password: String::new(),
machine_id: String::new(),
current_screen: Screens::Login,
sqlite_connection,
mls_context: None,
}
}
}

View File

@ -0,0 +1,10 @@
/// This error exist just to implement the `IntoAnyError` trait for the `MlsCLIDBError` enum.
/// This is necessary to convert the error to `anyhow::Error` in
/// the error that mls_rs can work with.
#[derive(Debug, thiserror::Error)]
pub enum MlsCLIDBError {
#[error("Error: {0}")]
AnyError(#[from] anyhow::Error),
}
impl mls_rs_core::error::IntoAnyError for MlsCLIDBError {}

View File

@ -0,0 +1,177 @@
use std::sync::{Arc, Mutex};
use mls_rs::GroupStateStorage;
use mls_rs_core::group::{EpochRecord, GroupState};
use rusqlite::{Connection, OptionalExtension};
#[derive(Debug, Clone)]
/// SQLite Storage for MLS group states.
pub struct MlsCliGroupStateStorage {
connection: Arc<Mutex<Connection>>,
max_epoch_retention: u64,
}
impl MlsCliGroupStateStorage {
pub fn new(connection: Arc<Mutex<Connection>>) -> MlsCliGroupStateStorage {
MlsCliGroupStateStorage {
connection,
max_epoch_retention: 3,
}
}
/// List all the group ids for groups that are stored.
pub fn group_ids(&self) -> anyhow::Result<Vec<Vec<u8>>> {
let connection = self.connection.lock().unwrap();
let mut statement = connection.prepare("SELECT group_id FROM mls_group")?;
let res = statement
.query_map([], |row| row.get::<_, Vec<u8>>(0))?
.try_fold(Vec::new(), |mut ids, id| {
ids.push(id?);
Ok::<_, anyhow::Error>(ids)
})?;
Ok(res)
}
/// Delete a group from storage.
pub fn delete_group(&self, group_id: &[u8]) -> anyhow::Result<()> {
let connection = self.connection.lock().unwrap();
connection
.execute(
"DELETE FROM mls_group WHERE group_id = ?",
rusqlite::params![group_id],
)
.map(|_| ())?;
Ok(())
}
pub fn max_epoch_retention(&self) -> u64 {
self.max_epoch_retention
}
fn get_snapshot_data(&self, group_id: &[u8]) -> anyhow::Result<Option<Vec<u8>>> {
let connection = self.connection.lock().unwrap();
connection
.query_row(
"SELECT snapshot FROM mls_group where group_id = ?",
[group_id],
|row| row.get::<_, Vec<u8>>(0),
)
.optional()
.map_err(anyhow::Error::from)
}
fn get_epoch_data(&self, group_id: &[u8], epoch_id: u64) -> anyhow::Result<Option<Vec<u8>>> {
let connection = self.connection.lock().unwrap();
let res = connection
.query_row(
"SELECT epoch_data FROM epoch where group_id = ? AND epoch_id = ?",
rusqlite::params![group_id, epoch_id],
|row| row.get::<_, Vec<u8>>(0),
)
.optional()?;
Ok(res)
}
fn max_epoch_id(&self, group_id: &[u8]) -> anyhow::Result<Option<u64>> {
let connection = self.connection.lock().unwrap();
connection
.query_row(
"SELECT MAX(epoch_id) FROM epoch WHERE group_id = ?",
rusqlite::params![group_id],
|row| row.get::<_, Option<u64>>(0),
)
.map_err(anyhow::Error::from)
}
fn update_group_state(
&self,
group_id: &[u8],
group_snapshot: Vec<u8>,
inserts: Vec<EpochRecord>,
updates: Vec<EpochRecord>,
) -> anyhow::Result<()> {
let mut max_epoch_id = None;
let mut connection = self.connection.lock().unwrap();
let transaction = connection.transaction()?;
// Upsert into the group table to set the most recent snapshot
transaction.execute(
"INSERT INTO mls_group (group_id, snapshot) VALUES (?, ?) ON CONFLICT(group_id) DO UPDATE SET snapshot=excluded.snapshot",
rusqlite::params![group_id, group_snapshot],
)?;
// Insert new epochs as needed
for epoch in inserts {
max_epoch_id = Some(epoch.id);
transaction
.execute(
"INSERT INTO epoch (group_id, epoch_id, epoch_data) VALUES (?, ?, ?)",
rusqlite::params![group_id, epoch.id, epoch.data],
)
.map(|_| ())?;
}
// Update existing epochs as needed
for update in updates {
max_epoch_id = Some(update.id);
transaction
.execute(
"UPDATE epoch SET epoch_data = ? WHERE group_id = ? AND epoch_id = ?",
rusqlite::params![update.data, group_id, update.id],
)
.map(|_| ())?;
}
// Delete old epochs as needed
if let Some(max_epoch_id) = max_epoch_id {
if max_epoch_id >= self.max_epoch_retention {
let delete_under = max_epoch_id - self.max_epoch_retention;
transaction.execute(
"DELETE FROM epoch WHERE group_id = ? AND epoch_id <= ?",
rusqlite::params![group_id, delete_under],
)?;
}
}
// Execute the full transaction
transaction.commit()?;
Ok(())
}
}
impl GroupStateStorage for MlsCliGroupStateStorage {
type Error = super::error::MlsCLIDBError;
fn write(
&mut self,
state: GroupState,
inserts: Vec<EpochRecord>,
updates: Vec<EpochRecord>,
) -> Result<(), Self::Error> {
let group_id = state.id;
let snapshot_data = state.data;
self.update_group_state(&group_id, snapshot_data, inserts, updates)
.map_err(From::from)
}
fn state(&self, group_id: &[u8]) -> Result<Option<Vec<u8>>, Self::Error> {
self.get_snapshot_data(group_id).map_err(From::from)
}
fn max_epoch_id(&self, group_id: &[u8]) -> Result<Option<u64>, Self::Error> {
self.max_epoch_id(group_id).map_err(From::from)
}
fn epoch(&self, group_id: &[u8], epoch_id: u64) -> Result<Option<Vec<u8>>, Self::Error> {
self.get_epoch_data(group_id, epoch_id).map_err(From::from)
}
}

View File

@ -0,0 +1,72 @@
use std::sync::{Arc, Mutex};
use rusqlite::{Connection, OptionalExtension};
#[derive(Debug, Clone)]
pub struct GroupNamesStorage {
pub connection: Arc<Mutex<Connection>>,
}
impl GroupNamesStorage {
pub fn new(connection: Arc<Mutex<Connection>>) -> Self {
Self { connection }
}
pub fn set_name(&self, chat_id: &str, name: &str) -> anyhow::Result<()> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
conn.execute(
"INSERT INTO mls_cli_group_names(
id,
name
) VALUES (
:id,
:name
) ON CONFLICT(id) DO UPDATE SET name=excluded.name",
rusqlite::named_params![
":id": chat_id,
":name": name
],
)?;
Ok(())
}
pub fn get_name(&self, group_id: &str) -> anyhow::Result<Option<String>> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
conn.query_row(
"SELECT name
FROM mls_cli_group_names
WHERE id = :id",
rusqlite::named_params![
":id": group_id,
],
|row| row.get(0),
)
.optional()
.map_err(anyhow::Error::from)
}
pub fn get_id(&self, name: &str) -> anyhow::Result<Option<String>> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
conn.query_row(
"SELECT
id
FROM mls_cli_group_names
WHERE name = :name",
rusqlite::named_params![
":name": name,
],
|row| row.get(0),
)
.optional()
.map_err(anyhow::Error::from)
}
}

View File

@ -0,0 +1,118 @@
use mls_rs_core::{
key_package::{KeyPackageData, KeyPackageStorage},
mls_rs_codec::{MlsDecode, MlsEncode},
time::MlsTime,
};
use rusqlite::{Connection, OptionalExtension, params};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone)]
/// SQLite storage for MLS Key Packages.
pub struct MlsCliKeyPackageStorage {
connection: Arc<Mutex<Connection>>,
}
impl MlsCliKeyPackageStorage {
pub fn new(connection: Arc<Mutex<Connection>>) -> MlsCliKeyPackageStorage {
MlsCliKeyPackageStorage { connection }
}
fn insert(&mut self, id: &[u8], key_package: KeyPackageData) -> anyhow::Result<()> {
let connection = self.connection.lock().unwrap();
connection
.execute(
"INSERT INTO key_package (id, expiration, data) VALUES (?,?,?)",
params![id, key_package.expiration, key_package.mls_encode_to_vec()?],
)
.map(|_| ())
.map_err(anyhow::Error::from)
}
fn get(&self, id: &[u8]) -> anyhow::Result<Option<KeyPackageData>> {
let connection = self.connection.lock().unwrap();
connection
.query_row(
"SELECT data FROM key_package WHERE id = ?",
params![id],
|row| {
Ok(
KeyPackageData::mls_decode(&mut row.get::<_, Vec<u8>>(0)?.as_slice())
.unwrap(),
)
},
)
.optional()
.map_err(anyhow::Error::from)
}
/// Delete a specific key package from storage based on it's id.
pub fn delete(&self, id: &[u8]) -> anyhow::Result<()> {
let connection = self.connection.lock().unwrap();
connection
.execute("DELETE FROM key_package where id = ?", params![id])
.map(|_| ())
.map_err(anyhow::Error::from)
}
/// Delete key packages that are expired based on the current system clock time.
pub fn delete_expired(&self) -> anyhow::Result<()> {
self.delete_expired_by_time(MlsTime::now().seconds_since_epoch())
}
/// Delete key packages that are expired based on an application provided time in seconds since
/// unix epoch.
pub fn delete_expired_by_time(&self, time: u64) -> anyhow::Result<()> {
let connection = self.connection.lock().unwrap();
connection
.execute(
"DELETE FROM key_package where expiration < ?",
params![time],
)
.map(|_| ())
.map_err(anyhow::Error::from)
}
/// Total number of key packages held in storage.
pub fn count(&self) -> anyhow::Result<usize> {
let connection = self.connection.lock().unwrap();
connection
.query_row("SELECT count(*) FROM key_package", params![], |row| {
row.get(0)
})
.map_err(anyhow::Error::from)
}
pub fn count_at_time(&self, time: u64) -> anyhow::Result<usize> {
self.delete_expired()?;
let connection = self.connection.lock().unwrap();
connection
.query_row(
"SELECT count(*) FROM key_package where expiration >= ?",
params![time],
|row| row.get(0),
)
.map_err(anyhow::Error::from)
}
}
impl KeyPackageStorage for MlsCliKeyPackageStorage {
type Error = super::error::MlsCLIDBError;
fn insert(&mut self, id: Vec<u8>, pkg: KeyPackageData) -> Result<(), Self::Error> {
self.insert(id.as_slice(), pkg).map_err(From::from)
}
fn get(&self, id: &[u8]) -> Result<Option<KeyPackageData>, Self::Error> {
self.get(id).map_err(From::from)
}
fn delete(&mut self, id: &[u8]) -> Result<(), Self::Error> {
(*self).delete(id).map_err(From::from)
}
}

View File

@ -0,0 +1,164 @@
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use rusqlite::Connection;
#[derive(Debug, Clone)]
pub struct MessageStorage {
pub connection: Arc<Mutex<Connection>>,
}
pub struct MessageDTO {
pub group_id: Vec<u8>,
pub sender_id: Vec<u8>,
pub data: Vec<u8>,
pub created_at: DateTime<Utc>,
}
impl MessageStorage {
pub fn new(connection: Arc<Mutex<Connection>>) -> Self {
MessageStorage { connection }
}
pub fn insert_message(
&self,
chat_id: &[u8],
sender_id: &[u8],
message: &[u8],
epoch_id: u64,
) -> anyhow::Result<()> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
conn.execute(
"INSERT INTO mls_cli_messages(
group_id,
sender_id,
data,
epoch_id
) VALUES (
:group_id,
:sender_id,
:data,
:epoch_id
)",
rusqlite::named_params![
":group_id": chat_id,
":sender_id": sender_id,
":data": message,
":epoch_id": epoch_id
],
)?;
Ok(())
}
#[allow(dead_code)]
pub fn get_messages(
&self,
group_id: &str,
offset: i64,
limit: i64,
) -> anyhow::Result<Vec<MessageDTO>> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
let mut stmt = conn.prepare(
"SELECT
group_id,
sender_id,
data,
created_at
FROM mls_cli_messages
WHERE group_id = :group_id
ORDER BY created_at DESC
LIMIT :limit
OFFSET :offset",
)?;
let rows = stmt.query_map(
rusqlite::named_params![
":group_id": group_id.as_bytes(),
":offset": offset,
":limit": limit
],
|row| {
let group_id = row.get(0)?;
let sender_id = row.get(1)?;
let data = row.get(2)?;
let created_at = row.get(3)?;
Ok(MessageDTO {
group_id,
sender_id,
data,
created_at,
})
},
)?;
let mut res = vec![];
for row in rows {
res.push(row?);
}
Ok(res)
}
#[allow(dead_code)]
pub fn get_all_messages(&self, offset: i64, limit: i64) -> anyhow::Result<Vec<MessageDTO>> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
let mut stmt = conn.prepare(
"SELECT
group_id,
sender_id,
data,
created_at
FROM mls_cli_messages
ORDER BY created_at DESC
LIMIT :limit
OFFSET :offset",
)?;
let rows = stmt.query_map(
rusqlite::named_params![":offset": offset, ":limit": limit],
|row| {
let group_id = row.get(0)?;
let sender_id = row.get(1)?;
let data = row.get(2)?;
let created_at = row.get(3)?;
Ok(MessageDTO {
group_id,
sender_id,
data,
created_at,
})
},
)?;
let mut res = vec![];
for row in rows {
res.push(row?);
}
Ok(res)
}
pub fn message_sent_in_this_epoch(&self, group_id: &str, epoch_id: u64) -> anyhow::Result<u64> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
let mut stmt = conn.prepare(
"SELECT
COUNT(*)
FROM mls_cli_messages
WHERE group_id = :group_id
AND epoch_id = :epoch_id",
)?;
let count = stmt.query_row(
rusqlite::named_params![
":group_id": group_id.as_bytes(),
":epoch_id": epoch_id
],
|row| row.get(0),
)?;
Ok(count)
}
}

View File

@ -0,0 +1,14 @@
mod error;
mod group_state;
mod groups_mapping;
mod key_packages;
mod messages;
mod pre_shared_keys;
mod user;
pub use group_state::MlsCliGroupStateStorage;
pub use groups_mapping::GroupNamesStorage;
pub use key_packages::MlsCliKeyPackageStorage;
pub use messages::MessageStorage;
pub use pre_shared_keys::MlsCliPreSharedKeyStorage;
pub use user::UserdataStorage;

View File

@ -0,0 +1,63 @@
use mls_rs_core::psk::{ExternalPskId, PreSharedKey, PreSharedKeyStorage};
use rusqlite::{Connection, OptionalExtension, params};
use std::{
ops::Deref,
sync::{Arc, Mutex},
};
#[derive(Debug, Clone)]
/// SQLite storage for MLS pre-shared keys.
pub struct MlsCliPreSharedKeyStorage {
connection: Arc<Mutex<Connection>>,
}
impl MlsCliPreSharedKeyStorage {
pub fn new(connection: Arc<Mutex<Connection>>) -> Self {
MlsCliPreSharedKeyStorage { connection }
}
/// Insert a pre-shared key into storage.
pub fn insert(&self, psk_id: &[u8], psk: &PreSharedKey) -> anyhow::Result<()> {
let connection = self.connection.lock().unwrap();
// Upsert into the database
connection
.execute(
"INSERT INTO psk (psk_id, data) VALUES (?,?) ON CONFLICT(psk_id) DO UPDATE SET data=excluded.data",
params![psk_id, psk.deref()],
)
.map(|_| ()).map_err(anyhow::Error::from)
}
/// Get a pre-shared key from storage based on a unique id.
pub fn get(&self, psk_id: &[u8]) -> anyhow::Result<Option<PreSharedKey>> {
let connection = self.connection.lock().unwrap();
connection
.query_row(
"SELECT data FROM psk WHERE psk_id = ?",
params![psk_id],
|row| Ok(PreSharedKey::new(row.get(0)?)),
)
.optional()
.map_err(anyhow::Error::from)
}
/// Delete a pre-shared key from storage based on a unique id.
pub fn delete(&self, psk_id: &[u8]) -> anyhow::Result<()> {
let connection = self.connection.lock().unwrap();
connection
.execute("DELETE FROM psk WHERE psk_id = ?", params![psk_id])
.map(|_| ())
.map_err(anyhow::Error::from)
}
}
impl PreSharedKeyStorage for MlsCliPreSharedKeyStorage {
type Error = super::error::MlsCLIDBError;
fn get(&self, id: &ExternalPskId) -> Result<Option<PreSharedKey>, Self::Error> {
self.get(id).map_err(From::from)
}
}

View File

@ -0,0 +1,72 @@
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use rusqlite::OptionalExtension;
#[derive(Debug, Clone)]
pub struct UserdataStorage {
pub connection: Arc<Mutex<Connection>>,
}
pub struct Userdata {
pub user_id: String,
pub secret_key: Vec<u8>,
pub public_key: Vec<u8>,
}
impl UserdataStorage {
pub fn new(connection: Arc<Mutex<Connection>>) -> Self {
Self { connection }
}
pub fn get_user(&self) -> anyhow::Result<Option<Userdata>> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
let res = conn
.query_row(
"SELECT user_id, secret_key, public_key FROM mls_cli_userdata",
[],
|row| {
let user_id = row.get(0)?;
let secret_key = row.get(1)?;
let public_key = row.get(2)?;
Ok(Userdata {
user_id,
secret_key,
public_key,
})
},
)
.optional()?;
Ok(res)
}
pub fn truncate(&self) -> anyhow::Result<()> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
conn.execute("DELETE FROM mls_cli_userdata", [])?;
Ok(())
}
pub fn save_user(
&self,
user_id: &str,
public_key: &[u8],
secret_key: &[u8],
) -> anyhow::Result<()> {
let conn = self
.connection
.lock()
.expect("Connection mutext is poisoned. It's a critical error. Exiting.");
conn.execute(
"INSERT INTO mls_cli_userdata (user_id, secret_key, public_key) VALUES (?1, ?2, ?3)",
rusqlite::params![user_id, secret_key, public_key],
)?;
Ok(())
}
}

158
mlc-client/src/listener.rs Normal file
View File

@ -0,0 +1,158 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::context::ClientContext;
use mls_rs::MlsMessage;
use mls_rs::group::ReceivedMessage;
use mls_rs::time::MlsTime;
use tokio::sync::RwLock;
pub fn process_updates(ctx: Arc<RwLock<ClientContext>>) {
// Messages not related to any group
let mut general_messages: Vec<MlsMessage> = vec![];
// Messages related to some groups
let mut grouped_updates: HashMap<Vec<u8>, Vec<MlsMessage>> = HashMap::default();
let ctx = ctx.read();
let Some(mls_ctx) = &ctx.mls else { return };
let updates = ctx.ds_client.get_updates().await;
match updates {
Ok(updates) => {
for update in updates {
if let Some(group_id) = update.group_id().map(|id| id.to_vec()) {
grouped_updates.entry(group_id).or_default().push(update);
continue;
} else {
general_messages.push(update);
}
}
}
Err(err) => {
maybe_printer_print(
format!("Cannot receive updates! Reason: {}", err.to_string().red()),
printer,
);
}
};
if !general_messages.is_empty() {
for msg in &general_messages {
if msg.wire_format() == mls_rs::WireFormat::Welcome {
match mls_ctx.mls_client.join_group(None, msg) {
Ok((mut group, _)) => {
group.write_to_storage().ok();
}
Err(err) => {
maybe_printer_print(
format!("Cannot join group! Reason: {}", err.to_string().red()),
printer,
);
}
}
}
}
}
if !grouped_updates.is_empty() {
for (group_id, messages) in &grouped_updates {
let Ok(mut group) = mls_ctx.mls_client.load_group(group_id) else {
maybe_printer_print(
format!(
"Cannot load group with id: {:?}",
String::from_utf8_lossy(group_id).red().to_string()
),
printer,
);
continue;
};
for message in messages {
match group.process_incoming_message_with_time(message.clone(), MlsTime::now()) {
Ok(msg) => match msg {
ReceivedMessage::ApplicationMessage(app_msg) => {
let Some(member) = group.member_at_index(app_msg.sender_index) else {
maybe_printer_print(
format!(
"Cannot find member with index: {}",
app_msg.sender_index
),
printer,
);
continue;
};
let sender_name = member
.signing_identity
.credential
.as_basic()
.unwrap()
.identifier
.as_slice();
let content = String::from_utf8_lossy(app_msg.data());
let group_name = String::from_utf8_lossy(group_id);
ctx.message_storage
.insert_message(
group_id,
sender_name,
app_msg.data(),
group.context().epoch(),
)
.ok();
eprintln!(
"Received a message. group: `{}`; content: `{}`",
group_name.yellow(),
content.green(),
);
}
ReceivedMessage::Proposal(proposal) => {
let commit = group
.commit_builder()
.raw_proposal(proposal.proposal)
.build();
match commit {
Ok(cmt) => {
group.apply_pending_commit().ok();
ctx.ds_client.post_commit(&cmt).await.ok();
}
Err(err) => {
maybe_printer_print(
format!(
"Cannot create commit! Reason: {}",
err.to_string().red()
),
printer,
);
}
}
}
_ => {}
},
Err(err) => match err {
// Ignore messages from self
mls_rs::error::MlsError::CantProcessMessageFromSelf => {}
_ => {
maybe_printer_print(
format!(
"Cannot process message! Reason: {}",
err.to_string().red()
),
printer,
);
}
},
}
}
group.write_to_storage().ok();
}
}
Vec::clear(&mut general_messages);
HashMap::clear(&mut grouped_updates);
}
pub async fn listen_to_updates(
ctx: Arc<RwLock<ClientContext>>,
printer: ExternalPrinter<String>,
) -> ! {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
process_updates(ctx.clone(), Some(&printer)).await;
}
}

129
mlc-client/src/main.rs Normal file
View File

@ -0,0 +1,129 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
#![allow(rustdoc::missing_crate_level_docs)] // it's an example
mod ctx;
mod daos;
mod migrations;
mod screens;
use std::sync::Arc;
use std::sync::Mutex;
use ctx::ClientCTX;
use eframe::egui;
use mls_rs::client_builder::BaseConfig;
use mls_rs::client_builder::WithCryptoProvider;
use mls_rs::client_builder::WithGroupStateStorage;
use mls_rs::client_builder::WithIdentityProvider;
use mls_rs::client_builder::WithKeyPackageRepo;
use mls_rs::client_builder::WithPskStore;
use mls_rs::identity::basic::BasicIdentityProvider;
use mls_rs_crypto_openssl::OpensslCryptoProvider;
use screens::Screens;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::FmtSubscriber;
pub type CliMlsConfig = WithIdentityProvider<
BasicIdentityProvider,
WithGroupStateStorage<
daos::MlsCliGroupStateStorage,
WithPskStore<
daos::MlsCliPreSharedKeyStorage,
WithKeyPackageRepo<
daos::MlsCliKeyPackageStorage,
WithCryptoProvider<OpensslCryptoProvider, BaseConfig>,
>,
>,
>,
>;
pub type MlsClient = mls_rs::Client<CliMlsConfig>;
pub type MlsGroup = mls_rs::Group<CliMlsConfig>;
fn main() -> eframe::Result {
// env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
FmtSubscriber::builder()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
)
.with_writer(std::io::stderr)
.init();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_title("My little chat"),
centered: true,
vsync: true,
..Default::default()
};
let machine_id = machine_uid::get().expect("Cannot determine machine uid");
let project_dir = directories::ProjectDirs::from("com", "le-memese", "mlc")
.expect("Cannot find local project directory");
let data_dir = project_dir.data_local_dir();
tracing::info!("Data dir: {}", data_dir.display());
tracing::info!("Machine id: {}", machine_id);
std::fs::create_dir_all(&data_dir).expect("Cannot create app data dir");
let mut sqlite_conn =
rusqlite::Connection::open(data_dir.join("data.sqlite3")).expect(&format!(
"Can't open database file at {}",
data_dir.join("data").display()
));
{
sqlite_conn
.pragma_update_and_check(None, "journal_mode", "WAL", |_| Ok(()))
.expect("Cannot set WAL mode on the database");
migrations::MIGRATIONS
.to_latest(&mut sqlite_conn)
.expect("Cannot run migrations");
}
let mut ctx = ClientCTX::new(Arc::new(Mutex::new(sqlite_conn)));
ctx.machine_id = machine_id;
let app = MyApp::new(ctx);
eframe::run_native(
"My egui App",
options,
Box::new(|cc| {
// This gives us image support:
egui_extras::install_image_loaders(&cc.egui_ctx);
Ok(Box::new(app))
}),
)
}
struct MyApp {
login: screens::LoginScreen,
chats: screens::ChatsScreen,
register: screens::RegisterScreen,
ctx: ctx::ClientCTX,
}
impl MyApp {
fn new(ctx: ClientCTX) -> Self {
Self {
login: screens::LoginScreen::default(),
chats: screens::ChatsScreen::default(),
register: screens::RegisterScreen::default(),
ctx,
}
}
}
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| match &self.ctx.current_screen {
Screens::Login => self.login.update(&mut self.ctx, ui),
Screens::Chats => self.chats.update(ctx, &mut self.ctx, ui),
Screens::Register => self.register.update(&mut self.ctx, ui),
});
}
}

View File

@ -0,0 +1,83 @@
use std::sync::LazyLock;
use rusqlite_migration::{M, Migrations};
pub static MIGRATIONS: LazyLock<Migrations> = LazyLock::new(|| {
Migrations::new(vec![
// Initial migration
M::up(
r#"
CREATE TABLE mls_group (
group_id BLOB PRIMARY KEY,
snapshot BLOB NOT NULL
) WITHOUT ROWID;
CREATE TABLE epoch (
group_id BLOB,
epoch_id INTEGER,
epoch_data BLOB NOT NULL,
FOREIGN KEY (group_id) REFERENCES mls_group (group_id) ON DELETE CASCADE
PRIMARY KEY (group_id, epoch_id)
) WITHOUT ROWID;
CREATE TABLE key_package (
id BLOB PRIMARY KEY,
expiration INTEGER,
data BLOB NOT NULL
) WITHOUT ROWID;
CREATE INDEX key_package_exp ON key_package (expiration);
CREATE TABLE psk (
psk_id BLOB PRIMARY KEY,
data BLOB NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS mls_cli_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id BLOB NOT NULL,
sender_id INT NOT NULL,
data BLOB NOT NULL,
epoch_id UNSIGNED BIG INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES mls_group (group_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_message_group_id ON mls_cli_messages(group_id);
CREATE TABLE IF NOT EXISTS mls_cli_userdata (
user_id TEXT PRIMARY KEY,
secret_key BLOB NOT NULL,
public_key BLOB NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS mls_cli_group_names (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL
) WITHOUT ROWID;
"#,
)
.down(
r#"
DROP TABLE mls_cli_messages;
DROP TABLE mls_cli_userdata;
DROP TABLE mls_group;
DROP TABLE epoch;
DROP TABLE key_package;
DROP TABLE psk;
DROP TABLE mls_cli_group_names;
"#,
),
])
});
#[cfg(test)]
mod tests {
use super::*;
// Validating that migrations are correctly defined. It is enough to test in the sync context,
// because under the hood, tokio_rusqlite executes the migrations in a sync context anyway.
#[test]
fn migrations_test() {
assert!(MIGRATIONS.validate().is_ok());
}
}

View File

@ -0,0 +1,25 @@
use egui::{ComboBox, Label, SidePanel, Ui, Widget};
use crate::ctx::ClientCTX;
#[derive(Default)]
pub struct ChatsScreen {
chats: Vec<String>,
selected_chat: Option<String>,
}
impl ChatsScreen {
pub fn update(&mut self, egui_ctx: &egui::Context, ctx: &mut ClientCTX, ui: &mut Ui) {
SidePanel::new(egui::panel::Side::Left, "chats_list")
.resizable(false)
.show_inside(ui, |ui| {
for chat in &self.chats {
let label = Label::new(format!("{}", chat)).ui(ui);
if label.clicked() {
self.selected_chat = Some(format!("Chat {}", chat));
}
}
});
ui.label(&format!("Chat : {:?}", self.selected_chat));
}
}

View File

@ -0,0 +1,45 @@
use egui::Color32;
use crate::{Screens, ctx::ClientCTX};
#[derive(Default)]
pub struct LoginScreen {
email: String,
password: String,
highlight_password: bool,
highlight_email: bool,
}
impl LoginScreen {
pub fn update(&mut self, ctx: &mut ClientCTX, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
let mut email_field = egui::TextEdit::singleline(&mut self.email).hint_text("Email");
let mut password_field = egui::TextEdit::singleline(&mut self.password)
.password(true)
.hint_text("Password");
if self.highlight_email {
email_field = email_field.background_color(Color32::RED);
}
if self.highlight_password {
password_field = password_field.background_color(Color32::RED);
}
ui.add(email_field);
ui.add(password_field);
if ui.button("Login").clicked() || ui.input(|inp| inp.key_pressed(egui::Key::Enter)) {
self.highlight_email = self.email.is_empty();
self.highlight_password = self.password.is_empty();
if self.highlight_password || self.highlight_email {
return;
}
ctx.password = self.password.clone();
ctx.email = self.email.clone();
self.password.clear();
ctx.current_screen = Screens::Chats;
}
if ui.button("Register").clicked() {
*self = Self::default();
ctx.current_screen = Screens::Register;
}
});
}
}

View File

@ -0,0 +1,15 @@
mod chats;
mod login;
mod register;
pub use chats::ChatsScreen;
pub use login::LoginScreen;
pub use register::RegisterScreen;
#[derive(Default)]
pub enum Screens {
#[default]
Login,
Register,
Chats,
}

View File

@ -0,0 +1,61 @@
use egui::Color32;
use crate::{ctx::ClientCTX, screens::Screens};
#[derive(Default)]
pub struct RegisterScreen {
email: String,
password: String,
repeat_password: String,
highlight_password: bool,
highlight_repeat_password: bool,
highlight_email: bool,
}
impl RegisterScreen {
pub fn update(&mut self, ctx: &mut ClientCTX, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
let mut email_field = egui::TextEdit::singleline(&mut self.email).hint_text("Email");
let mut password_field = egui::TextEdit::singleline(&mut self.password)
.password(true)
.hint_text("Password");
let mut repeat_password_field = egui::TextEdit::singleline(&mut self.repeat_password)
.password(true)
.hint_text("Repeat password");
if self.highlight_email {
email_field = email_field.background_color(Color32::RED);
}
if self.highlight_password {
password_field = password_field.background_color(Color32::RED);
}
if self.highlight_repeat_password {
repeat_password_field = repeat_password_field.background_color(Color32::RED);
}
ui.add(email_field);
ui.add(password_field);
ui.add(repeat_password_field);
if ui.button("Register").clicked() || ui.input(|inp| inp.key_pressed(egui::Key::Enter))
{
self.highlight_email = self.email.is_empty();
self.highlight_password = self.password.is_empty();
if self.password != self.repeat_password {
self.highlight_repeat_password = true;
self.highlight_password = true;
return;
}
if self.highlight_password || self.highlight_email || self.highlight_repeat_password
{
return;
}
ctx.password = self.password.clone();
ctx.email = self.email.clone();
self.password.clear();
ctx.current_screen = Screens::Login;
}
if ui.button("Back").clicked() {
*self = Self::default();
ctx.current_screen = Screens::Login;
}
});
}
}