feat(sync): add modules for pairing and syncing clipboard data

This commit is contained in:
PandaDEV 2025-01-29 20:40:06 +01:00
parent 12b8f9a49e
commit dcb2daaa87
No known key found for this signature in database
GPG key ID: 13EFF9BAF70EE75C
4 changed files with 252 additions and 25 deletions

View file

@ -3,6 +3,7 @@
mod api; mod api;
mod db; mod db;
mod utils; mod utils;
mod sync;
use sqlx::sqlite::SqlitePoolOptions; use sqlx::sqlite::SqlitePoolOptions;
use std::fs; use std::fs;
@ -10,11 +11,13 @@ use tauri::Manager;
use tauri_plugin_aptabase::{ EventTracker, InitOptions }; use tauri_plugin_aptabase::{ EventTracker, InitOptions };
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_prevent_default::Flags; use tauri_plugin_prevent_default::Flags;
use sync::sync::ClipboardSync;
use sync::pairing::PairingManager;
use tokio::sync::Mutex;
use std::sync::Arc;
fn main() { #[tokio::main]
let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); async fn main() {
let _guard = runtime.enter();
tauri::Builder tauri::Builder
::default() ::default()
.plugin(tauri_plugin_clipboard::init()) .plugin(tauri_plugin_clipboard::init())
@ -69,38 +72,52 @@ fn main() {
} }
let db_url = format!("sqlite:{}", db_path.to_str().unwrap()); let db_url = format!("sqlite:{}", db_path.to_str().unwrap());
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let app_handle_clone = app_handle.clone(); // Create the pool in a separate tokio runtime
tauri::async_runtime::spawn(async move { let pool = tokio::runtime::Runtime
let pool = SqlitePoolOptions::new() ::new()
.max_connections(5) .unwrap()
.connect(&db_url).await .block_on(async {
.expect("Failed to create pool"); SqlitePoolOptions::new()
.max_connections(5)
.connect(&db_url).await
.expect("Failed to create pool")
});
app_handle_clone.manage(pool); app_handle.manage(pool);
});
let main_window = app.get_webview_window("main"); let main_window = app.get_webview_window("main");
let _ = db::database::setup(app); db::database::setup(app).expect("Failed to setup database");
api::hotkeys::setup(app_handle.clone()); api::hotkeys::setup(app_handle.clone());
api::tray::setup(app)?; api::tray::setup(app).expect("Failed to setup tray");
api::clipboard::setup(app.handle()); api::clipboard::setup(&app_handle);
let _ = api::clipboard::start_monitor(app_handle.clone()); api::clipboard::start_monitor(app_handle.clone()).expect("Failed to start monitor");
let pairing_manager = PairingManager::new();
let encryption_key = pairing_manager.get_encryption_key().clone();
let nonce = pairing_manager.get_nonce().clone();
app_handle.manage(pairing_manager);
let clipboard_sync = ClipboardSync::new(&encryption_key, &nonce);
let clipboard_sync_arc = Arc::new(Mutex::new(clipboard_sync));
app_handle.manage(clipboard_sync_arc.clone());
let clipboard_sync_clone = clipboard_sync_arc.clone();
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
let sync = clipboard_sync_clone.lock().await;
sync.listen_webhook(app_handle_clone, clipboard_sync_clone).await;
});
utils::commands::center_window_on_current_monitor(main_window.as_ref().unwrap()); utils::commands::center_window_on_current_monitor(main_window.as_ref().unwrap());
main_window let _ = main_window
.as_ref() .as_ref()
.map(|w| w.hide()) .map(|w| w.hide())
.unwrap_or(Ok(()))?; .expect("Failed to hide window");
let _ = app.track_event("app_started", None); app.track_event("app_started", None).expect("Failed to track event");
tauri::async_runtime::spawn(async move {
api::updater::check_for_updates(app_handle, false).await;
});
Ok(()) Ok(())
}) })
@ -124,7 +141,11 @@ fn main() {
db::history::read_image, db::history::read_image,
db::settings::get_setting, db::settings::get_setting,
db::settings::save_setting, db::settings::save_setting,
utils::commands::fetch_page_meta utils::commands::fetch_page_meta,
sync::pairing::initiate_pairing,
sync::pairing::complete_pairing,
sync::sync::send_clipboard_data,
sync::sync::receive_clipboard_data
] ]
) )
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View file

@ -0,0 +1,2 @@
pub mod pairing;
pub mod sync;

View file

@ -0,0 +1,112 @@
use aes_gcm::{ Aes256Gcm, KeyInit };
use aes_gcm::aead::Aead;
use base64::{ engine::general_purpose::STANDARD, Engine };
use rand::{ Rng, thread_rng };
use rand::seq::SliceRandom;
use serde::{ Deserialize, Serialize };
use tauri::State;
use uuid::Uuid;
const EMOJI_POOL: &[&str] = &["😀", "😁", "😂", "🤣", "😃", "😄", "😅", "😆", "😉", "😊"];
const PAIRING_KEY_LENGTH: usize = 4;
#[derive(Serialize, Deserialize)]
pub struct PairingRequest {
pub inviter_id: String,
pub invitation_code: String,
}
pub struct PairingManager {
pairing_key: String,
encryption_key: [u8; 32],
nonce: [u8; 12],
}
impl PairingManager {
pub fn new() -> Self {
let mut rng = thread_rng();
let pairing_key = Self::generate_emoji_sequence(&mut rng);
let encryption_key: [u8; 32] = rng.gen();
let nonce: [u8; 12] = rng.gen();
PairingManager {
pairing_key,
encryption_key,
nonce,
}
}
pub fn generate_emoji_sequence(rng: &mut impl Rng) -> String {
let key: Vec<&str> = EMOJI_POOL.choose_multiple(rng, PAIRING_KEY_LENGTH).cloned().collect();
key.join(" ")
}
pub fn validate_pairing(&self, input_key: &str) -> bool {
self.pairing_key == input_key
}
pub fn get_encryption_key(&self) -> &[u8; 32] {
&self.encryption_key
}
pub fn get_nonce(&self) -> &[u8; 12] {
&self.nonce
}
pub fn generate_invitation_code(&self) -> String {
Uuid::new_v4().to_string()
}
pub fn encrypt_key(&self, key: &[u8; 32]) -> Result<String, String> {
let cipher = Aes256Gcm::new(&self.encryption_key.into());
let ciphertext = cipher
.encrypt(&self.nonce.into(), key.as_ref())
.map_err(|e| e.to_string())?;
Ok(STANDARD.encode(ciphertext))
}
pub fn decrypt_key(&self, encrypted_key: &str) -> Result<[u8; 32], String> {
let ciphertext = STANDARD.decode(encrypted_key).map_err(|e| e.to_string())?;
let cipher = Aes256Gcm::new(&self.encryption_key.into());
let plaintext = cipher
.decrypt(&self.nonce.into(), ciphertext.as_ref())
.map_err(|e| e.to_string())?;
let mut key = [0u8; 32];
key.copy_from_slice(&plaintext);
Ok(key)
}
pub fn create_pairing_request(&self, inviter_id: String) -> PairingRequest {
PairingRequest {
inviter_id,
invitation_code: self.generate_invitation_code(),
}
}
pub fn handle_pairing_response(&self, response: PairingRequest) -> bool {
self.validate_pairing(&response.invitation_code)
}
}
#[tauri::command]
pub fn initiate_pairing(_pairing_manager: State<'_, PairingManager>) -> String {
let mut rng = thread_rng();
PairingManager::generate_emoji_sequence(&mut rng)
}
#[tauri::command]
pub fn complete_pairing(
input_key: String,
pairing_manager: State<'_, PairingManager>
) -> Result<String, String> {
if pairing_manager.validate_pairing(&input_key) {
let _shared_key = pairing_manager.encryption_key.to_vec();
Ok("Pairing successful".to_string())
} else {
Err("Invalid pairing key".to_string())
}
}
#[tauri::command]
pub fn generate_invitation(pairing_manager: State<'_, PairingManager>) -> String {
pairing_manager.generate_invitation_code()
}

View file

@ -0,0 +1,92 @@
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use aes_gcm::aead::Aead;
use base64::{engine::general_purpose::STANDARD, Engine};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex;
use std::sync::Arc;
use typenum::U12;
const KVS_URL: &str = "https://kvs.wireway.ch";
#[derive(Serialize, Deserialize, Clone)]
pub struct ClipData {
content: String,
content_type: String,
timestamp: u64,
}
#[derive(Clone)]
pub struct ClipboardSync {
client: Client,
cipher: Aes256Gcm,
nonce: Nonce<U12>,
}
impl ClipboardSync {
pub fn new(encryption_key: &[u8; 32], nonce_bytes: &[u8; 12]) -> Self {
let cipher = Aes256Gcm::new(encryption_key.into());
let nonce = Nonce::from_slice(nonce_bytes).clone();
ClipboardSync {
client: Client::new(),
cipher,
nonce,
}
}
pub async fn send_clipboard(&self, clip: ClipData) -> Result<(), String> {
let plaintext = serde_json::to_string(&clip).map_err(|e| e.to_string())?;
let ciphertext = self.cipher.encrypt(&self.nonce, plaintext.as_bytes()).map_err(|e| e.to_string())?;
let encoded = STANDARD.encode(ciphertext);
self.client
.post(&format!("{}/clipboard", KVS_URL))
.json(&serde_json::json!({
"key": "clipboard",
"value": encoded,
"expires_in": 60
}))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn receive_clipboard(&self, app_handle: AppHandle) -> Result<(), String> {
let res = self.client.get(&format!("{}/clipboard", KVS_URL)).send().await.map_err(|e| e.to_string())?;
if res.status().is_success() {
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
if let Some(encoded) = json["value"].as_str() {
let ciphertext = STANDARD.decode(encoded).map_err(|e| e.to_string())?;
let plaintext = self.cipher.decrypt(&self.nonce, ciphertext.as_ref()).map_err(|e| e.to_string())?;
let clip_str = String::from_utf8(plaintext).map_err(|e| e.to_string())?;
let clip: ClipData = serde_json::from_str(&clip_str).map_err(|e| e.to_string())?;
app_handle.emit("clipboard-update", clip).map_err(|e| e.to_string())?;
}
}
Ok(())
}
pub async fn listen_webhook(&self, app_handle: AppHandle, state: Arc<Mutex<Self>>) {
tokio::spawn(async move {
loop {
if let Err(_) = state.lock().await.receive_clipboard(app_handle.clone()).await {
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
}
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
}
});
}
}
#[tauri::command]
pub async fn send_clipboard_data(clip: ClipData, sync: tauri::State<'_, Arc<Mutex<ClipboardSync>>>) -> Result<(), String> {
let sync = sync.lock().await;
sync.send_clipboard(clip).await
}
#[tauri::command]
pub async fn receive_clipboard_data(app_handle: AppHandle, sync: tauri::State<'_, Arc<Mutex<ClipboardSync>>>) -> Result<(), String> {
let sync = sync.lock().await;
sync.receive_clipboard(app_handle).await
}