From 48671fffa68c182a573aee9a1b2a212126168876 Mon Sep 17 00:00:00 2001 From: JuniorAww <50590566+junioraww@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:55:24 +0200 Subject: [PATCH 01/12] refactor: migrate encryption from JS to Rust backend --- package.json | 2 - src-tauri/Cargo.toml | 3 + src-tauri/src/crypto/asymmetric.rs | 199 ++++++++ src-tauri/src/crypto/commands.rs | 544 ++++++++++++++++++++++ src-tauri/src/crypto/emoji.rs | 50 ++ src-tauri/src/crypto/media.rs | 72 +++ src-tauri/src/crypto/mod.rs | 7 + src-tauri/src/crypto/obfuscation.rs | 414 ++++++++++++++++ src-tauri/src/crypto/protocol.rs | 327 +++++++++++++ src-tauri/src/crypto/symmetric.rs | 101 ++++ src-tauri/src/lib.rs | 10 + src-tauri/src/stores.rs | 12 +- src/components/ChatWindow.svelte | 58 ++- src/components/ChatWindow/E2eModal.svelte | 188 ++++---- src/components/ChatWindow/Settings.svelte | 22 +- src/components/ChatWindow/actions.js | 72 ++- src/components/ChatWindow/e2e.js | 504 +++++++++----------- src/lib/crypto/asymmetric.js | 274 +---------- src/lib/crypto/messages.js | 322 +++++-------- src/lib/crypto/plugins.js | 47 ++ src/lib/crypto/sodium.js | 32 +- src/lib/crypto/symmetric.js | 111 +---- src/lib/crypto/text-codec.js | 241 +--------- tests/encryption.spec.js | 306 ++++++++++++ 24 files changed, 2666 insertions(+), 1252 deletions(-) create mode 100644 src-tauri/src/crypto/asymmetric.rs create mode 100644 src-tauri/src/crypto/commands.rs create mode 100644 src-tauri/src/crypto/emoji.rs create mode 100644 src-tauri/src/crypto/media.rs create mode 100644 src-tauri/src/crypto/mod.rs create mode 100644 src-tauri/src/crypto/obfuscation.rs create mode 100644 src-tauri/src/crypto/protocol.rs create mode 100644 src-tauri/src/crypto/symmetric.rs create mode 100644 src/lib/crypto/plugins.js create mode 100644 tests/encryption.spec.js diff --git a/package.json b/package.json index 07c3a74..e371eb4 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,7 @@ "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-stronghold": "^2.3.2", "@tauri-apps/plugin-upload": "^2.4.1", - "fflate": "^0.8.3", "jsqr": "^1.4.0", - "libsodium-wrappers-sumo": "^0.8.4", "lottie-web": "^5.13.0", "mp4-muxer": "^5.2.2", "opus-recorder": "^8.0.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ca4499a..e4f0264 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,9 @@ chrono = "0.4.45" chacha20poly1305 = "0.11.0" walkdir = "2.5.0" sha2 = "0.11.0" +hex = "0.4.3" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +ed25519-dalek = { version = "2.1", features = ["rand_core"] } jni = "0.22.4" tauri-plugin-single-instance = { version = "2.4.5", features = ["deep-link"] } tauri-plugin-deep-link = "2.4.10" diff --git a/src-tauri/src/crypto/asymmetric.rs b/src-tauri/src/crypto/asymmetric.rs new file mode 100644 index 0000000..902ad5a --- /dev/null +++ b/src-tauri/src/crypto/asymmetric.rs @@ -0,0 +1,199 @@ +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::rngs::OsRng; +use sha2::{Digest, Sha256}; +use x25519_dalek::{PublicKey, StaticSecret}; + +pub struct KeypairBundle { + pub ed_sk: [u8; 32], + pub ed_pk: [u8; 32], + pub x_sk: [u8; 32], + pub x_pk: [u8; 32], +} + +pub fn generate_keypair_bundle() -> KeypairBundle { + let ed_signing = SigningKey::generate(&mut OsRng); + let ed_verifying = ed_signing.verifying_key(); + + let x_secret = StaticSecret::random_from_rng(OsRng); + let x_public = PublicKey::from(&x_secret); + + KeypairBundle { + ed_sk: ed_signing.to_bytes(), + ed_pk: ed_verifying.to_bytes(), + x_sk: x_secret.to_bytes(), + x_pk: *x_public.as_bytes(), + } +} + +pub fn compute_shared_secret(my_x_sk: &[u8; 32], peer_x_pk: &[u8; 32]) -> [u8; 32] { + let my_secret = StaticSecret::from(*my_x_sk); + let peer_public = PublicKey::from(*peer_x_pk); + let shared = my_secret.diffie_hellman(&peer_public); + + let mut hasher = Sha256::new(); + hasher.update(b"maxplus-e2e-session-v1"); + hasher.update(shared.as_bytes()); + let result = hasher.finalize(); + + let mut key = [0u8; 32]; + key.copy_from_slice(&result); + key +} + +pub fn sign_data(ed_sk: &[u8; 32], data: &[u8]) -> [u8; 64] { + let signing_key = SigningKey::from_bytes(ed_sk); + let signature = signing_key.sign(data); + signature.to_bytes() +} + +pub fn verify_signature(ed_pk: &[u8; 32], data: &[u8], sig_bytes: &[u8; 64]) -> bool { + let verifying_key = match VerifyingKey::from_bytes(ed_pk) { + Ok(k) => k, + Err(_) => return false, + }; + let signature = Signature::from_bytes(sig_bytes); + verifying_key.verify(data, &signature).is_ok() +} + +pub fn create_handshake_init( + ed_sk: &[u8; 32], + ed_pk: &[u8; 32], + x_pk: &[u8; 32], + timestamp: i64, +) -> Vec { + let mut data_to_sign = Vec::with_capacity(74); + data_to_sign.push(0x01); + data_to_sign.extend_from_slice(ed_pk); + data_to_sign.extend_from_slice(x_pk); + data_to_sign.extend_from_slice(×tamp.to_be_bytes()); + + let sig = sign_data(ed_sk, &data_to_sign); + + let mut packet = Vec::with_capacity(1 + 74 + 64); + packet.push(0x01); + packet.extend_from_slice(ed_pk); + packet.extend_from_slice(x_pk); + packet.extend_from_slice(×tamp.to_be_bytes()); + packet.extend_from_slice(&sig); + packet +} + +pub fn create_handshake_accept( + ed_sk: &[u8; 32], + ed_pk: &[u8; 32], + x_pk: &[u8; 32], + timestamp: i64, +) -> Vec { + let mut data_to_sign = Vec::with_capacity(74); + data_to_sign.push(0x02); + data_to_sign.extend_from_slice(ed_pk); + data_to_sign.extend_from_slice(x_pk); + data_to_sign.extend_from_slice(×tamp.to_be_bytes()); + + let sig = sign_data(ed_sk, &data_to_sign); + + let mut packet = Vec::with_capacity(1 + 74 + 64); + packet.push(0x02); + packet.extend_from_slice(ed_pk); + packet.extend_from_slice(x_pk); + packet.extend_from_slice(×tamp.to_be_bytes()); + packet.extend_from_slice(&sig); + packet +} + +#[allow(dead_code)] +pub struct ParsedHandshake { + pub subtype: u8, + pub ed_pk: [u8; 32], + pub x_pk: [u8; 32], + pub timestamp: i64, +} + +pub fn parse_and_verify_handshake(bytes: &[u8]) -> Result { + if bytes.len() != 1 + 32 + 32 + 8 + 64 { + return Err("Invalid handshake packet length".into()); + } + + let subtype = bytes[0]; + if subtype != 0x01 && subtype != 0x02 { + return Err("Unknown handshake subtype".into()); + } + + let mut ed_pk = [0u8; 32]; + ed_pk.copy_from_slice(&bytes[1..33]); + + let mut x_pk = [0u8; 32]; + x_pk.copy_from_slice(&bytes[33..65]); + + let ts_bytes: [u8; 8] = bytes[65..73].try_into().map_err(|e| format!("{e}"))?; + let timestamp = i64::from_be_bytes(ts_bytes); + + let mut sig = [0u8; 64]; + sig.copy_from_slice(&bytes[73..137]); + + let data_to_verify = &bytes[0..73]; + if !verify_signature(&ed_pk, data_to_verify, &sig) { + return Err("Invalid handshake signature".into()); + } + + Ok(ParsedHandshake { + subtype, + ed_pk, + x_pk, + timestamp, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dh_shared_secret_agreement() { + let alice = generate_keypair_bundle(); + let bob = generate_keypair_bundle(); + + let alice_shared = compute_shared_secret(&alice.x_sk, &bob.x_pk); + let bob_shared = compute_shared_secret(&bob.x_sk, &alice.x_pk); + + assert_eq!(alice_shared, bob_shared); + } + + #[test] + fn test_signature_verification() { + let bundle = generate_keypair_bundle(); + let data = b"synthetic verification payload"; + + let sig = sign_data(&bundle.ed_sk, data); + assert!(verify_signature(&bundle.ed_pk, data, &sig)); + + let corrupted = b"synthetic verification corrupted"; + assert!(!verify_signature(&bundle.ed_pk, corrupted, &sig)); + } + + #[test] + fn test_handshake_init_parse() { + let bundle = generate_keypair_bundle(); + let ts = 1700000000000; + let init_packet = create_handshake_init(&bundle.ed_sk, &bundle.ed_pk, &bundle.x_pk, ts); + + let parsed = parse_and_verify_handshake(&init_packet).unwrap(); + assert_eq!(parsed.subtype, 0x01); + assert_eq!(parsed.ed_pk, bundle.ed_pk); + assert_eq!(parsed.x_pk, bundle.x_pk); + assert_eq!(parsed.timestamp, ts); + } + + #[test] + fn test_handshake_accept_parse() { + let bundle = generate_keypair_bundle(); + let ts = 1700000005000; + let accept_packet = create_handshake_accept(&bundle.ed_sk, &bundle.ed_pk, &bundle.x_pk, ts); + + let parsed = parse_and_verify_handshake(&accept_packet).unwrap(); + assert_eq!(parsed.subtype, 0x02); + assert_eq!(parsed.ed_pk, bundle.ed_pk); + assert_eq!(parsed.x_pk, bundle.x_pk); + assert_eq!(parsed.timestamp, ts); + } +} diff --git a/src-tauri/src/crypto/commands.rs b/src-tauri/src/crypto/commands.rs new file mode 100644 index 0000000..2820ec8 --- /dev/null +++ b/src-tauri/src/crypto/commands.rs @@ -0,0 +1,544 @@ +use crate::crypto::asymmetric::{ + compute_shared_secret, create_handshake_accept, create_handshake_init, generate_keypair_bundle, + parse_and_verify_handshake, +}; +use crate::crypto::emoji::generate_fingerprint; +use crate::crypto::media::{decrypt_media_bytes, encrypt_media_bytes, MediaDescriptor}; +use crate::crypto::obfuscation::{ChineseObfuscator, DictionaryData, WordsObfuscator}; +use crate::crypto::protocol::{pack_message, unpack_message, PayloadData}; +use crate::stores::{crypto_key, Paths, Storage}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use tauri::{AppHandle, Manager}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct IncomingMessageDto { + pub id: Value, + pub text: Option, + pub sender: Option, + pub time: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DecryptedMessageDto { + pub text: String, + pub obf: Option, + pub is_encrypted: bool, + pub media: Option, + pub is_handshake_request: bool, + pub is_handshake_accept: bool, + pub handshake_data: Option, + pub error: Option, +} + +fn load_chat_settings_json(app: &AppHandle, account: u64, chat_id: i64) -> Value { + let key = crypto_key(app, account); + Storage::new(key) + .load(Paths::new(app, account).settings(chat_id)) + .unwrap_or_else(|| { + json!({ + "version": 1, + "keys": { + "current": null, + "keys": [], + "messages": [] + }, + "password": null, + "obfs": null, + "reader": true + }) + }) +} + +fn save_chat_settings_json( + app: &AppHandle, + account: u64, + chat_id: i64, + data: &Value, +) -> Result<(), String> { + let key = crypto_key(app, account); + Storage::new(key).save(Paths::new(app, account).settings(chat_id), data) +} + +fn get_active_session_key(settings: &Value) -> Option<[u8; 32]> { + let hex_key = settings + .get("session") + .and_then(|s| s.get("shared_secret")) + .and_then(|k| k.as_str())?; + + let bytes = hex::decode(hex_key).ok()?; + if bytes.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Some(arr) + } else { + None + } +} + +fn load_dictionary_data(app: &AppHandle) -> Option { + let dict_path = app + .path() + .app_data_dir() + .ok()? + .join("data") + .join("dictionary"); + let stored: Value = Storage::new(None).load(&dict_path)?; + let data_val = stored.get("data")?; + serde_json::from_value(data_val.clone()).ok() +} + +#[tauri::command] +pub async fn batch_decrypt_messages( + app: AppHandle, + account: u64, + chat_id: i64, + messages: Vec, + password: Option, +) -> Result, String> { + let settings = load_chat_settings_json(&app, account, chat_id); + let session_key = get_active_session_key(&settings); + let effective_password = password.or_else(|| { + settings + .get("password") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()) + }); + + let dict_opt = load_dictionary_data(&app); + let mut results = HashMap::new(); + + for msg in messages { + let msg_id_str = match &msg.id { + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + _ => continue, + }; + + let raw_text = match msg.text.as_deref() { + Some(t) if !t.trim().is_empty() => t.trim(), + _ => continue, + }; + + let mut obf_name: Option = None; + let mut raw_bytes: Option> = None; + + if ChineseObfuscator::detect(raw_text) { + if let Ok(b) = ChineseObfuscator::deobfuscate(raw_text) { + obf_name = Some("zh".into()); + raw_bytes = Some(b); + } + } + + if raw_bytes.is_none() { + if let Some(ref dict) = dict_opt { + if WordsObfuscator::detect(raw_text, dict) { + if let Ok(b) = WordsObfuscator::deobfuscate(raw_text, dict) { + obf_name = Some("words".into()); + raw_bytes = Some(b); + } + } + } + } + + if raw_bytes.is_none() { + if let Ok(b) = STANDARD.decode(raw_text) { + if !b.is_empty() && (b[0] >> 6) == 0 { + raw_bytes = Some(b); + } + } + } + + let bytes = match raw_bytes { + Some(b) => b, + None => continue, + }; + + match unpack_message(&bytes, session_key.as_ref(), effective_password.as_deref()) { + Ok(PayloadData::Text(t)) => { + results.insert( + msg_id_str, + DecryptedMessageDto { + text: t, + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: None, + }, + ); + } + Ok(PayloadData::Media { text, media }) => { + results.insert( + msg_id_str, + DecryptedMessageDto { + text, + obf: obf_name, + is_encrypted: true, + media: Some(media), + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: None, + }, + ); + } + Ok(PayloadData::Handshake(hs_bytes)) => { + let parsed = parse_and_verify_handshake(&hs_bytes); + match parsed { + Ok(p) => { + let is_init = p.subtype == 0x01; + let notice_text = if is_init { + "Запрос на секретный чат".to_string() + } else { + "Секретный чат установлен".to_string() + }; + results.insert( + msg_id_str, + DecryptedMessageDto { + text: notice_text, + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: is_init, + is_handshake_accept: !is_init, + handshake_data: Some(hex::encode(&hs_bytes)), + error: None, + }, + ); + } + Err(e) => { + results.insert( + msg_id_str, + DecryptedMessageDto { + text: "Ошибка проверки рукопожатия" + .into(), + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: Some(e), + }, + ); + } + } + } + Err(e) => { + results.insert( + msg_id_str, + DecryptedMessageDto { + text: format!("Ошибка! {e}"), + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: Some(e), + }, + ); + } + } + } + + Ok(results) +} + +#[tauri::command] +pub async fn encrypt_message( + app: AppHandle, + account: u64, + chat_id: i64, + text: String, + media: Option, + password: Option, + use_session: bool, + obf: Option, +) -> Result { + let settings = load_chat_settings_json(&app, account, chat_id); + let session_key = if use_session { + get_active_session_key(&settings) + } else { + None + }; + + let effective_password = password.or_else(|| { + settings + .get("password") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()) + }); + + let payload = match media { + Some(m) => PayloadData::Media { text, media: m }, + None => PayloadData::Text(text), + }; + + let packed = pack_message( + payload, + session_key.as_ref(), + effective_password.as_deref(), + )?; + + let target_obf = obf.or_else(|| { + settings + .get("obfs") + .and_then(|o| o.as_str()) + .map(|s| s.to_string()) + }); + + match target_obf.as_deref() { + Some("zh") => Ok(ChineseObfuscator::obfuscate(&packed)), + Some("words") => { + let dict = load_dictionary_data(&app) + .ok_or_else(|| "Dictionary not loaded. Please download dictionary in settings.".to_string())?; + WordsObfuscator::obfuscate(&packed, &dict) + } + _ => Ok(STANDARD.encode(&packed)), + } +} + +#[tauri::command] +pub async fn init_e2e_handshake( + app: AppHandle, + account: u64, + chat_id: i64, + obf: Option, +) -> Result { + let bundle = generate_keypair_bundle(); + let timestamp = Utc::now().timestamp_millis(); + + let mut settings = load_chat_settings_json(&app, account, chat_id); + settings["pending"] = json!({ + "ed_sk": hex::encode(bundle.ed_sk), + "ed_pk": hex::encode(bundle.ed_pk), + "x_sk": hex::encode(bundle.x_sk), + "x_pk": hex::encode(bundle.x_pk), + "timestamp": timestamp + }); + save_chat_settings_json(&app, account, chat_id, &settings)?; + + let hs_packet = + create_handshake_init(&bundle.ed_sk, &bundle.ed_pk, &bundle.x_pk, timestamp); + let packed = pack_message(PayloadData::Handshake(hs_packet), None, None)?; + + let target_obf = obf.unwrap_or_else(|| "zh".into()); + if target_obf == "words" { + if let Some(dict) = load_dictionary_data(&app) { + return WordsObfuscator::obfuscate(&packed, &dict); + } + } + + Ok(ChineseObfuscator::obfuscate(&packed)) +} + +#[tauri::command] +pub async fn accept_e2e_handshake( + app: AppHandle, + account: u64, + chat_id: i64, + handshake_data: String, + obf: Option, +) -> Result { + let hs_bytes = hex::decode(&handshake_data).map_err(|e| e.to_string())?; + let parsed = parse_and_verify_handshake(&hs_bytes)?; + + let my_bundle = generate_keypair_bundle(); + let shared_secret = compute_shared_secret(&my_bundle.x_sk, &parsed.x_pk); + let fingerprint = generate_fingerprint(&shared_secret); + let timestamp = Utc::now().timestamp_millis(); + + let mut settings = load_chat_settings_json(&app, account, chat_id); + settings["session"] = json!({ + "shared_secret": hex::encode(shared_secret), + "fingerprint": fingerprint, + "peer_ed_pk": hex::encode(parsed.ed_pk), + "peer_x_pk": hex::encode(parsed.x_pk), + "my_ed_sk": hex::encode(my_bundle.ed_sk), + "my_ed_pk": hex::encode(my_bundle.ed_pk), + "established_at": timestamp + }); + settings["keys"]["current"] = json!(1); + save_chat_settings_json(&app, account, chat_id, &settings)?; + + let hs_packet = create_handshake_accept( + &my_bundle.ed_sk, + &my_bundle.ed_pk, + &my_bundle.x_pk, + timestamp, + ); + let packed = pack_message(PayloadData::Handshake(hs_packet), None, None)?; + + let target_obf = obf.unwrap_or_else(|| "zh".into()); + if target_obf == "words" { + if let Some(dict) = load_dictionary_data(&app) { + return WordsObfuscator::obfuscate(&packed, &dict); + } + } + + Ok(ChineseObfuscator::obfuscate(&packed)) +} + +#[tauri::command] +pub async fn process_e2e_accept( + app: AppHandle, + account: u64, + chat_id: i64, + handshake_data: String, +) -> Result { + let mut settings = load_chat_settings_json(&app, account, chat_id); + + let existing_fingerprint = settings + .get("session") + .and_then(|s| s.get("fingerprint")) + .and_then(|f| f.as_str()) + .map(|s| s.to_string()); + + let pending = settings.get("pending"); + let x_sk_hex = pending.and_then(|p| p.get("x_sk")).and_then(|x| x.as_str()); + + let x_sk_hex = match x_sk_hex { + Some(hex) => hex, + None => { + if let Some(fp) = existing_fingerprint { + return Ok(fp); + } + return Err("Missing pending x_sk".to_string()); + } + }; + + let hs_bytes = hex::decode(&handshake_data).map_err(|e| e.to_string())?; + let parsed = parse_and_verify_handshake(&hs_bytes)?; + + let x_sk_vec = hex::decode(x_sk_hex).map_err(|e| e.to_string())?; + if x_sk_vec.len() != 32 { + return Err("Invalid x_sk length".into()); + } + let mut my_x_sk = [0u8; 32]; + my_x_sk.copy_from_slice(&x_sk_vec); + + let shared_secret = compute_shared_secret(&my_x_sk, &parsed.x_pk); + let fingerprint = generate_fingerprint(&shared_secret); + + settings["session"] = json!({ + "shared_secret": hex::encode(shared_secret), + "fingerprint": fingerprint.clone(), + "peer_ed_pk": hex::encode(parsed.ed_pk), + "peer_x_pk": hex::encode(parsed.x_pk), + "established_at": Utc::now().timestamp_millis() + }); + settings["keys"]["current"] = json!(1); + settings.as_object_mut().map(|o| o.remove("pending")); + save_chat_settings_json(&app, account, chat_id, &settings)?; + + Ok(fingerprint) +} + +#[tauri::command] +pub async fn get_chat_encryption_info( + app: AppHandle, + account: u64, + chat_id: i64, +) -> Result { + let settings = load_chat_settings_json(&app, account, chat_id); + let has_session = settings.get("session").is_some(); + let fingerprint = settings + .get("session") + .and_then(|s| s.get("fingerprint")) + .and_then(|f| f.as_str()) + .map(|s| s.to_string()); + + let has_pending = settings.get("pending").is_some(); + + Ok(json!({ + "active": has_session, + "fingerprint": fingerprint, + "pending": has_pending + })) +} + +#[tauri::command] +pub async fn make_dictionary(app: AppHandle, text: Value) -> Result { + let text_str = match text { + Value::String(s) => s, + Value::Array(arr) => { + let bytes: Vec = arr + .into_iter() + .filter_map(|v| v.as_u64().map(|b| b as u8)) + .collect(); + String::from_utf8_lossy(&bytes).to_string() + } + _ => return Err("Invalid dictionary text input".into()), + }; + + let dict = WordsObfuscator::build_dictionary(&text_str)?; + let val = serde_json::to_value(&dict).map_err(|e| e.to_string())?; + + let path = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("data") + .join("dictionary"); + + let mut store = Storage::new(None) + .load(&path) + .unwrap_or_else(|| json!({ "url": null, "data": null })); + store["data"] = val.clone(); + Storage::new(None).save(&path, &store)?; + + Ok(val) +} + +#[tauri::command] +pub async fn encrypt_media_file( + app: AppHandle, + account: u64, + chat_id: i64, + file_path: String, + dummy_type: String, +) -> Result { + let settings = load_chat_settings_json(&app, account, chat_id); + let session_key = get_active_session_key(&settings) + .ok_or_else(|| "Session key required to encrypt media".to_string())?; + + let raw_bytes = fs::read(&file_path).map_err(|e| e.to_string())?; + let encrypted = encrypt_media_bytes(&raw_bytes, &session_key, &dummy_type)?; + + let out_path = format!("{}.enc", file_path); + fs::write(&out_path, encrypted).map_err(|e| e.to_string())?; + + Ok(out_path) +} + +#[tauri::command] +pub async fn decrypt_media_file( + app: AppHandle, + account: u64, + chat_id: i64, + file_path: String, + out_path: String, +) -> Result { + let settings = load_chat_settings_json(&app, account, chat_id); + let session_key = get_active_session_key(&settings) + .ok_or_else(|| "Session key required to decrypt media".to_string())?; + + let encrypted_bytes = fs::read(&file_path).map_err(|e| e.to_string())?; + let decrypted = decrypt_media_bytes(&encrypted_bytes, &session_key)?; + + if let Some(parent) = Path::new(&out_path).parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&out_path, decrypted).map_err(|e| e.to_string())?; + + Ok(out_path) +} diff --git a/src-tauri/src/crypto/emoji.rs b/src-tauri/src/crypto/emoji.rs new file mode 100644 index 0000000..2d98411 --- /dev/null +++ b/src-tauri/src/crypto/emoji.rs @@ -0,0 +1,50 @@ +use sha2::{Digest, Sha256}; + +pub const EMOJI_TABLE: [&str; 256] = [ + "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", + "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔", + "🐧", "🐦", "🐤", "🦆", "🦅", "🦉", "🦇", "🐺", + "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", + "🐜", "🦟", "🐢", "🐍", "🦎", "🐙", "🦑", "🦐", + "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", "🐳", "鲨", + "🐊", "🐅", "🐆", "🦓", "🦍", "🐘", "🦛", "🦏", + "🐪", "🐫", "🦒", "🦘", "🐃", "🐂", "🐄", "🐎", + "🐖", "🐏", "🐑", "🐐", "🦌", "🐕", "🐩", "🐈", + "🐓", "🦃", "🦚", "🦜", "🦢", "🦩", "🕊", "🐇", + "🦝", "🦨", "🦡", "🦦", "🦥", "🐁", "🐀", "🐿", + "🦔", "🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", + "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", + "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", + "🌶", "🌽", "🥕", "🧄", "🧅", "🥔", "🍠", "🥐", + "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", + "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🌭", "🍔", + "🍟", "🍕", "🥪", "🥙", "🧆", "🌮", "🌯", "🥗", + "🥘", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟", + "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🍢", + "🍡", "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", + "🍮", "🍭", "🍬", "🍫", "🍿", "🍩", "🍪", "🌰", + "🥜", "🍯", "🥛", "☕", "🍵", "🧃", "🥤", "🧊", + "⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", + "🥏", "🎱", "🪀", "🏓", "🏸", "🏒", "🥍", "🏏", + "🥊", "🥋", "🥅", "⛳", "🏹", "🎣", "🤿", "🎽", + "🛹", "🛼", "🛷", "⛸", "🎯", "🪄", "🪅", "🎮", + "🚀", "🛸", "🚁", "⛵", "🛶", "🚤", "⚓", "⚡", + "🌙", "⭐", "🌟", "✨", "💥", "🔥", "🌈", "☀️", + "💎", "🔔", "🔑", "🗝", "🎸", "🎷", "🎺", "🎻", + "🥁", "🎹", "🎙", "📻", "🎨", "🎭", "🎬", "🎪", + "🧭", "⏰", "⏱", "⌛", "💡", "🔦", "🕯", "🪔", +]; + +pub fn generate_fingerprint(shared_secret: &[u8; 32]) -> String { + let mut hasher = Sha256::new(); + hasher.update(shared_secret); + let hash = hasher.finalize(); + + format!( + "{} {} {} {}", + EMOJI_TABLE[hash[0] as usize], + EMOJI_TABLE[hash[1] as usize], + EMOJI_TABLE[hash[2] as usize], + EMOJI_TABLE[hash[3] as usize] + ) +} diff --git a/src-tauri/src/crypto/media.rs b/src-tauri/src/crypto/media.rs new file mode 100644 index 0000000..a0f1376 --- /dev/null +++ b/src-tauri/src/crypto/media.rs @@ -0,0 +1,72 @@ +use crate::crypto::symmetric; +use rand::{RngCore, thread_rng}; +use serde::{Deserialize, Serialize}; + +const MAGIC_MARKER: &[u8] = b"MAXMEDIA\x01"; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MediaDescriptor { + pub attach_index: usize, + pub name: String, + pub mime: String, + pub media_type: String, + pub size: u64, + pub width: Option, + pub height: Option, + pub duration: Option, +} + +pub fn encrypt_media_bytes( + raw_bytes: &[u8], + key: &[u8; 32], + dummy_type: &str, +) -> Result, String> { + let mut nonce = [0u8; 12]; + thread_rng().fill_bytes(&mut nonce); + + let ciphertext = symmetric::encrypt(key, &nonce, raw_bytes, Some(MAGIC_MARKER))?; + + let dummy_header: &[u8] = match dummy_type { + "pdf" => b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n", + "docx" => b"PK\x03\x04\x14\x00\x08\x00\x08\x00", + "mp3" => b"ID3\x03\x00\x00\x00\x00\x00", + _ => b"", + }; + + let mut out = Vec::with_capacity(dummy_header.len() + MAGIC_MARKER.len() + 12 + ciphertext.len()); + out.extend_from_slice(dummy_header); + out.extend_from_slice(MAGIC_MARKER); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ciphertext); + + Ok(out) +} + +pub fn decrypt_media_bytes(encrypted_bytes: &[u8], key: &[u8; 32]) -> Result, String> { + let marker_pos = encrypted_bytes + .windows(MAGIC_MARKER.len()) + .position(|window| window == MAGIC_MARKER); + + let payload_start = match marker_pos { + Some(pos) => pos + MAGIC_MARKER.len(), + None => { + if encrypted_bytes.len() >= 28 { + 0 + } else { + return Err("Encrypted media header missing".into()); + } + } + }; + + let remaining = &encrypted_bytes[payload_start..]; + if remaining.len() < 28 { + return Err("Encrypted payload too short".into()); + } + + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&remaining[..12]); + let ciphertext = &remaining[12..]; + + symmetric::decrypt(key, &nonce, ciphertext, Some(MAGIC_MARKER)) + .or_else(|_| symmetric::decrypt(key, &nonce, ciphertext, None)) +} diff --git a/src-tauri/src/crypto/mod.rs b/src-tauri/src/crypto/mod.rs new file mode 100644 index 0000000..b886c44 --- /dev/null +++ b/src-tauri/src/crypto/mod.rs @@ -0,0 +1,7 @@ +pub mod asymmetric; +pub mod commands; +pub mod emoji; +pub mod media; +pub mod obfuscation; +pub mod protocol; +pub mod symmetric; diff --git a/src-tauri/src/crypto/obfuscation.rs b/src-tauri/src/crypto/obfuscation.rs new file mode 100644 index 0000000..52f19dd --- /dev/null +++ b/src-tauri/src/crypto/obfuscation.rs @@ -0,0 +1,414 @@ +use rand::Rng; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; + +const ZH_BASE: u32 = 0x4E00; +const ZH_COUNT: usize = 2048; + +pub struct ChineseObfuscator; + +impl ChineseObfuscator { + pub fn detect(text: &str) -> bool { + let chars: Vec = text.chars().take(5).collect(); + if chars.len() < 5 { + return false; + } + + for c in chars { + let cp = c as u32; + if cp < ZH_BASE || cp >= ZH_BASE + ZH_COUNT as u32 { + return false; + } + let idx = cp - ZH_BASE; + if (idx & 1) != 0 { + return false; + } + } + + true + } + + pub fn obfuscate(bytes: &[u8]) -> String { + let mut rng = rand::thread_rng(); + let mut marker = String::with_capacity(5); + let max_even = (ZH_COUNT - 1) / 2; + for _ in 0..5 { + let r = rng.gen_range(0..=max_even); + let idx = (r * 2) as u32; + if let Some(ch) = char::from_u32(ZH_BASE + idx) { + marker.push(ch); + } + } + + let len = bytes.len() as u16; + let mut payload_bytes = Vec::with_capacity(2 + bytes.len()); + payload_bytes.extend_from_slice(&len.to_be_bytes()); + payload_bytes.extend_from_slice(bytes); + + let bits_per = 11; + let mut bit_buffer: u64 = 0; + let mut bit_count = 0; + let mut out = marker; + + for &b in &payload_bytes { + bit_buffer = (bit_buffer << 8) | (b as u64); + bit_count += 8; + while bit_count >= bits_per { + bit_count -= bits_per; + let idx = ((bit_buffer >> bit_count) & ((1 << bits_per) - 1)) as u32; + if let Some(ch) = char::from_u32(ZH_BASE + idx) { + out.push(ch); + } + bit_buffer &= (1 << bit_count) - 1; + } + } + + if bit_count > 0 { + let idx = ((bit_buffer << (bits_per - bit_count)) & ((1 << bits_per) - 1)) as u32; + if let Some(ch) = char::from_u32(ZH_BASE + idx) { + out.push(ch); + } + } + + out + } + + pub fn deobfuscate(text: &str) -> Result, String> { + let chars: Vec = text.chars().collect(); + if chars.len() < 5 { + return Err("Text too short for Chinese deobfuscation".into()); + } + + let payload_chars = &chars[5..]; + let bits_per = 11; + let mut bit_buffer: u64 = 0; + let mut bit_count = 0; + let mut raw_bytes = Vec::new(); + + for &ch in payload_chars { + let cp = ch as u32; + if cp < ZH_BASE || cp >= ZH_BASE + ZH_COUNT as u32 { + continue; + } + let idx = cp - ZH_BASE; + bit_buffer = (bit_buffer << bits_per) | (idx as u64); + bit_count += bits_per; + while bit_count >= 8 { + bit_count -= 8; + let byte = ((bit_buffer >> bit_count) & 0xFF) as u8; + raw_bytes.push(byte); + bit_buffer &= (1 << bit_count) - 1; + } + } + + if raw_bytes.len() < 2 { + return Err("Payload too short".into()); + } + + let expected_len = u16::from_be_bytes([raw_bytes[0], raw_bytes[1]]) as usize; + let content = &raw_bytes[2..]; + if content.len() < expected_len { + return Err("Corrupted payload length".into()); + } + + Ok(content[..expected_len].to_vec()) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DictionaryData { + pub dict_sha256: String, + pub punct: Vec<(String, u32)>, + pub dict8: Vec, + pub dict16: Vec, +} + +pub struct WordsObfuscator; + +impl WordsObfuscator { + pub fn build_dictionary(text: &str) -> Result { + let mut punct_count: HashMap = HashMap::new(); + let punctuation = ['.', ',', '?', '!', '—', ':']; + for c in text.chars() { + if punctuation.contains(&c) { + *punct_count.entry(c).or_insert(0) += 1; + } + } + + let re = regex::Regex::new(r"[А-Яа-яЁё]+").map_err(|e| e.to_string())?; + let mut normalized: Vec = re + .find_iter(text) + .map(|m| m.as_str().to_lowercase()) + .collect(); + normalized.sort(); + + let mut unique = Vec::new(); + let mut seen = HashSet::new(); + for w in normalized { + if seen.insert(w.clone()) { + unique.push(w); + } + } + + let min_size = 65536 + 256; + if unique.len() < min_size { + return Err(format!( + "Not enough unique words: have {}, need {}", + unique.len(), + min_size + )); + } + + let mut dict8 = Vec::with_capacity(256); + let mut dict16 = Vec::with_capacity(65536); + + for (i, word) in unique.into_iter().enumerate() { + if i % 257 == 0 && dict8.len() < 256 { + dict8.push(word); + } else if dict16.len() < 65536 { + dict16.push(word); + } + } + + if dict8.len() < 256 || dict16.len() < 65536 { + return Err("Failed to construct complete dictionary tables".into()); + } + + let dict_concat = format!("{} {}", dict8.join(" "), dict16.join(" ")); + let mut hasher = Sha256::new(); + hasher.update(dict_concat.as_bytes()); + let dict_sha256 = hex::encode(hasher.finalize()); + + let words_gross = text.split_whitespace().count().max(1) as u32; + let mut punct = Vec::new(); + for &p in &punctuation { + let count = punct_count.get(&p).copied().unwrap_or(0); + let prob = (count * 10000) / words_gross; + punct.push((p.to_string(), prob)); + } + + Ok(DictionaryData { + dict_sha256, + punct, + dict8, + dict16, + }) + } + + pub fn detect(text: &str, dict: &DictionaryData) -> bool { + if text.contains('\n') || !text.contains(' ') { + return false; + } + + let words: Vec<&str> = text.split_whitespace().collect(); + if words.len() < 2 { + return false; + } + + let clean_first = clean_word(words[0]); + let clean_second = clean_word(words[1]); + + let dict8_map: HashMap<&str, usize> = dict + .dict8 + .iter() + .enumerate() + .map(|(i, w)| (w.as_str(), i)) + .collect(); + + let first_idx = match dict8_map.get(clean_first.as_str()) { + Some(&idx) => idx, + None => return false, + }; + + let second_idx = match dict8_map.get(clean_second.as_str()) { + Some(&idx) => idx, + None => return false, + }; + + let mini_hash = match u8::from_str_radix(&dict.dict_sha256[..1], 16) { + Ok(h) => (h & 0x0F) as usize, + Err(_) => return false, + }; + + let expected_hash = first_idx & 0x0F; + if expected_hash != mini_hash { + return false; + } + + let seed = (first_idx >> 4) & 0x0F; + let expected_second = ((seed ^ mini_hash ^ 0x5A) & 0xFF) % dict.dict8.len(); + + second_idx == expected_second + } + + pub fn obfuscate(bytes: &[u8], dict: &DictionaryData) -> Result { + let mut rng = rand::thread_rng(); + let seed = rng.gen_range(0..16) as usize; + let mini_hash = u8::from_str_radix(&dict.dict_sha256[..1], 16).map_err(|e| e.to_string())? as usize & 0x0F; + + let prefix_0 = (seed << 4) | mini_hash; + let first_word = &dict.dict8[prefix_0 % dict.dict8.len()]; + + let prefix_1 = ((seed ^ mini_hash ^ 0x5A) & 0xFF) % dict.dict8.len(); + let second_word = &dict.dict8[prefix_1]; + + let mut out = Vec::new(); + out.push(capitalize(first_word)); + out.push(capitalize(second_word)); + + let len16 = dict.dict16.len(); + let len8 = dict.dict8.len(); + + let mut i = 0; + let mut sentence_start = false; + + let mut punct_ranges = Vec::new(); + let mut cumulative = 0; + for (char_str, prob) in &dict.punct { + let start = cumulative; + cumulative += prob; + if let Some(ch) = char_str.chars().next() { + punct_ranges.push((start, cumulative.saturating_sub(1), ch)); + } + } + + while i < bytes.len() { + let mut word = if i + 1 < bytes.len() { + let idx = ((bytes[i] as usize) << 8) | (bytes[i + 1] as usize); + i += 2; + dict.dict16[(idx + seed) % len16].clone() + } else { + let idx = bytes[i] as usize; + i += 1; + dict.dict8[(idx + seed) % len8].clone() + }; + + if sentence_start { + word = capitalize(&word); + sentence_start = false; + } + + out.push(word); + + let roll: u32 = rng.gen_range(0..10000); + for &(start, end, ch) in &punct_ranges { + if roll >= start && roll <= end { + if ch == '—' { + out.push("—".to_string()); + } else { + if let Some(last) = out.last_mut() { + last.push(ch); + } + if ch == '.' || ch == '!' || ch == '?' { + sentence_start = true; + } + } + break; + } + } + } + + let mut text = out.join(" "); + if !text.ends_with('.') && !text.ends_with('!') && !text.ends_with('?') { + text.push('.'); + } + + Ok(text) + } + + pub fn deobfuscate(text: &str, dict: &DictionaryData) -> Result, String> { + let tokens: Vec<&str> = text.split_whitespace().collect(); + if tokens.len() < 2 { + return Err("Too few tokens for word deobfuscation".into()); + } + + let dict8_map: HashMap<&str, usize> = dict + .dict8 + .iter() + .enumerate() + .map(|(i, w)| (w.as_str(), i)) + .collect(); + let dict16_map: HashMap<&str, usize> = dict + .dict16 + .iter() + .enumerate() + .map(|(i, w)| (w.as_str(), i)) + .collect(); + + let first = clean_word(tokens[0]); + let prefix_0 = dict8_map + .get(first.as_str()) + .copied() + .ok_or_else(|| format!("Unknown prefix word: {first}"))?; + let seed = (prefix_0 >> 4) & 0x0F; + + let len16 = dict.dict16.len(); + let len8 = dict.dict8.len(); + + let mut bytes = Vec::new(); + + for &tok in &tokens[2..] { + let cleaned = clean_word(tok); + if cleaned.is_empty() || cleaned == "—" { + continue; + } + + if let Some(&idx16) = dict16_map.get(cleaned.as_str()) { + let real_idx = (idx16 + len16 - (seed % len16)) % len16; + bytes.push(((real_idx >> 8) & 0xFF) as u8); + bytes.push((real_idx & 0xFF) as u8); + continue; + } + + if let Some(&idx8) = dict8_map.get(cleaned.as_str()) { + let real_idx = (idx8 + len8 - (seed % len8)) % len8; + bytes.push(real_idx as u8); + continue; + } + + return Err(format!("Word not found in dictionary: {cleaned}")); + } + + Ok(bytes) + } +} + +fn clean_word(s: &str) -> String { + s.chars() + .filter(|&c| c != '.' && c != ',' && c != '?' && c != '!' && c != ':' && c != '—' && c != '-') + .collect::() + .to_lowercase() +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + chars.as_str(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chinese_roundtrip_various_lengths() { + for len in [1, 2, 3, 5, 7, 10, 11, 16, 22, 33, 64, 128] { + let original: Vec = (0..len).map(|i| (i * 17) as u8).collect(); + let obfuscated = ChineseObfuscator::obfuscate(&original); + assert!(ChineseObfuscator::detect(&obfuscated)); + + let deobfuscated = ChineseObfuscator::deobfuscate(&obfuscated).unwrap(); + assert_eq!(deobfuscated, original, "Failed for length {}", len); + } + } + + #[test] + fn test_chinese_detect_rejects_plain_text() { + assert!(!ChineseObfuscator::detect("Hello world")); + assert!(!ChineseObfuscator::detect("Привет мир")); + assert!(!ChineseObfuscator::detect("")); + } +} diff --git a/src-tauri/src/crypto/protocol.rs b/src-tauri/src/crypto/protocol.rs new file mode 100644 index 0000000..be84867 --- /dev/null +++ b/src-tauri/src/crypto/protocol.rs @@ -0,0 +1,327 @@ +use crate::crypto::media::MediaDescriptor; +use crate::crypto::symmetric; +use flate2::read::DeflateDecoder; +use flate2::write::DeflateEncoder; +use flate2::Compression; +use rand::{RngCore, thread_rng}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; + +pub const PROTOCOL_VERSION: u8 = 0x00; + +pub const FLAG_SESSION: u8 = 1 << 5; +pub const FLAG_PASSWORD: u8 = 1 << 4; +pub const FLAG_COMPRESSED: u8 = 1 << 3; +pub const FLAG_CONTROL: u8 = 1 << 2; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum PayloadData { + Text(String), + Media { + text: String, + media: MediaDescriptor, + }, + Handshake(Vec), +} + +pub struct Header { + pub version: u8, + pub has_session: bool, + pub has_password: bool, + pub is_compressed: bool, + pub is_control: bool, +} + +impl Header { + pub fn encode(&self) -> u8 { + ((self.version & 0x03) << 6) + | if self.has_session { FLAG_SESSION } else { 0 } + | if self.has_password { FLAG_PASSWORD } else { 0 } + | if self.is_compressed { FLAG_COMPRESSED } else { 0 } + | if self.is_control { FLAG_CONTROL } else { 0 } + } + + pub fn decode(byte: u8) -> Self { + Self { + version: (byte >> 6) & 0x03, + has_session: (byte & FLAG_SESSION) != 0, + has_password: (byte & FLAG_PASSWORD) != 0, + is_compressed: (byte & FLAG_COMPRESSED) != 0, + is_control: (byte & FLAG_CONTROL) != 0, + } + } +} + +pub fn compress_bytes(bytes: &[u8]) -> Result, String> { + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(bytes).map_err(|e| e.to_string())?; + encoder.finish().map_err(|e| e.to_string()) +} + +pub fn decompress_bytes(bytes: &[u8]) -> Result, String> { + let mut decoder = DeflateDecoder::new(bytes); + let mut out = Vec::new(); + decoder.read_to_end(&mut out).map_err(|e| e.to_string())?; + Ok(out) +} + +pub fn pack_message( + payload: PayloadData, + session_key: Option<&[u8; 32]>, + password: Option<&str>, +) -> Result, String> { + let is_control = matches!(payload, PayloadData::Handshake(_)); + let mut raw_bytes = match payload { + PayloadData::Text(t) => { + let mut b = Vec::with_capacity(1 + t.len()); + b.push(0x01); + b.extend_from_slice(t.as_bytes()); + b + } + PayloadData::Media { text, media } => { + let mut b = Vec::new(); + b.push(0x02); + let encoded = rmp_serde::to_vec(&(text, media)).map_err(|e| e.to_string())?; + b.extend_from_slice(&encoded); + b + } + PayloadData::Handshake(bytes) => bytes, + }; + + let should_compress = !is_control && raw_bytes.len() >= 64; + let mut is_compressed = false; + if should_compress { + if let Ok(compressed) = compress_bytes(&raw_bytes) { + if compressed.len() < raw_bytes.len() { + raw_bytes = compressed; + is_compressed = true; + } + } + } + + let has_session = session_key.is_some(); + if let Some(s_key) = session_key { + let mut nonce = [0u8; 12]; + thread_rng().fill_bytes(&mut nonce); + let ciphertext = symmetric::encrypt(s_key, &nonce, &raw_bytes, None)?; + let mut session_payload = Vec::with_capacity(12 + ciphertext.len()); + session_payload.extend_from_slice(&nonce); + session_payload.extend_from_slice(&ciphertext); + raw_bytes = session_payload; + } + + let has_password = password.is_some(); + if let Some(pwd) = password { + let mut salt = [0u8; 16]; + let mut nonce = [0u8; 12]; + thread_rng().fill_bytes(&mut salt); + thread_rng().fill_bytes(&mut nonce); + + let p_key = symmetric::derive_key(pwd, &salt)?; + let ciphertext = symmetric::encrypt(&p_key, &nonce, &raw_bytes, None)?; + + let mut pass_payload = Vec::with_capacity(16 + 12 + ciphertext.len()); + pass_payload.extend_from_slice(&salt); + pass_payload.extend_from_slice(&nonce); + pass_payload.extend_from_slice(&ciphertext); + raw_bytes = pass_payload; + } + + let header = Header { + version: PROTOCOL_VERSION, + has_session, + has_password, + is_compressed, + is_control, + }; + + let mut out = Vec::with_capacity(1 + raw_bytes.len()); + out.push(header.encode()); + out.extend_from_slice(&raw_bytes); + + Ok(out) +} + +pub fn unpack_message( + bytes: &[u8], + session_key: Option<&[u8; 32]>, + password: Option<&str>, +) -> Result { + if bytes.is_empty() { + return Err("Empty packet".into()); + } + + let header = Header::decode(bytes[0]); + if header.version != PROTOCOL_VERSION { + return Err("Unsupported protocol version".into()); + } + + let mut current = bytes[1..].to_vec(); + + if header.has_password { + let pwd = password.ok_or("Password required to decrypt this message")?; + if current.len() < 16 + 12 + 16 { + return Err("Password payload too short".into()); + } + let salt = ¤t[..16]; + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(¤t[16..28]); + let ciphertext = ¤t[28..]; + + let p_key = symmetric::derive_key(pwd, salt)?; + current = symmetric::decrypt(&p_key, &nonce, ciphertext, None) + .map_err(|_| "Wrong password".to_string())?; + } + + if header.has_session { + let s_key = session_key.ok_or("Session key required to decrypt this message")?; + if current.len() < 12 + 16 { + return Err("Session payload too short".into()); + } + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(¤t[..12]); + let ciphertext = ¤t[12..]; + + current = symmetric::decrypt(s_key, &nonce, ciphertext, None) + .map_err(|_| "Failed to decrypt with session key".to_string())?; + } + + if header.is_compressed { + current = decompress_bytes(¤t)?; + } + + if header.is_control { + return Ok(PayloadData::Handshake(current)); + } + + if current.is_empty() { + return Ok(PayloadData::Text(String::new())); + } + + match current[0] { + 0x01 => { + let s = String::from_utf8(current[1..].to_vec()).map_err(|e| e.to_string())?; + Ok(PayloadData::Text(s)) + } + 0x02 => { + let (text, media): (String, MediaDescriptor) = + rmp_serde::from_slice(¤t[1..]).map_err(|e| e.to_string())?; + Ok(PayloadData::Media { text, media }) + } + _ => { + if let Ok(s) = String::from_utf8(current.clone()) { + Ok(PayloadData::Text(s)) + } else { + Err("Unknown payload format".into()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plain_message_roundtrip() { + let text = "Synthetic plain message test".to_string(); + let packed = pack_message(PayloadData::Text(text.clone()), None, None).unwrap(); + let unpacked = unpack_message(&packed, None, None).unwrap(); + + match unpacked { + PayloadData::Text(t) => assert_eq!(t, text), + _ => panic!("Expected text payload"), + } + } + + #[test] + fn test_session_encrypted_roundtrip() { + let session_key = [55u8; 32]; + let text = "Synthetic session encrypted message".to_string(); + let packed = + pack_message(PayloadData::Text(text.clone()), Some(&session_key), None).unwrap(); + assert_ne!(&packed[1..], text.as_bytes()); + + let unpacked = unpack_message(&packed, Some(&session_key), None).unwrap(); + match unpacked { + PayloadData::Text(t) => assert_eq!(t, text), + _ => panic!("Expected text payload"), + } + } + + #[test] + fn test_password_encrypted_roundtrip() { + let password = "synthetic_password_xyz"; + let text = "Synthetic password encrypted message".to_string(); + let packed = pack_message(PayloadData::Text(text.clone()), None, Some(password)).unwrap(); + + let unpacked = unpack_message(&packed, None, Some(password)).unwrap(); + match unpacked { + PayloadData::Text(t) => assert_eq!(t, text), + _ => panic!("Expected text payload"), + } + + let wrong_unpacked = unpack_message(&packed, None, Some("wrong_password")); + assert!(wrong_unpacked.is_err()); + } + + #[test] + fn test_both_layers_roundtrip() { + let session_key = [99u8; 32]; + let password = "dual_layer_secret"; + let text = "Synthetic dual layer message".to_string(); + + let packed = pack_message( + PayloadData::Text(text.clone()), + Some(&session_key), + Some(password), + ) + .unwrap(); + + let unpacked = unpack_message(&packed, Some(&session_key), Some(password)).unwrap(); + match unpacked { + PayloadData::Text(t) => assert_eq!(t, text), + _ => panic!("Expected text payload"), + } + } + + #[test] + fn test_media_descriptor_messagepack_roundtrip() { + let session_key = [77u8; 32]; + let desc = MediaDescriptor { + attach_index: 0, + name: "synthetic_image.jpg".into(), + mime: "image/jpeg".into(), + media_type: "PHOTO".into(), + size: 1048576, + width: Some(1920), + height: Some(1080), + duration: None, + }; + + let caption = "Photo caption".to_string(); + let payload = PayloadData::Media { + text: caption.clone(), + media: desc.clone(), + }; + + let packed = pack_message(payload, Some(&session_key), None).unwrap(); + let unpacked = unpack_message(&packed, Some(&session_key), None).unwrap(); + + match unpacked { + PayloadData::Media { + text, + media: unpacked_media, + } => { + assert_eq!(text, caption); + assert_eq!(unpacked_media.name, desc.name); + assert_eq!(unpacked_media.mime, desc.mime); + assert_eq!(unpacked_media.media_type, desc.media_type); + assert_eq!(unpacked_media.size, desc.size); + assert_eq!(unpacked_media.width, desc.width); + assert_eq!(unpacked_media.height, desc.height); + } + _ => panic!("Expected media payload"), + } + } +} diff --git a/src-tauri/src/crypto/symmetric.rs b/src-tauri/src/crypto/symmetric.rs new file mode 100644 index 0000000..9d7fa45 --- /dev/null +++ b/src-tauri/src/crypto/symmetric.rs @@ -0,0 +1,101 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Key, Nonce, +}; + +pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32], String> { + let params = Params::new(19456, 2, 1, Some(32)).map_err(|e| e.to_string())?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + + let mut key = [0u8; 32]; + argon2 + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| e.to_string())?; + + Ok(key) +} + +pub fn encrypt( + key: &[u8; 32], + nonce: &[u8; 12], + plaintext: &[u8], + aad: Option<&[u8]>, +) -> Result, String> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let n = Nonce::from_slice(nonce); + + match aad { + Some(ad) => { + use chacha20poly1305::aead::Payload; + let payload = Payload { + msg: plaintext, + aad: ad, + }; + cipher.encrypt(&n, payload).map_err(|e| e.to_string()) + } + None => cipher.encrypt(&n, plaintext).map_err(|e| e.to_string()), + } +} + +pub fn decrypt( + key: &[u8; 32], + nonce: &[u8; 12], + ciphertext: &[u8], + aad: Option<&[u8]>, +) -> Result, String> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let n = Nonce::from_slice(nonce); + + match aad { + Some(ad) => { + use chacha20poly1305::aead::Payload; + let payload = Payload { + msg: ciphertext, + aad: ad, + }; + cipher.decrypt(&n, payload).map_err(|e| e.to_string()) + } + None => cipher.decrypt(&n, ciphertext).map_err(|e| e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_symmetric_roundtrip() { + let key = [42u8; 32]; + let nonce = [7u8; 12]; + let plaintext = b"synthetic test message payload"; + let encrypted = encrypt(&key, &nonce, plaintext, None).unwrap(); + assert_ne!(encrypted, plaintext); + + let decrypted = decrypt(&key, &nonce, &encrypted, None).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_symmetric_wrong_key() { + let key = [42u8; 32]; + let wrong_key = [43u8; 32]; + let nonce = [7u8; 12]; + let plaintext = b"synthetic test message"; + let encrypted = encrypt(&key, &nonce, plaintext, None).unwrap(); + + let result = decrypt(&wrong_key, &nonce, &encrypted, None); + assert!(result.is_err()); + } + + #[test] + fn test_argon2_derive_key() { + let salt = [9u8; 16]; + let key1 = derive_key("password123", &salt).unwrap(); + let key2 = derive_key("password123", &salt).unwrap(); + assert_eq!(key1, key2); + + let key3 = derive_key("different", &salt).unwrap(); + assert_ne!(key1, key3); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30f1ab3..b884b3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod crypto; mod files; mod notifications; mod ssl; @@ -208,6 +209,15 @@ pub fn run() { webapp_proxy::get_webapp_filter_rules, webapp_proxy::get_webapp_ram_logs, webapp_proxy::clear_webapp_ram_logs, + crypto::commands::batch_decrypt_messages, + crypto::commands::encrypt_message, + crypto::commands::init_e2e_handshake, + crypto::commands::accept_e2e_handshake, + crypto::commands::process_e2e_accept, + crypto::commands::get_chat_encryption_info, + crypto::commands::make_dictionary, + crypto::commands::encrypt_media_file, + crypto::commands::decrypt_media_file, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/stores.rs b/src-tauri/src/stores.rs index 4551d28..fd91a5c 100644 --- a/src-tauri/src/stores.rs +++ b/src-tauri/src/stores.rs @@ -124,7 +124,7 @@ impl Storage { } } -fn crypto_key( +pub(crate) fn crypto_key( app: &AppHandle, account: u64 ) -> Option<[u8;32]> { @@ -137,13 +137,13 @@ fn crypto_key( .map(|x| x.key) } -struct Paths { - root: PathBuf, - cache: PathBuf, +pub(crate) struct Paths { + pub(crate) root: PathBuf, + pub(crate) cache: PathBuf, } impl Paths { - fn new( + pub(crate) fn new( app: &AppHandle, account: u64, ) -> Self { @@ -176,7 +176,7 @@ impl Paths { self.chat(chat).join("info") } - fn settings(&self, chat: i64) -> PathBuf { + pub(crate) fn settings(&self, chat: i64) -> PathBuf { self.chat(chat).join("settings") } diff --git a/src/components/ChatWindow.svelte b/src/components/ChatWindow.svelte index 4b442a6..49a97a5 100644 --- a/src/components/ChatWindow.svelte +++ b/src/components/ChatWindow.svelte @@ -31,10 +31,9 @@ get as sessionGet, } from "$lib/stores/session"; import { handleReaction } from "$components/ChatWindow/actions.js"; - import { - checkForEncryptionRequest, - decode_msg, - } from "$components/ChatWindow/e2e.js"; + import { checkForEncryptionRequest } from "$components/ChatWindow/e2e.js"; + import { batchDecrypt } from "$lib/crypto/messages.js"; + import { getCurrentAccount } from "$lib/stores/accounts.js"; import { scrollToBottom } from "$lib/utils/scroll.js"; import { getChatScroll, saveChatScroll } from "$lib/stores/chatScroll.js"; import * as Caching from "$lib/utils/caching.js"; @@ -516,22 +515,30 @@ const decodedMessages = writable({}); const decodeMessagesBatch = async (list) => { - const updates = {}; - const toRemove = []; - - await Promise.all( - list.map(async msg => { - const res = await decode_msg(msg); - if (res) updates[msg.id] = res; - else toRemove.push(msg.id); - }) - ); + if (!list || !list.length) return; + try { + const account = await getCurrentAccount(); + const password = $chatSettings?.password || null; + const currentChatId = chat?.id ?? chatId; + const updates = await batchDecrypt( + Number(account?.id || 0), + Number(currentChatId), + list, + password + ); - decodedMessages.update(old => { - const next = { ...old, ...updates }; - for (const id of toRemove) delete next[id]; - return next; - }); + decodedMessages.update(old => ({ + ...old, + ...updates + })); + + const newReq = await checkForEncryptionRequest(chat, chatSettings, updates, list); + if (newReq) { + gotSecretChatRequest = newReq; + } + } catch (e) { + console.error(e); + } }; const mergeMessages = async ( @@ -941,13 +948,7 @@ } return _messages; }); - const decoded = await decode_msg(message); - decodedMessages.update(d => { - const next = { ...d }; - if (decoded) next[message.id] = decoded; - else delete next[message.id]; - return next; - }); + await decodeMessagesBatch([message]); await tick(); applyPendingHeights(); computeCumulativeHeights(); @@ -976,8 +977,7 @@ return _messages; }); - const decoded = await decode_msg(message); - if (decoded) decodedMessages.update(d => ({ ...d, [message.id]: decoded })); + await decodeMessagesBatch([message]); await tick(); applyPendingHeights(); @@ -990,8 +990,6 @@ await updateVisibleMessages(wasAtBottom); } - - checkForEncryptionRequest(chat, chatSettings, [message]); }); } } diff --git a/src/components/ChatWindow/E2eModal.svelte b/src/components/ChatWindow/E2eModal.svelte index b743406..1ceea88 100644 --- a/src/components/ChatWindow/E2eModal.svelte +++ b/src/components/ChatWindow/E2eModal.svelte @@ -1,5 +1,6 @@ + + {#if gotSecretChatRequest} -
@@ -92,15 +92,29 @@ Статус
- { $chatSettings.keys.current ? "Активно" - : $chatSettings.keys.keys?.some(x => x.edp === null) ? "Предложение отправлено" + { ($chatSettings.keys?.current || $chatSettings.session) ? "Активно" + : $chatSettings.pending ? "Предложение отправлено" : "Отключено" }
+ {#if $chatSettings.session?.fingerprint} +
+
+ Ключ сессии +
+
+ {$chatSettings.session.fingerprint} +
+
+ {/if} -Что НЕ реализовано: -➖ Звонки, работа с папками, настройки приватности - Что реализовано: -➕ Вход, регистрация, работа с контактами, каналы и чаты, стикеры, медиа, веб-приложения (Сферум и Цифровой ID), работа с папками, сохранение удаленных сообщений, включение шифрования +➕ Двухслойное шифрование сообщений и медиа, сохранение сообщений, работа с несколькими аккаунтами, кружки и голосовые из файла, цифровой ID и сферум Что еще будет: -➕ Светлая тема, Telegram Proxy, разные интерфейсы, шифрованные звонки, поддержка кастомного сервера +➕ Безопасные шифруемые звонки, плагины, поддержка кастомных серверов ## Предостережения @@ -59,16 +56,13 @@ ### Особенности - Открытый исходный код -- Приложение не требует доступ к файлам устройства, микрофону, камере -- Прозрачность запросов к oneme[.]ru и ok[.]ru, можно просматривать каждый запрос в логах -- Сквозное E2E шифрование (можно включить для отдельных чатов) -- В планах: поддержка звонков, многопользовательских чатов +- Приложение весит всего от 5 до 20 МБ +- Написано с нуля, есть почти все из официального клиента +- Возможность шифровать все сообщения и медиафайлы +- Просмотр и блокировка запросов к oneme[.]ru и ok[.]ru Небольшой размер приложения достигается использованием системного WebView (Tauri) -> [!NOTE] -> Для покупки пива в Магните можно разрешить доступ к камере (QR-сканер) - ## Использование Предварительные версии доступны в разделе [Releases](https://github.com/me0wkie/maxplus/releases) @@ -221,4 +215,4 @@ $ rm -rf ~/.local/share/org.meowkie.max - [PyMax](https://github.com/noxzion/PyMax) — работа с Max API (портировано на Rust в репозитории [rumax](https://github.com/me0wkie/rumax)) - [Tauri](https://github.com/tauri-apps/tauri) — фреймворк для разработки приложений на WebView -- [libsodium-wrappers-sumo](https://www.npmjs.com/package/libsodium-wrappers-sumo) — криптография для сквозного шифрования +- [x25519-dalek](https://crates.io/crates/x25519-dalek), [ed25519-dalek](https://crates.io/crates/ed25519-dalek) и [chacha20poly1305](https://crates.io/crates/chacha20poly1305) — криптография для сквозного шифрования diff --git a/src-tauri/src/crypto/commands.rs b/src-tauri/src/crypto/commands.rs index 2820ec8..84a9e54 100644 --- a/src-tauri/src/crypto/commands.rs +++ b/src-tauri/src/crypto/commands.rs @@ -11,9 +11,12 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use futures_util::future::join_all; use std::collections::HashMap; use std::fs; use std::path::Path; +use std::sync::Arc; use tauri::{AppHandle, Manager}; #[derive(Serialize, Deserialize, Clone, Debug)] @@ -94,160 +97,171 @@ fn load_dictionary_data(app: &AppHandle) -> Option { } #[tauri::command] -pub async fn batch_decrypt_messages( - app: AppHandle, - account: u64, - chat_id: i64, - messages: Vec, - password: Option, -) -> Result, String> { - let settings = load_chat_settings_json(&app, account, chat_id); - let session_key = get_active_session_key(&settings); - let effective_password = password.or_else(|| { - settings - .get("password") - .and_then(|p| p.as_str()) - .map(|s| s.to_string()) - }); - - let dict_opt = load_dictionary_data(&app); - let mut results = HashMap::new(); - - for msg in messages { - let msg_id_str = match &msg.id { - Value::Number(n) => n.to_string(), - Value::String(s) => s.clone(), - _ => continue, - }; +fn decrypt_single_message_dto( + msg: IncomingMessageDto, + session_key: Option<&[u8; 32]>, + effective_password: Option<&str>, + dict_opt: Option<&DictionaryData>, +) -> Option<(String, DecryptedMessageDto)> { + let msg_id_str = match &msg.id { + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + _ => return None, + }; - let raw_text = match msg.text.as_deref() { - Some(t) if !t.trim().is_empty() => t.trim(), - _ => continue, - }; + let raw_text = match msg.text.as_deref() { + Some(t) if !t.trim().is_empty() => t.trim(), + _ => return None, + }; - let mut obf_name: Option = None; - let mut raw_bytes: Option> = None; + let mut obf_name: Option = None; + let mut raw_bytes: Option> = None; - if ChineseObfuscator::detect(raw_text) { - if let Ok(b) = ChineseObfuscator::deobfuscate(raw_text) { - obf_name = Some("zh".into()); - raw_bytes = Some(b); - } + if ChineseObfuscator::detect(raw_text) { + if let Ok(b) = ChineseObfuscator::deobfuscate(raw_text) { + obf_name = Some("zh".into()); + raw_bytes = Some(b); } + } - if raw_bytes.is_none() { - if let Some(ref dict) = dict_opt { - if WordsObfuscator::detect(raw_text, dict) { - if let Ok(b) = WordsObfuscator::deobfuscate(raw_text, dict) { - obf_name = Some("words".into()); - raw_bytes = Some(b); - } + if raw_bytes.is_none() { + if let Some(dict) = dict_opt { + if WordsObfuscator::detect(raw_text, dict) { + if let Ok(b) = WordsObfuscator::deobfuscate(raw_text, dict) { + obf_name = Some("words".into()); + raw_bytes = Some(b); } } } + } - if raw_bytes.is_none() { - if let Ok(b) = STANDARD.decode(raw_text) { - if !b.is_empty() && (b[0] >> 6) == 0 { - raw_bytes = Some(b); - } + if raw_bytes.is_none() { + if let Ok(b) = STANDARD.decode(raw_text) { + if !b.is_empty() && (b[0] >> 6) == 0 { + raw_bytes = Some(b); } } + } - let bytes = match raw_bytes { - Some(b) => b, - None => continue, - }; + let bytes = match raw_bytes { + Some(b) => b, + None => return None, + }; - match unpack_message(&bytes, session_key.as_ref(), effective_password.as_deref()) { - Ok(PayloadData::Text(t)) => { - results.insert( - msg_id_str, + let dto = match unpack_message(&bytes, session_key, effective_password) { + Ok(PayloadData::Text(t)) => DecryptedMessageDto { + text: t, + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: None, + }, + Ok(PayloadData::Media { text, media }) => DecryptedMessageDto { + text, + obf: obf_name, + is_encrypted: true, + media: Some(media), + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: None, + }, + Ok(PayloadData::Handshake(hs_bytes)) => { + let parsed = parse_and_verify_handshake(&hs_bytes); + match parsed { + Ok(p) => { + let is_init = p.subtype == 0x01; + let notice_text = if is_init { + "Запрос на секретный чат".to_string() + } else { + "Секретный чат установлен".to_string() + }; DecryptedMessageDto { - text: t, + text: notice_text, obf: obf_name, is_encrypted: true, media: None, - is_handshake_request: false, - is_handshake_accept: false, - handshake_data: None, - error: None, - }, - ); - } - Ok(PayloadData::Media { text, media }) => { - results.insert( - msg_id_str, - DecryptedMessageDto { - text, - obf: obf_name, - is_encrypted: true, - media: Some(media), - is_handshake_request: false, - is_handshake_accept: false, - handshake_data: None, + is_handshake_request: is_init, + is_handshake_accept: !is_init, + handshake_data: Some(hex::encode(&hs_bytes)), error: None, - }, - ); - } - Ok(PayloadData::Handshake(hs_bytes)) => { - let parsed = parse_and_verify_handshake(&hs_bytes); - match parsed { - Ok(p) => { - let is_init = p.subtype == 0x01; - let notice_text = if is_init { - "Запрос на секретный чат".to_string() - } else { - "Секретный чат установлен".to_string() - }; - results.insert( - msg_id_str, - DecryptedMessageDto { - text: notice_text, - obf: obf_name, - is_encrypted: true, - media: None, - is_handshake_request: is_init, - is_handshake_accept: !is_init, - handshake_data: Some(hex::encode(&hs_bytes)), - error: None, - }, - ); - } - Err(e) => { - results.insert( - msg_id_str, - DecryptedMessageDto { - text: "Ошибка проверки рукопожатия" - .into(), - obf: obf_name, - is_encrypted: true, - media: None, - is_handshake_request: false, - is_handshake_accept: false, - handshake_data: None, - error: Some(e), - }, - ); } } - } - Err(e) => { - results.insert( - msg_id_str, - DecryptedMessageDto { - text: format!("Ошибка! {e}"), - obf: obf_name, - is_encrypted: true, - media: None, - is_handshake_request: false, - is_handshake_accept: false, - handshake_data: None, - error: Some(e), - }, - ); + Err(e) => DecryptedMessageDto { + text: "Ошибка проверки рукопожатия".into(), + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: Some(e), + }, } } + Err(e) => DecryptedMessageDto { + text: format!("Ошибка! {e}"), + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: Some(e), + }, + }; + + Some((msg_id_str, dto)) +} + +#[tauri::command] +pub async fn batch_decrypt_messages( + app: AppHandle, + account: u64, + chat_id: i64, + messages: Vec, + password: Option, +) -> Result, String> { + let settings = load_chat_settings_json(&app, account, chat_id); + let session_key = get_active_session_key(&settings); + let effective_password = password.or_else(|| { + settings + .get("password") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()) + }); + + let dict_opt = Arc::new(load_dictionary_data(&app)); + let session_key = Arc::new(session_key); + let effective_password = Arc::new(effective_password); + + let tasks: Vec<_> = messages + .into_iter() + .map(|msg| { + let session_key = session_key.clone(); + let effective_password = effective_password.clone(); + let dict_opt = dict_opt.clone(); + tokio::task::spawn_blocking(move || { + decrypt_single_message_dto( + msg, + session_key.as_ref().as_ref(), + effective_password.as_deref(), + dict_opt.as_ref().as_ref(), + ) + }) + }) + .collect(); + + let results_vec = join_all(tasks).await; + + let mut results = HashMap::with_capacity(results_vec.len()); + for res in results_vec { + if let Ok(Some((msg_id_str, dto))) = res { + results.insert(msg_id_str, dto); + } } Ok(results) @@ -499,25 +513,66 @@ pub async fn make_dictionary(app: AppHandle, text: Value) -> Result) -> Option<[u8; 32]> { + if let Some(sk) = get_active_session_key(settings) { + return Some(sk); + } + + let effective_password = password_arg.or_else(|| { + settings + .get("password") + .and_then(|p| p.as_str()) + }); + + if let Some(pwd) = effective_password { + let salt = b"maxplus_media_salt_bytes"; + if let Ok(derived) = crate::crypto::symmetric::derive_key(pwd, salt) { + return Some(derived); + } + } + + let mut hasher = Sha256::new(); + hasher.update(b"maxplus_media_obfuscation_fixed_key_v1"); + let res: [u8; 32] = hasher.finalize().into(); + Some(res) +} + #[tauri::command] pub async fn encrypt_media_file( app: AppHandle, account: u64, chat_id: i64, file_path: String, - dummy_type: String, + dummy_type: Option, + password: Option, ) -> Result { let settings = load_chat_settings_json(&app, account, chat_id); - let session_key = get_active_session_key(&settings) - .ok_or_else(|| "Session key required to encrypt media".to_string())?; + let key = get_media_crypto_key(&settings, password.as_deref()) + .ok_or_else(|| "Failed to derive encryption key for media".to_string())?; let raw_bytes = fs::read(&file_path).map_err(|e| e.to_string())?; - let encrypted = encrypt_media_bytes(&raw_bytes, &session_key, &dummy_type)?; + let dummy = dummy_type.as_deref().unwrap_or("pdf"); + let encrypted = encrypt_media_bytes(&raw_bytes, &key, dummy)?; + + let ext = match dummy { + "docx" => "docx", + "xlsx" => "xlsx", + "mp3" => "mp3", + "ogg" => "ogg", + _ => "pdf", + }; - let out_path = format!("{}.enc", file_path); + let cache_dir = app + .path() + .app_cache_dir() + .map_err(|e| e.to_string())? + .join("encrypted_outgoing"); + let _ = fs::create_dir_all(&cache_dir); + let rand_id = uuid::Uuid::new_v4().to_string(); + let out_path = cache_dir.join(format!("{}.{}", rand_id, ext)); fs::write(&out_path, encrypted).map_err(|e| e.to_string())?; - Ok(out_path) + Ok(out_path.to_string_lossy().to_string()) } #[tauri::command] @@ -526,19 +581,98 @@ pub async fn decrypt_media_file( account: u64, chat_id: i64, file_path: String, - out_path: String, + out_path: Option, + target_path: Option, + password: Option, ) -> Result { + let effective_out = out_path + .or(target_path) + .ok_or_else(|| "Missing out_path or target_path".to_string())?; + let settings = load_chat_settings_json(&app, account, chat_id); - let session_key = get_active_session_key(&settings) - .ok_or_else(|| "Session key required to decrypt media".to_string())?; + let key = get_media_crypto_key(&settings, password.as_deref()) + .ok_or_else(|| "Failed to derive decryption key for media".to_string())?; let encrypted_bytes = fs::read(&file_path).map_err(|e| e.to_string())?; - let decrypted = decrypt_media_bytes(&encrypted_bytes, &session_key)?; + let decrypted = decrypt_media_bytes(&encrypted_bytes, &key)?; - if let Some(parent) = Path::new(&out_path).parent() { + if let Some(parent) = Path::new(&effective_out).parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - fs::write(&out_path, decrypted).map_err(|e| e.to_string())?; + fs::write(&effective_out, decrypted).map_err(|e| e.to_string())?; - Ok(out_path) + if file_path.ends_with(".enc") { + let _ = fs::remove_file(&file_path); + } + + Ok(effective_out) +} + +#[tauri::command] +pub async fn cache_encrypted_media( + app: AppHandle, + account: u64, + chat_id: i64, + file_id: u64, + src: String, + password: Option, +) -> Result { + let cache_key = format!("enc_media_{}", file_id); + + if let Ok(Some(existing)) = crate::stores::get_cached_file(app.clone(), account, cache_key.clone()) { + if Path::new(&existing).exists() { + return Ok(existing); + } + } + + let encrypted_bytes = if src.starts_with("http://") || src.starts_with("https://") { + let client = rumax::shared_http_client(); + let resp = client.get(&src) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.bytes().await.map_err(|e| e.to_string())?.to_vec() + } else { + fs::read(&src).map_err(|e| e.to_string())? + }; + + let settings = load_chat_settings_json(&app, account, chat_id); + let key = get_media_crypto_key(&settings, password.as_deref()) + .ok_or_else(|| "Failed to derive decryption key for media".to_string())?; + + let decrypted = decrypt_media_bytes(&encrypted_bytes, &key)?; + + let path = crate::stores::set_cached_file_with_meta( + app, + account, + cache_key, + decrypted, + Some(chat_id), + None, + )?; + + Ok(path) +} + +#[tauri::command] +pub async fn register_media_cache( + app: AppHandle, + account: u64, + chat_id: i64, + file_id: u64, + local_path: String, +) -> Result { + let cache_key = format!("enc_media_{}", file_id); + let bytes = fs::read(&local_path).map_err(|e| e.to_string())?; + crate::stores::set_cached_file_with_meta( + app, + account, + cache_key, + bytes, + Some(chat_id), + None, + ) } diff --git a/src-tauri/src/crypto/media.rs b/src-tauri/src/crypto/media.rs index a0f1376..6fc9835 100644 --- a/src-tauri/src/crypto/media.rs +++ b/src-tauri/src/crypto/media.rs @@ -2,7 +2,8 @@ use crate::crypto::symmetric; use rand::{RngCore, thread_rng}; use serde::{Deserialize, Serialize}; -const MAGIC_MARKER: &[u8] = b"MAXMEDIA\x01"; +pub const BINARY_MAGIC: [u8; 2] = [0x8F, 0x3D]; +const LEGACY_MAGIC: &[u8] = b"MAXMEDIA\x01"; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct MediaDescriptor { @@ -14,6 +15,12 @@ pub struct MediaDescriptor { pub width: Option, pub height: Option, pub duration: Option, + #[serde(default)] + pub wave: Option>, + #[serde(default)] + pub video_type: Option, + #[serde(default)] + pub color: Option, } pub fn encrypt_media_bytes( @@ -24,18 +31,18 @@ pub fn encrypt_media_bytes( let mut nonce = [0u8; 12]; thread_rng().fill_bytes(&mut nonce); - let ciphertext = symmetric::encrypt(key, &nonce, raw_bytes, Some(MAGIC_MARKER))?; + let ciphertext = symmetric::encrypt(key, &nonce, raw_bytes, Some(&BINARY_MAGIC))?; let dummy_header: &[u8] = match dummy_type { - "pdf" => b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n", - "docx" => b"PK\x03\x04\x14\x00\x08\x00\x08\x00", + "docx" | "xlsx" => b"PK\x03\x04\x14\x00\x08\x00\x08\x00", "mp3" => b"ID3\x03\x00\x00\x00\x00\x00", - _ => b"", + "ogg" => b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00", + _ => b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n", }; - let mut out = Vec::with_capacity(dummy_header.len() + MAGIC_MARKER.len() + 12 + ciphertext.len()); + let mut out = Vec::with_capacity(dummy_header.len() + BINARY_MAGIC.len() + 12 + ciphertext.len()); out.extend_from_slice(dummy_header); - out.extend_from_slice(MAGIC_MARKER); + out.extend_from_slice(&BINARY_MAGIC); out.extend_from_slice(&nonce); out.extend_from_slice(&ciphertext); @@ -44,29 +51,70 @@ pub fn encrypt_media_bytes( pub fn decrypt_media_bytes(encrypted_bytes: &[u8], key: &[u8; 32]) -> Result, String> { let marker_pos = encrypted_bytes - .windows(MAGIC_MARKER.len()) - .position(|window| window == MAGIC_MARKER); + .windows(BINARY_MAGIC.len()) + .position(|window| window == BINARY_MAGIC); - let payload_start = match marker_pos { - Some(pos) => pos + MAGIC_MARKER.len(), - None => { - if encrypted_bytes.len() >= 28 { - 0 - } else { - return Err("Encrypted media header missing".into()); + if let Some(pos) = marker_pos { + let payload = &encrypted_bytes[pos + BINARY_MAGIC.len()..]; + if payload.len() >= 28 { + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&payload[..12]); + let ciphertext = &payload[12..]; + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, Some(&BINARY_MAGIC)) { + return Ok(dec); + } + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, None) { + return Ok(dec); } } - }; + } + + let legacy_pos = encrypted_bytes + .windows(LEGACY_MAGIC.len()) + .position(|window| window == LEGACY_MAGIC); - let remaining = &encrypted_bytes[payload_start..]; - if remaining.len() < 28 { - return Err("Encrypted payload too short".into()); + if let Some(pos) = legacy_pos { + let payload = &encrypted_bytes[pos + LEGACY_MAGIC.len()..]; + if payload.len() >= 28 { + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&payload[..12]); + let ciphertext = &payload[12..]; + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, Some(LEGACY_MAGIC)) { + return Ok(dec); + } + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, None) { + return Ok(dec); + } + } } - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&remaining[..12]); - let ciphertext = &remaining[12..]; + let known_headers: &[&[u8]] = &[ + b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n", + b"PK\x03\x04\x14\x00\x08\x00\x08\x00", + b"ID3\x03\x00\x00\x00\x00\x00", + b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00", + ]; + + for hdr in known_headers { + if encrypted_bytes.starts_with(hdr) && encrypted_bytes.len() >= hdr.len() + 28 { + let payload = &encrypted_bytes[hdr.len()..]; + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&payload[..12]); + let ciphertext = &payload[12..]; + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, None) { + return Ok(dec); + } + } + } + + if encrypted_bytes.len() >= 28 { + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&encrypted_bytes[..12]); + let ciphertext = &encrypted_bytes[12..]; + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, None) { + return Ok(dec); + } + } - symmetric::decrypt(key, &nonce, ciphertext, Some(MAGIC_MARKER)) - .or_else(|_| symmetric::decrypt(key, &nonce, ciphertext, None)) + Err("Failed to decrypt media bytes: invalid key or corrupted envelope".into()) } diff --git a/src-tauri/src/crypto/protocol.rs b/src-tauri/src/crypto/protocol.rs index be84867..59d43f6 100644 --- a/src-tauri/src/crypto/protocol.rs +++ b/src-tauri/src/crypto/protocol.rs @@ -297,6 +297,9 @@ mod tests { width: Some(1920), height: Some(1080), duration: None, + wave: None, + video_type: None, + color: None, }; let caption = "Photo caption".to_string(); diff --git a/src-tauri/src/crypto/symmetric.rs b/src-tauri/src/crypto/symmetric.rs index 9d7fa45..9757542 100644 --- a/src-tauri/src/crypto/symmetric.rs +++ b/src-tauri/src/crypto/symmetric.rs @@ -4,7 +4,20 @@ use chacha20poly1305::{ ChaCha20Poly1305, Key, Nonce, }; +use std::collections::HashMap; +use std::sync::{LazyLock, RwLock}; + +static KEY_CACHE: LazyLock), [u8; 32]>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32], String> { + let cache_key = (password.to_string(), salt.to_vec()); + if let Ok(guard) = KEY_CACHE.read() { + if let Some(cached_key) = guard.get(&cache_key) { + return Ok(*cached_key); + } + } + let params = Params::new(19456, 2, 1, Some(32)).map_err(|e| e.to_string())?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); @@ -13,6 +26,10 @@ pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32], String> { .hash_password_into(password.as_bytes(), salt, &mut key) .map_err(|e| e.to_string())?; + if let Ok(mut guard) = KEY_CACHE.write() { + guard.insert(cache_key, key); + } + Ok(key) } diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs index 7449b3c..c6a1fc1 100644 --- a/src-tauri/src/files.rs +++ b/src-tauri/src/files.rs @@ -22,6 +22,22 @@ fn unwrap_media_source(raw: &str) -> (Option, Option) { if s.starts_with("http://asset.localhost/") { let path = s.trim_start_matches("http://asset.localhost/"); let path = urlencoding::decode(path).map(|c| c.into_owned()).unwrap_or_else(|_| path.to_string()); + #[cfg(not(target_os = "windows"))] + let path = if path.starts_with('/') { path } else { format!("/{}", path) }; + return (Some(path), None); + } + if s.starts_with("asset://localhost/") { + let path = s.trim_start_matches("asset://localhost/"); + let path = urlencoding::decode(path).map(|c| c.into_owned()).unwrap_or_else(|_| path.to_string()); + #[cfg(not(target_os = "windows"))] + let path = if path.starts_with('/') { path } else { format!("/{}", path) }; + return (Some(path), None); + } + if s.starts_with("asset://") { + let path = s.trim_start_matches("asset://"); + let path = urlencoding::decode(path).map(|c| c.into_owned()).unwrap_or_else(|_| path.to_string()); + #[cfg(not(target_os = "windows"))] + let path = if path.starts_with('/') { path } else { format!("/{}", path) }; return (Some(path), None); } if s.starts_with('/') { @@ -244,6 +260,7 @@ pub async fn upload( token: Option, mime: Option, video_type: Option, + file_name: Option, ) -> Result { use tokio::fs::File; @@ -277,16 +294,24 @@ pub async fn upload( tokio::fs::File::from_std(std_file) }; + let upload_name = file_name.unwrap_or_else(|| { + std::path::Path::new(&effective_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string() + }); + match attach_type.as_str() { - "PHOTO" => Ok(state.client.upload_photo(upload_url, file, effective_path, mime).await), + "PHOTO" => Ok(state.client.upload_photo(upload_url, file, upload_name, mime).await), "VIDEO" | "AUDIO" => { let video_id = video_id.ok_or("No video_id")?; let token = token.ok_or("No token")?; - Ok(state.client.upload_video(upload_url, video_id, token, file, effective_path).await) + Ok(state.client.upload_video(upload_url, video_id, token, file, upload_name).await) } "FILE" => { let file_id = file_id.ok_or("No file_id")?; - Ok(state.client.upload_file(upload_url, file_id, file, effective_path).await) + Ok(state.client.upload_file(upload_url, file_id, file, upload_name).await) } _ => Err("Wrong type".into()), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b884b3e..9bf914d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,8 @@ pub fn run() { crypto::commands::make_dictionary, crypto::commands::encrypt_media_file, crypto::commands::decrypt_media_file, + crypto::commands::cache_encrypted_media, + crypto::commands::register_media_cache, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/video.rs b/src-tauri/src/video.rs index 127874d..312781d 100644 --- a/src-tauri/src/video.rs +++ b/src-tauri/src/video.rs @@ -16,12 +16,25 @@ fn handle_local_file(request: tiny_http::Request, mut path: &str) { if let Some(stripped) = path.strip_prefix("file://") { path = stripped; } + let fixed_path; + let path = if !path.starts_with('/') && !path.contains("://") && !path.starts_with('\\') { + let candidate = format!("/{}", path); + if std::path::Path::new(&candidate).exists() { + fixed_path = candidate; + &fixed_path + } else { + path + } + } else { + path + }; use std::fs::File; use std::io::{Seek, SeekFrom}; let mut file = match File::open(path) { Ok(f) => f, - Err(_) => { + Err(e) => { + eprintln!("[VideoProxy] File Not Found: {} (error: {})", path, e); let _ = request.respond(Response::from_string("File Not Found").with_status_code(404)); return; } @@ -61,6 +74,8 @@ fn handle_local_file(request: tiny_http::Request, mut path: &str) { } } + println!("[VideoProxy] Serving local file: {}, mime: {}, size: {}", path, mime, file_len); + let mut range_header = None; for h in request.headers() { if h.field.as_str().as_str().eq_ignore_ascii_case("Range") { @@ -176,6 +191,8 @@ fn handle_request(request: tiny_http::Request, client: &reqwest::blocking::Clien } }; + println!("[VideoProxy] Incoming request raw_url: {}, decoded: {}", raw_url, url); + if url.starts_with('/') { handle_local_file(request, &url); return; @@ -184,16 +201,34 @@ fn handle_request(request: tiny_http::Request, client: &reqwest::blocking::Clien handle_local_file(request, local_path); return; } - if let Some(asset_path) = url.strip_prefix("asset://localhost/") { - let decoded = urlencoding::decode(asset_path).unwrap_or(std::borrow::Cow::Borrowed(asset_path)); - handle_local_file(request, &decoded); + if let Some(asset_path) = url.strip_prefix("asset://") { + let trimmed = asset_path.strip_prefix("localhost/").unwrap_or(asset_path); + let decoded = urlencoding::decode(trimmed).unwrap_or(std::borrow::Cow::Borrowed(trimmed)); + let path = if !decoded.starts_with('/') && !decoded.contains("://") { + format!("/{}", decoded) + } else { + decoded.into_owned() + }; + handle_local_file(request, &path); return; } if let Some(asset_path) = url.strip_prefix("http://asset.localhost/") { let decoded = urlencoding::decode(asset_path).unwrap_or(std::borrow::Cow::Borrowed(asset_path)); - handle_local_file(request, &decoded); + let path = if !decoded.starts_with('/') && !decoded.contains("://") { + format!("/{}", decoded) + } else { + decoded.into_owned() + }; + handle_local_file(request, &path); return; } + if !url.starts_with("http://") && !url.starts_with("https://") { + let candidate = format!("/{}", url.trim_start_matches('/')); + if std::path::Path::new(&candidate).exists() { + handle_local_file(request, &candidate); + return; + } + } if url.starts_with("http://") || url.starts_with("https://") { if let Some(data_dir) = dirs::data_local_dir().or_else(dirs::data_dir) { diff --git a/src/components/ChatWindow.svelte b/src/components/ChatWindow.svelte index 49a97a5..6d88f04 100644 --- a/src/components/ChatWindow.svelte +++ b/src/components/ChatWindow.svelte @@ -33,6 +33,8 @@ import { handleReaction } from "$components/ChatWindow/actions.js"; import { checkForEncryptionRequest } from "$components/ChatWindow/e2e.js"; import { batchDecrypt } from "$lib/crypto/messages.js"; + import { convertFileSrc } from "@tauri-apps/api/core"; + import { getProxiedMediaUrl } from "$lib/utils/images.js"; import { getCurrentAccount } from "$lib/stores/accounts.js"; import { scrollToBottom } from "$lib/utils/scroll.js"; import { getChatScroll, saveChatScroll } from "$lib/stores/chatScroll.js"; @@ -517,13 +519,24 @@ const decodeMessagesBatch = async (list) => { if (!list || !list.length) return; try { + const currentDecoded = get(decodedMessages); + const toDecode = list.filter(m => { + const idStr = String(m.id); + const existing = currentDecoded[idStr]; + if (!existing) return true; + if (m.edited || m.status === "EDITED") return true; + return false; + }); + + if (!toDecode.length) return; + const account = await getCurrentAccount(); const password = $chatSettings?.password || null; const currentChatId = chat?.id ?? chatId; const updates = await batchDecrypt( Number(account?.id || 0), Number(currentChatId), - list, + toDecode, password ); @@ -532,7 +545,7 @@ ...updates })); - const newReq = await checkForEncryptionRequest(chat, chatSettings, updates, list); + const newReq = await checkForEncryptionRequest(chat, chatSettings, updates, toDecode); if (newReq) { gotSecretChatRequest = newReq; } @@ -605,8 +618,6 @@ if (!changed.length) return; - await decodeMessagesBatch(changed); - messages.set( [...map.values()].sort( (a,b) => a.time - b.time @@ -616,6 +627,8 @@ if (updateCache) { chatCache.updateMessages(changed); } + + await decodeMessagesBatch(changed); }; const loadHistory = async ( @@ -1249,7 +1262,15 @@ e.target.closest(".voice-message-bubble") || e.target.closest(".video-note-bubble") || e.target.closest(".transcription-card") || - e.target.closest(".transcription-close-btn") + e.target.closest(".transcription-close-btn") || + e.target.closest(".media-grid") || + e.target.closest(".grid-item") || + e.target.closest(".attaches") || + e.target.closest(".media-download-badge") || + e.target.closest(".file-attachment") || + e.target.closest(".file-attach") || + e.target.closest(".attach") || + e.target.closest(".encrypted-media-placeholder") ) return; if (clicked) { @@ -1322,7 +1343,15 @@ e.target.closest(".inline-keyboard") || e.target.closest(".inline-btn") || e.target.closest(".avatar-msg-btn") || - e.target.closest(".avatar-wrapper") + e.target.closest(".avatar-wrapper") || + e.target.closest(".media-grid") || + e.target.closest(".grid-item") || + e.target.closest(".attaches") || + e.target.closest(".media-download-badge") || + e.target.closest(".file-attachment") || + e.target.closest(".file-attach") || + e.target.closest(".attach") || + e.target.closest(".encrypted-media-placeholder") ) return; const dx = Math.abs(e.clientX - clickStartPos.x); @@ -1501,23 +1530,64 @@ } } - /* media stuff */ - $: allMedia = $messages.flatMap((m) => - (m.attaches || []) + $: allMedia = $messages.flatMap((m) => { + const decoded = $decodedMessages[String(m.id)]; + const media = decoded?.media; + const isMe = Number(m.sender) === Number($currentUser); + const attaches = (m.attaches || []).map((att, idx) => { + if (media && idx === (media.attach_index ?? 0)) { + const resolvedType = media.media_type || att._type || "FILE"; + const localPath = att.localPath || (isMe ? att.path : null); + return { + ...att, + _type: resolvedType, + type: resolvedType, + originalType: media.media_type, + name: media.name || att.name, + size: media.size || att.size, + mime: media.mime || att.mime, + width: media.width ?? att.width, + height: media.height ?? att.height, + duration: media.duration ?? att.duration, + wave: media.wave ?? att.wave, + videoType: media.video_type ?? att.videoType, + color: media.color || null, + isEncryptedMedia: true, + encryptedAttach: att, + localPath, + baseUrl: att.baseUrl || (localPath ? (resolvedType === "VIDEO" ? getProxiedMediaUrl(localPath) : convertFileSrc(localPath)) : null), + }; + } + return att; + }); + + return attaches .filter((a) => a._type === "PHOTO" || a._type === "VIDEO") - .map((a) => ({ - ...a, - messageId: m.id, - uid: a.videoId || a.photoId || a.url || a.baseUrl, - })), - ); + .map((a) => { + const fid = a.fileId || a.encryptedAttach?.fileId; + const uid = a.videoId || a.photoId || fid || a.url || a.baseUrl || a.localPath || `${m.id}_${a.name || 'media'}`; + return { + ...a, + messageId: m.id, + uid: String(uid), + }; + }); + }); function openMedia(attach) { - const targetUid = - attach.videoId || attach.photoId || attach.url || attach.baseUrl; - const index = allMedia.findIndex((m) => m.uid === targetUid); + const fid = attach.fileId || attach.encryptedAttach?.fileId; + const targetUid = String(attach.videoId || attach.photoId || fid || attach.url || attach.baseUrl || attach.localPath || ""); + const index = allMedia.findIndex((m) => + (targetUid && m.uid === targetUid) || + (attach.baseUrl && m.baseUrl === attach.baseUrl) || + (attach.localPath && m.localPath === attach.localPath) || + (fid && (m.fileId === fid || m.encryptedAttach?.fileId === fid)) + ); if (index !== -1) { + if (attach.baseUrl) allMedia[index].baseUrl = attach.baseUrl; + if (attach.localPath) allMedia[index].localPath = attach.localPath; + allMedia = [...allMedia]; viewerIndex = index; viewerOpen = true; } @@ -1708,6 +1778,7 @@ {messages} {chatSettings} {botCommands} + {decodedMessages} /> {/if} diff --git a/src/components/ChatWindow/Attachments.svelte b/src/components/ChatWindow/Attachments.svelte index 671c17f..516d1ab 100644 --- a/src/components/ChatWindow/Attachments.svelte +++ b/src/components/ChatWindow/Attachments.svelte @@ -1,10 +1,15 @@ @@ -266,10 +297,10 @@ {/each} {/if} - {#if msg.attaches?.length} + {#if effectiveAttaches?.length} +
+
Автозагрузка зашифрованных медиа
+ +
Уведомления
+ {#if attachType === "PHOTO"} preview @@ -1651,6 +1728,16 @@
{/if} + + {#if attach.uploading || attach.uploaded} +
+
+
+
+ {/if} {/each} @@ -2400,9 +2487,54 @@ justify-content: center; font-size: 11px; cursor: pointer; + z-index: 4; + opacity: 1; + transform: scale(1); + transition: opacity 0.2s ease, transform 0.2s ease; + } + + .attach-card .remove.hidden { + opacity: 0; + pointer-events: none; + transform: scale(0.7); + } + + .upload-overlay { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.55); + pointer-events: none; + transition: height 0.12s ease-out; z-index: 2; } + .upload-loader-wrap { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 3; + } + + .upload-loader { + width: 24px; + height: 24px; + border: 2.5px solid rgba(255, 255, 255, 0.25); + border-top-color: #fff; + border-radius: 50%; + animation: upload-spin 0.8s linear infinite; + } + + @keyframes upload-spin { + to { + transform: rotate(360deg); + } + } + .file-preview { text-align: center; padding: 4px; From 7c917aa41b1fa19019a33248de43737c5b48d8e9 Mon Sep 17 00:00:00 2001 From: JuniorAww <50590566+junioraww@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:40:12 +0200 Subject: [PATCH 04/12] fix: digitalId page not updating --- src-tauri/src/commands.rs | 65 +++++ src-tauri/src/lib.rs | 1 + src-tauri/src/webapp_proxy.rs | 100 ++++--- src/lib/services/digitalId.js | 315 ++++++++++++++++++++++ src/lib/stores/webapp.js | 7 + src/lib/webapp/storage.js | 6 + src/routes/digital_id/+page.svelte | 420 +++++++++++++++++++++++++---- tests/digital_id.spec.js | 243 +++++++++++++++++ 8 files changed, 1068 insertions(+), 89 deletions(-) create mode 100644 src/lib/services/digitalId.js create mode 100644 tests/digital_id.spec.js diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 232a100..491daec 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -251,4 +251,69 @@ pub async fn get_system_trace_info(app: AppHandle) -> Result { })) } +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ExtApiResponse { + pub status: u16, + pub ok: bool, + pub data: Value, +} + +#[tauri::command] +pub async fn ext_api_request( + method: String, + path: String, + headers: Option>, + body: Option, +) -> Result { + let base_url = "https://ext-api.max.ru"; + let url = if path.starts_with("https://") || path.starts_with("http://") { + path + } else if path.starts_with('/') { + format!("{}{}", base_url, path) + } else { + format!("{}/{}", base_url, path) + }; + + let client = rumax::shared_http_client(); + let req_method = match method.to_uppercase().as_str() { + "GET" => reqwest::Method::GET, + "POST" => reqwest::Method::POST, + "PUT" => reqwest::Method::PUT, + "DELETE" => reqwest::Method::DELETE, + "PATCH" => reqwest::Method::PATCH, + _ => return Err(format!("Unsupported method: {}", method)), + }; + + let mut req = client.request(req_method, &url); + if let Some(hdrs) = headers { + for (k, v) in hdrs { + let header_val = reqwest::header::HeaderValue::from_str(&v) + .or_else(|_| reqwest::header::HeaderValue::from_bytes(v.as_bytes())); + if let (Ok(name), Ok(val)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + header_val, + ) { + req = req.header(name, val); + } + } + } + + if let Some(b) = body { + req = req.json(&b); + } + + let res = req.send().await.map_err(|e| e.to_string())?; + let status = res.status().as_u16(); + let ok = res.status().is_success(); + let text = res.text().await.map_err(|e| e.to_string())?; + + let data = if text.trim().is_empty() { + Value::Null + } else { + serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text)) + }; + + Ok(ExtApiResponse { status, ok, data }) +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9bf914d..a8a6bef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -84,6 +84,7 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + commands::ext_api_request, commands::get_system_trace_info, commands::get_video_secret, commands::init, diff --git a/src-tauri/src/webapp_proxy.rs b/src-tauri/src/webapp_proxy.rs index c519011..f8df2b0 100644 --- a/src-tauri/src/webapp_proxy.rs +++ b/src-tauri/src/webapp_proxy.rs @@ -184,9 +184,15 @@ const SHIM_SCRIPT: &str = concat!( r#"var t=resolveTarget(u);"#, r#"if(t){return 'http://127.0.0.1:11448/proxy?url='+encodeURIComponent(t);}"#, r#"return u;}"#, - r#"try{var lp=HTMLLinkElement.prototype;var ld=Object.getOwnPropertyDescriptor(lp,'href');if(ld&&ld.set){var olsh=ld.set;Object.defineProperty(lp,'href',{set:function(v){return olsh.call(this,wrapProxy(v));},get:ld.get,configurable:true,enumerable:true});}}catch(e){}"#, - r#"try{var sp=HTMLScriptElement.prototype;var sd=Object.getOwnPropertyDescriptor(sp,'src');if(sd&&sd.set){var osss=sd.set;Object.defineProperty(sp,'src',{set:function(v){return osss.call(this,wrapProxy(v));},get:sd.get,configurable:true,enumerable:true});}}catch(e){}"#, - r#"try{var osa=Element.prototype.setAttribute;Element.prototype.setAttribute=function(n,v){try{var k=String(n).toLowerCase();var tg=(this.tagName||'').toLowerCase();if((tg==='link'&&k==='href')||(tg==='script'&&k==='src')){v=wrapProxy(v);}}catch(x){}return osa.call(this,n,v);};}catch(e){}"#, + r#"function wrapProxyAsset(u){"#, + r#"if(!u||typeof u!=='string')return u;"#, + r#"if(u.indexOf('https://')===0||u.indexOf('http://')===0){"#, + r#"if(u.indexOf('http://127.0.0.1')===0||u.indexOf('http://localhost')===0)return u;"#, + r#"return wrapProxy(u);"#, + r#"}return u;}"#, + r#"try{var lp=HTMLLinkElement.prototype;var ld=Object.getOwnPropertyDescriptor(lp,'href');if(ld&&ld.set){var olsh=ld.set;Object.defineProperty(lp,'href',{set:function(v){return olsh.call(this,wrapProxyAsset(v));},get:ld.get,configurable:true,enumerable:true});}}catch(e){}"#, + r#"try{var sp=HTMLScriptElement.prototype;var sd=Object.getOwnPropertyDescriptor(sp,'src');if(sd&&sd.set){var osss=sd.set;Object.defineProperty(sp,'src',{set:function(v){return osss.call(this,wrapProxyAsset(v));},get:sd.get,configurable:true,enumerable:true});}}catch(e){}"#, + r#"try{var osa=Element.prototype.setAttribute;Element.prototype.setAttribute=function(n,v){try{var k=String(n).toLowerCase();var tg=(this.tagName||'').toLowerCase();if((tg==='link'&&k==='href')||(tg==='script'&&k==='src')){v=wrapProxyAsset(v);}}catch(x){}return osa.call(this,n,v);};}catch(e){}"#, r#"try{var ow=window.Worker;if(ow){window.Worker=function(u,o){return new ow(wrapProxy(u),o);};}}catch(e){}"#, r#"window.open=function(u){var t=resolveTarget(u)||u;if(t){toParent('web_app_open_link',{url:String(t)});}return null;};"#, r#"document.addEventListener('click',function(e){var a=e.target&&e.target.closest?e.target.closest('a'):null;if(!a)return;var h=a.getAttribute('href');if(!h||h.charAt(0)==='#'||h.indexOf('javascript:')===0)return;var tgt=a.getAttribute('target');var isMax=(h.indexOf('max.ru')!==-1||h.indexOf('max://')===0||(a.href&&a.href.indexOf('max.ru')!==-1)||(a.href&&a.href.indexOf('max://')===0));if(tgt==='_blank'||tgt==='_new'||isMax){var targetUrl=resolveTarget(h)||resolveTarget(a.href)||a.href;if(targetUrl){e.preventDefault();e.stopPropagation();toParent('web_app_open_link',{url:targetUrl});}}},true);"#, @@ -279,6 +285,7 @@ fn handle_request( let mut caller_origin = String::new(); let mut caller_base = String::new(); + let mut referer_dir = String::new(); for h in request.headers() { let name = h.field.as_str().as_str(); if name.eq_ignore_ascii_case("Referer") { @@ -303,6 +310,15 @@ fn handle_request( caller_origin = get_origin_from_url(ref_val); if caller_origin.contains("127.0.0.1") || caller_origin.contains("localhost") { caller_origin.clear(); + if let Ok(parsed_ref) = url::Url::parse(ref_val) { + let p = parsed_ref.path(); + if let Some(pos) = p.rfind('/') { + let d = &p[..pos + 1]; + if d != "/" && !d.is_empty() { + referer_dir = d.to_string(); + } + } + } } } } else if name.eq_ignore_ascii_case("Cookie") { @@ -325,7 +341,9 @@ fn handle_request( } } - if caller_base.is_empty() { + if !referer_dir.is_empty() && !caller_origin.is_empty() { + caller_base = format!("{}{}", caller_origin.trim_end_matches('/'), referer_dir); + } else if caller_base.is_empty() { if let Ok(lock) = state.last_base_url.lock() { caller_base = lock.clone(); } @@ -337,13 +355,21 @@ fn handle_request( let clean_req = req_url.split('?').next().unwrap_or(&req_url); let is_css_req = clean_req.ends_with(".css"); let is_js_req = clean_req.ends_with(".js") || clean_req.ends_with(".mjs"); - let is_asset_path = req_url.starts_with("/assets/") || req_url.starts_with("assets/"); let mut target_url = if let Some(t) = direct_target.take() { t - } else if !base_url.is_empty() && is_asset_path && !base_url.ends_with("/assets/") { - let relative = req_url.trim_start_matches('/'); - format!("{}{}", base_url, relative) + } else if !base_url.is_empty() { + let rel = req_url.trim_start_matches('/'); + if base_url.ends_with("/assets/") && rel.starts_with("assets/") { + let inner = rel.trim_start_matches("assets/").trim_start_matches('/'); + format!("{}{}", base_url, inner) + } else if rel.starts_with("assets/") { + format!("{}/{}", origin.trim_end_matches('/'), rel) + } else if base_url.ends_with("/assets/") { + format!("{}{}", base_url, rel) + } else { + format!("{}/{}", base_url.trim_end_matches('/'), rel) + } } else if !origin.is_empty() { format!("{}{}", origin.trim_end_matches('/'), req_url) } else { @@ -576,16 +602,34 @@ fn handle_request( .unwrap_or(""); if is_asset_request && (initial_status == 404 || (res.status().is_success() && initial_ct.contains("text/html"))) { - let alt_target = if !base_url.is_empty() && target_url.starts_with(&base_url) { - format!("{}{}", origin.trim_end_matches('/'), req_url) - } else if !base_url.is_empty() && target_url.starts_with(&origin) { - let relative = req_url.trim_start_matches('/'); - format!("{}{}", base_url, relative) - } else { - String::new() - }; + let clean_path = req_url.trim_start_matches('/'); + let inner_path = clean_path.trim_start_matches("assets/").trim_start_matches('/'); + let mut candidates = Vec::new(); + + if !base_url.is_empty() { + let b = base_url.trim_end_matches('/'); + let u1 = format!("{}/assets/{}", b, inner_path); + let u2 = format!("{}/{}", b, clean_path); + let u3 = format!("{}/{}", b, inner_path); + for u in [u1, u2, u3] { + if u != target_url && !candidates.contains(&u) { + candidates.push(u); + } + } + } + if !origin.is_empty() { + let o = origin.trim_end_matches('/'); + let u1 = format!("{}/assets/{}", o, inner_path); + let u2 = format!("{}/{}", o, clean_path); + let u3 = format!("{}/{}", o, inner_path); + for u in [u1, u2, u3] { + if u != target_url && !candidates.contains(&u) { + candidates.push(u); + } + } + } - if !alt_target.is_empty() && alt_target != target_url { + for alt_target in candidates { let mut alt_rb = match method_str.as_str() { "HEAD" => client.head(&alt_target), _ => client.get(&alt_target), @@ -632,6 +676,7 @@ fn handle_request( if alt_res.status().is_success() && !alt_ct.contains("text/html") { res = alt_res; target_url = alt_target; + break; } } } @@ -925,27 +970,6 @@ fn handle_request( text = format!("{}{}", full_shim, text); } - if !page_origin.is_empty() { - let proxy_prefix = "http://127.0.0.1:11448/proxy?url="; - if let Ok(re_link) = Regex::new(r#"(<(?:link|script)\b[^>]*?\b(?:href|src)=["'])(/(?:assets/|[^"']+\.(?:css|js|mjs)))(["'])"#) { - let po = page_origin.clone(); - text = re_link.replace_all(&text, |caps: ®ex::Captures| { - let full_url = format!("{}{}", po.trim_end_matches('/'), &caps[2]); - format!("{}{}{}{}", &caps[1], proxy_prefix, urlencoding::encode(&full_url), &caps[3]) - }).to_string(); - } - if !base_path_str.is_empty() { - if let Ok(re_rel) = Regex::new(r#"(<(?:link|script)\b[^>]*?\b(?:href|src)=["'])(\.?/assets/[^"']+)(["'])"#) { - let bp = base_path_str.clone(); - text = re_rel.replace_all(&text, |caps: ®ex::Captures| { - let path = caps[2].trim_start_matches('.').trim_start_matches('/'); - let full_url = format!("{}{}", bp, path); - format!("{}{}{}{}", &caps[1], proxy_prefix, urlencoding::encode(&full_url), &caps[3]) - }).to_string(); - } - } - } - text.into_bytes() } else { bytes diff --git a/src/lib/services/digitalId.js b/src/lib/services/digitalId.js new file mode 100644 index 0000000..d276c6d --- /dev/null +++ b/src/lib/services/digitalId.js @@ -0,0 +1,315 @@ +import { invoke as defaultInvoke } from "@tauri-apps/api/core"; + +let invokeHandler = defaultInvoke; + +export function setApiInvoker(fn) { + invokeHandler = fn || defaultInvoke; +} + +let activeAuthHeader = null; +let authHeaderExpiry = 0; + +export const DOCUMENT_TITLES = { + passport: "Паспорт гражданина РФ", + oms: "Полис ОМС", + inn: "ИНН", + driver_license: "Водительское удостоверение", + vehicle_sts: "СТС", + snils: "СНИЛС", + child_birth_cert: "Свидетельство о рождении", + pension_cert: "Пенсионное удостоверение", + disabled_cert: "Справка об инвалидности", + large_family_cert: "Удостоверение многодетной семьи", + student_ticket: "Студенческий билет", + child_inn: "ИНН ребёнка", + child_oms: "Полис ОМС ребёнка", +}; + +export function parseInitData(rawUrl) { + if (!rawUrl || typeof rawUrl !== "string") return null; + const hashPos = rawUrl.indexOf("#"); + if (hashPos < 0) return null; + const fragment = rawUrl.slice(hashPos + 1); + try { + const params = new URLSearchParams(fragment); + const data = params.get("WebAppData") || params.get("tgWebAppData"); + if (data) { + return data; + } + } catch {} + const matched = fragment.match(/(?:tg)?WebAppData=([^&]+)/); + if (matched?.[1]) { + try { + return decodeURIComponent(matched[1]); + } catch { + return matched[1]; + } + } + return null; +} + +export function resetDigitalIdAuth() { + activeAuthHeader = null; + authHeaderExpiry = 0; +} + +export async function resolveAuthHeader(apiInstance, force = false) { + if (!force && activeAuthHeader && Date.now() < authHeaderExpiry) { + return activeAuthHeader; + } + if (!apiInstance || typeof apiInstance.launchDigitalId !== "function") { + return activeAuthHeader; + } + const launch = await apiInstance.launchDigitalId(); + const token = parseInitData(launch?.url); + if (!token) return activeAuthHeader; + activeAuthHeader = `#WebAppData=${token}`; + authHeaderExpiry = Date.now() + 10 * 60 * 1000; + return activeAuthHeader; +} + +export async function requestExtApi(method, path, options = {}) { + const { body = null, headers = {}, apiInstance = null, allowRetry = true } = options; + const auth = apiInstance + ? await resolveAuthHeader(apiInstance) + : activeAuthHeader; + + const reqHeaders = { + Accept: "application/json", + Origin: "https://digital-id.max.ru", + Referer: "https://digital-id.max.ru/", + "x-requested-with": "ru.oneme.app", + ...(auth ? { Authorization: auth } : {}), + ...headers, + }; + + const response = await invokeHandler("ext_api_request", { + method, + path, + headers: reqHeaders, + body, + }); + + if (response?.status === 401 && allowRetry && apiInstance) { + resetDigitalIdAuth(); + const freshAuth = await resolveAuthHeader(apiInstance, true); + reqHeaders.Authorization = freshAuth; + return await invokeHandler("ext_api_request", { + method, + path, + headers: reqHeaders, + body, + }); + } + + return response; +} + +export async function resolveSessionDeviceId() { + try { + const dev = await invokeHandler("get_device"); + if (dev?.deviceId) return dev.deviceId; + } catch {} + try { + const direct = localStorage.getItem("max_device_id"); + if (direct) return direct; + } catch {} + return getAppDeviceId(); +} + +export function getAppDeviceId() { + let devId = ""; + try { + devId = localStorage.getItem("max_device_id") || ""; + if (!devId) { + devId = crypto.randomUUID().replace(/-/g, ""); + localStorage.setItem("max_device_id", devId); + } + } catch {} + return devId; +} + +export function getStoredBiometryToken(userId, botId) { + try { + const direct = localStorage.getItem("digital_id_biometry_token"); + if (direct) return direct; + if (userId && botId) { + const bioKey = `max_wa_bio_${userId}_${botId}`; + const raw = localStorage.getItem(bioKey); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed?.token) return parsed.token; + } + } + } catch {} + return null; +} + +export function saveStoredBiometryToken(token, userId, botId) { + try { + if (token) { + localStorage.setItem("digital_id_biometry_token", token); + if (userId && botId) { + const bioKey = `max_wa_bio_${userId}_${botId}`; + const existing = JSON.parse(localStorage.getItem(bioKey) || "{}"); + existing.token = token; + existing.granted = true; + localStorage.setItem(bioKey, JSON.stringify(existing)); + } + } else { + localStorage.removeItem("digital_id_biometry_token"); + if (userId && botId) { + localStorage.removeItem(`max_wa_bio_${userId}_${botId}`); + } + } + } catch {} +} + +export async function fetchSecurityStatus(apiInstance) { + const resp = await requestExtApi("GET", "/v3.1/digital-id/security-status", { + apiInstance, + }); + const data = resp?.data?.data || resp?.data || {}; + return { + tokenLevel: data?.token_level || null, + deviceId: data?.device_id || null, + attemptsLeft: data?.attempts_left, + cooldown: data?.cooldown, + }; +} + +export async function fetchBiometryStatus(apiInstance) { + const resp = await requestExtApi("GET", "/v2/digital-id/biometry-status", { + apiInstance, + }); + const data = resp?.data?.data || resp?.data || {}; + return { + hasBiometryToken: !!data?.has_biometry_token, + deviceId: data?.device_id || null, + hasPhotoHash: !!data?.has_photo_hash, + }; +} + +export async function acquireBiometryToken(apiInstance, userId, botId) { + const existing = getStoredBiometryToken(userId, botId); + if (existing) return existing; + const devId = await resolveSessionDeviceId(); + const resp = await requestExtApi("POST", "/v3/digital-id/create-biometry-token", { + body: { device_id: devId }, + apiInstance, + }); + const token = resp?.data?.data?.token || resp?.data?.token || ""; + if (token) { + saveStoredBiometryToken(token, userId, botId); + } + return token; +} + +function delay(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function fetchDigitalIdProfile(apiInstance, userId, botId) { + let isLinked = false; + let profile = null; + let documents = []; + let cards = []; + let error = null; + + try { + const secStatus = await fetchSecurityStatus(apiInstance); + if (secStatus.tokenLevel === "high" || secStatus.tokenLevel === "lite") { + isLinked = true; + } + } catch (err) { + error = err?.message || String(err); + } + + try { + const bioStatus = await fetchBiometryStatus(apiInstance); + if (bioStatus.hasBiometryToken) { + isLinked = true; + } + } catch {} + + try { + const token = await acquireBiometryToken(apiInstance, userId, botId); + if (!token) { + return { isLinked, profile, documents, cards, error: isLinked ? null : error }; + } + + const refreshResp = await requestExtApi("POST", "/v3/digital-id/refresh-user-docs", { + body: { token }, + apiInstance, + }); + + const refreshData = refreshResp?.data || {}; + const errCode = refreshData?.error?.code || refreshData?.code || (typeof refreshData?.error === "string" ? refreshData.error : null); + if (errCode === "NO_GOSUSLUGI_LINK") { + return { isLinked: false, profile: null, documents: [], cards: [], error: null }; + } + + const state = refreshData?.data?.state || refreshData?.state; + if (state) { + isLinked = true; + for (let i = 0; i < 5; i++) { + await delay(1200); + const docsResp = await requestExtApi("POST", "/v2/digital-id/get-user-docs", { + body: { state }, + apiInstance, + }); + const docsData = docsResp?.data || {}; + if (docsData?.status === "done" && docsData?.data) { + const fetchedProfile = docsData.data.digital_profile || {}; + profile = { + firstName: fetchedProfile.first_name || "", + lastName: fetchedProfile.last_name || "", + middleName: fetchedProfile.middle_name || "", + birthDate: fetchedProfile.birth_date || null, + birthPlace: fetchedProfile.birth_place || null, + gender: fetchedProfile.gender || null, + snils: fetchedProfile.snils || null, + inn: fetchedProfile.inn || null, + address: fetchedProfile.registration_address?.address || null, + }; + documents = Array.isArray(fetchedProfile.documents) ? fetchedProfile.documents : []; + isLinked = true; + break; + } + } + } + } catch (err) { + if (!error) error = err?.message || String(err); + } + + try { + const cardsResp = await requestExtApi("GET", "/v2/digital-id/get-cards-list?pass_status=active", { + apiInstance, + }); + const rawCards = cardsResp?.data?.data?.acms_cards || cardsResp?.data?.acms_cards; + if (Array.isArray(rawCards)) { + cards = rawCards; + if (cards.length > 0) { + isLinked = true; + } + } + } catch {} + + return { isLinked, profile, documents, cards, error: isLinked ? null : error }; +} + +export async function deleteDigitalIdProfile(apiInstance, userId, botId) { + try { + await requestExtApi("DELETE", "/v3/digital-id/delete-profile", { apiInstance }); + } finally { + saveStoredBiometryToken(null, userId, botId); + resetDigitalIdAuth(); + } +} + +export async function requestEsiaLink(apiInstance) { + const resp = await requestExtApi("GET", "/v2/digital-id/create-esia-link", { + apiInstance, + }); + return resp?.data?.data?.url || resp?.data?.url || ""; +} diff --git a/src/lib/stores/webapp.js b/src/lib/stores/webapp.js index 9b896c6..783c646 100644 --- a/src/lib/stores/webapp.js +++ b/src/lib/stores/webapp.js @@ -11,6 +11,8 @@ export const visibleWebApps = derived(activeWebApps, ($apps) => $apps.filter((a) => a.state !== "minimized") ); +export const lastClosedMiniApp = writable(null); + let instanceCounter = 0; export async function openMiniApp({ @@ -170,6 +172,11 @@ export function restoreMiniApp(id) { } export function closeMiniApp(id) { + const current = get(activeWebApps); + const found = current.find((a) => a.id === id); + if (found) { + lastClosedMiniApp.set({ ...found, closedAt: Date.now() }); + } activeWebApps.update((apps) => apps.filter((a) => a.id !== id)); } diff --git a/src/lib/webapp/storage.js b/src/lib/webapp/storage.js index de07a9e..96fa610 100644 --- a/src/lib/webapp/storage.js +++ b/src/lib/webapp/storage.js @@ -153,6 +153,9 @@ export function updateBiometryTokenValue(userId, botId, token) { if (!token) { delete data.token; writeBiometryData(userId, botId, data); + try { + localStorage.removeItem("digital_id_biometry_token"); + } catch {} return { status: "removed" }; } if (token.length > 1024) { @@ -160,5 +163,8 @@ export function updateBiometryTokenValue(userId, botId, token) { } data.token = String(token); writeBiometryData(userId, botId, data); + try { + localStorage.setItem("digital_id_biometry_token", String(token)); + } catch {} return { status: "updated" }; } diff --git a/src/routes/digital_id/+page.svelte b/src/routes/digital_id/+page.svelte index 7a9f7ca..c420ddd 100644 --- a/src/routes/digital_id/+page.svelte +++ b/src/routes/digital_id/+page.svelte @@ -1,13 +1,86 @@
+

Цифровой ID

+
@@ -114,13 +227,49 @@ {#if isLinked} - {/if}
+ {#if isLinked && digitalProfile && (digitalProfile.snils || digitalProfile.inn || digitalProfile.birthDate || digitalProfile.address)} +
Личные данные
+
+ {#if digitalProfile.snils} +
+ СНИЛС + {digitalProfile.snils} +
+ {/if} + {#if digitalProfile.inn} +
+ ИНН + {digitalProfile.inn} +
+ {/if} + {#if digitalProfile.birthDate} +
+ Дата рождения + {digitalProfile.birthDate} +
+ {/if} + {#if digitalProfile.birthPlace} +
+ Место рождения + {digitalProfile.birthPlace} +
+ {/if} + {#if digitalProfile.address} +
+ Регистрация + {digitalProfile.address} +
+ {/if} +
+ {/if} +
Сервисы
@@ -142,51 +291,93 @@
Электронные документы
-
-
- - - - - - -
-
-
Паспорт гражданина РФ
-
{isLinked ? "Подтвержден" : "Требуется привязка ЕСИА"}
+ {#if isLinked && documents.length > 0} + {#each documents as doc, idx} + {#if idx > 0} +
+ {/if} +
+
+ + + + + + +
+
+
{DOCUMENT_TITLES[doc.type] || doc.type}
+
{getDocDescription(doc)}
+
+
+ {/each} + {:else} +
+
+ + + + + + +
+
+
Паспорт гражданина РФ
+
{isLinked ? "Подтвержден" : "Требуется привязка ЕСИА"}
+
-
- -
-
-
- - - - -
-
-
СНИЛС и ИНН
-
{isLinked ? "Синхронизировано" : "Требуется привязка ЕСИА"}
+
+ +
+
+ + + + +
+
+
СНИЛС и ИНН
+
{isLinked ? "Синхронизировано" : "Требуется привязка ЕСИА"}
+
-
- -
-
-
- - - - -
-
-
Водительское удостоверение
-
{isLinked ? "Электронный документ" : "Требуется привязка ЕСИА"}
+
+ +
+
+ + + + +
+
+
Водительское удостоверение
+
{isLinked ? "Электронный документ" : "Требуется привязка ЕСИА"}
+
-
+ {/if}
+ + {#if isLinked && cards.length > 0} +
Пропуска
+
+ {#each cards as card, idx} + {#if idx > 0} +
+ {/if} +
+
+
{card.company_name || card.companyName || "Пропуск"}
+ {#if card.inn} +
ИНН {card.inn}
+ {/if} +
+ Активен +
+ {/each} +
+ {/if}
@@ -202,6 +393,12 @@ /> {/if} +{#if toastMessage} +
+ {toastMessage} +
+{/if} + diff --git a/tests/digital_id.spec.js b/tests/digital_id.spec.js new file mode 100644 index 0000000..19b3aab --- /dev/null +++ b/tests/digital_id.spec.js @@ -0,0 +1,243 @@ +import { test, expect } from "@playwright/test"; +import { + parseInitData, + DOCUMENT_TITLES, + getStoredBiometryToken, + saveStoredBiometryToken, + setApiInvoker, + resetDigitalIdAuth, + requestExtApi, + fetchDigitalIdProfile, + fetchSecurityStatus, +} from "../src/lib/services/digitalId.js"; + +test.describe("digital ID service and parsing", () => { + let mockStore; + + test.beforeAll(() => { + mockStore = new Map(); + if (typeof globalThis.localStorage === "undefined") { + globalThis.localStorage = { + getItem: (k) => mockStore.get(k) ?? null, + setItem: (k, v) => mockStore.set(k, String(v)), + removeItem: (k) => mockStore.delete(k), + clear: () => mockStore.clear(), + }; + } + }); + + test.beforeEach(() => { + resetDigitalIdAuth(); + setApiInvoker(null); + mockStore?.clear?.(); + }); + + test("extracts encoded init data from url fragment", () => { + const syntheticUrl = + "https://digital-id.synthetic.test/#WebAppData=query_id%3Dsynth_123%26user%3D%257B%2522id%2522%253A999%257D%26hash%3Dabc"; + const parsed = parseInitData(syntheticUrl); + expect(parsed).toBe("query_id=synth_123&user=%7B%22id%22%3A999%7D&hash=abc"); + }); + + test("returns null when WebAppData is absent", () => { + expect(parseInitData("https://digital-id.synthetic.test/#other=123")).toBeNull(); + expect(parseInitData("https://digital-id.synthetic.test/no-hash")).toBeNull(); + expect(parseInitData("")).toBeNull(); + expect(parseInitData(null)).toBeNull(); + }); + + test("contains standard document title mappings", () => { + expect(DOCUMENT_TITLES.passport).toBe("Паспорт гражданина РФ"); + expect(DOCUMENT_TITLES.driver_license).toBe("Водительское удостоверение"); + expect(DOCUMENT_TITLES.snils).toBe("СНИЛС"); + expect(DOCUMENT_TITLES.inn).toBe("ИНН"); + }); + + test("manages biometry token in local storage", () => { + const syntheticToken = "synth_bio_token_test_abc123"; + const syntheticUser = "999888"; + const syntheticBot = "8250447"; + + saveStoredBiometryToken(syntheticToken, syntheticUser, syntheticBot); + expect(getStoredBiometryToken(syntheticUser, syntheticBot)).toBe(syntheticToken); + + saveStoredBiometryToken(null, syntheticUser, syntheticBot); + expect(getStoredBiometryToken(syntheticUser, syntheticBot)).toBeNull(); + }); + + test("routes ext_api_request through invoker with synthetic headers", async () => { + let capturedCall = null; + setApiInvoker(async (command, args) => { + capturedCall = { command, args }; + return { status: 200, ok: true, data: { status: "ok" } }; + }); + + const mockApi = { + launchDigitalId: async () => ({ + url: "https://digital-id.synthetic.test/#WebAppData=synth_auth_token_999", + }), + }; + + const resp = await requestExtApi("GET", "/v2/digital-id/biometry-status", { + apiInstance: mockApi, + }); + + expect(resp.status).toBe(200); + expect(capturedCall).not.toBeNull(); + expect(capturedCall.command).toBe("ext_api_request"); + expect(capturedCall.args.path).toBe("/v2/digital-id/biometry-status"); + expect(capturedCall.args.headers.Authorization).toBe("#WebAppData=synth_auth_token_999"); + }); + + test("fetchDigitalIdProfile returns unlinked when NO_GOSUSLUGI_LINK is received", async () => { + setApiInvoker(async (command, args) => { + if (args.path.includes("biometry-status")) { + return { status: 200, ok: true, data: { data: { has_biometry_token: false } } }; + } + if (args.path.includes("create-biometry-token")) { + return { status: 200, ok: true, data: { data: { token: "synth_tok_1" } } }; + } + if (args.path.includes("refresh-user-docs")) { + return { status: 200, ok: true, data: { code: "NO_GOSUSLUGI_LINK" } }; + } + return { status: 200, ok: true, data: {} }; + }); + + const mockApi = { + launchDigitalId: async () => ({ + url: "https://digital-id.synthetic.test/#WebAppData=synth_auth_token_999", + }), + }; + + const result = await fetchDigitalIdProfile(mockApi, "999888", 8250447); + expect(result.isLinked).toBe(false); + expect(result.profile).toBeNull(); + expect(result.documents).toHaveLength(0); + }); + + test("fetchDigitalIdProfile parses profile and documents when linked", async () => { + setApiInvoker(async (command, args) => { + if (args.path.includes("biometry-status")) { + return { status: 200, ok: true, data: { data: { has_biometry_token: true } } }; + } + if (args.path.includes("create-biometry-token")) { + return { status: 200, ok: true, data: { data: { token: "synth_tok_2" } } }; + } + if (args.path.includes("refresh-user-docs")) { + return { status: 200, ok: true, data: { data: { state: "synth_state_abc" } } }; + } + if (args.path.includes("get-user-docs")) { + return { + status: 200, + ok: true, + data: { + status: "done", + data: { + digital_profile: { + first_name: "Иван", + last_name: "Иванов", + snils: "123-456-789 00", + inn: "123456789012", + documents: [ + { type: "passport", fields: { series: "1234", number: "567890" } }, + { type: "snils", fields: { number: "123-456-789 00" } }, + ], + }, + }, + }, + }; + } + if (args.path.includes("get-cards-list")) { + return { + status: 200, + ok: true, + data: { + data: { + acms_cards: [ + { id: "c1", inn: "7700000000", company_name: "Synthetic Org" }, + ], + }, + }, + }; + } + return { status: 200, ok: true, data: {} }; + }); + + const mockApi = { + launchDigitalId: async () => ({ + url: "https://digital-id.synthetic.test/#WebAppData=synth_auth_token_999", + }), + }; + + const result = await fetchDigitalIdProfile(mockApi, "999888", 8250447); + expect(result.isLinked).toBe(true); + expect(result.profile?.firstName).toBe("Иван"); + expect(result.profile?.lastName).toBe("Иванов"); + expect(result.profile?.snils).toBe("123-456-789 00"); + expect(result.documents).toHaveLength(2); + expect(result.documents[0].type).toBe("passport"); + expect(result.cards).toHaveLength(1); + expect(result.cards[0].company_name).toBe("Synthetic Org"); + }); + + test("fetchSecurityStatus extracts token_level and device_id", async () => { + setApiInvoker(async (command, args) => { + if (args.path.includes("security-status")) { + return { + status: 200, + ok: true, + data: { + data: { + token_level: "high", + device_id: "synth_dev_456", + attempts_left: 3, + cooldown: 0, + }, + }, + }; + } + return { status: 200, ok: true, data: {} }; + }); + + const mockApi = { + launchDigitalId: async () => ({ + url: "https://digital-id.synthetic.test/#WebAppData=synth_auth_token_999", + }), + }; + + const status = await fetchSecurityStatus(mockApi); + expect(status.tokenLevel).toBe("high"); + expect(status.deviceId).toBe("synth_dev_456"); + expect(status.attemptsLeft).toBe(3); + }); + + test("fetchDigitalIdProfile marks isLinked when token_level is high even without documents", async () => { + setApiInvoker(async (command, args) => { + if (args.path.includes("security-status")) { + return { status: 200, ok: true, data: { data: { token_level: "high" } } }; + } + if (args.path.includes("biometry-status")) { + return { status: 200, ok: true, data: { data: { has_biometry_token: false } } }; + } + if (args.path.includes("create-biometry-token")) { + return { status: 200, ok: true, data: { data: { token: "synth_tok_3" } } }; + } + if (args.path.includes("refresh-user-docs")) { + return { status: 200, ok: true, data: { data: { state: "" } } }; + } + if (args.path.includes("get-cards-list")) { + return { status: 200, ok: true, data: { data: { acms_cards: [] } } }; + } + return { status: 200, ok: true, data: {} }; + }); + + const mockApi = { + launchDigitalId: async () => ({ + url: "https://digital-id.synthetic.test/#WebAppData=synth_auth_token_999", + }), + }; + + const result = await fetchDigitalIdProfile(mockApi, "999888", 8250447); + expect(result.isLinked).toBe(true); + }); +}); From 560f5fe53acff36a4c90e71be9ef46279801895b Mon Sep 17 00:00:00 2001 From: JuniorAww <50590566+junioraww@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:01:25 +0200 Subject: [PATCH 05/12] fix: get rid of fflate usage --- package-lock.json | 5 -- src/lib/services/trace.js | 6 +- src/lib/utils/zip.js | 163 ++++++++++++++++++++++++++++++++++++++ tests/trace.spec.js | 10 +-- 4 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 src/lib/utils/zip.js diff --git a/package-lock.json b/package-lock.json index 97e5286..86ba1a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-stronghold": "^2.3.2", "@tauri-apps/plugin-upload": "^2.4.1", - "fflate": "^0.8.3", "jsqr": "^1.4.0", "libsodium-wrappers-sumo": "^0.8.4", "lottie-web": "^5.13.0", @@ -1524,10 +1523,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.3", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/src/lib/services/trace.js b/src/lib/services/trace.js index 44629df..75920e2 100644 --- a/src/lib/services/trace.js +++ b/src/lib/services/trace.js @@ -2,7 +2,7 @@ import { writable, get } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; import { save } from "@tauri-apps/plugin-dialog"; import { listen } from "@tauri-apps/api/event"; -import * as fflate from "fflate"; +import { zipSync, strToU8 } from "../utils/zip.js"; import { showAlert } from "../utils/alert.js"; export const isTracing = writable(false); @@ -584,7 +584,7 @@ export async function stopTraceAndExport() { const readableTimeline = formatReadableTimeline(activeEvents, activeSystemInfo, startTimeEpoch, stopTimeEpoch); const zipFiles = { - "log.txt": fflate.strToU8(readableTimeline), + "log.txt": strToU8(readableTimeline), }; for (const s of activeScreenshots) { @@ -593,7 +593,7 @@ export async function stopTraceAndExport() { let zipBytes; try { - zipBytes = fflate.zipSync(zipFiles, { level: 6 }); + zipBytes = zipSync(zipFiles); } catch (err) { showAlert("Ошибка создания архива", String(err)); return; diff --git a/src/lib/utils/zip.js b/src/lib/utils/zip.js new file mode 100644 index 0000000..39225a3 --- /dev/null +++ b/src/lib/utils/zip.js @@ -0,0 +1,163 @@ +const crcTable = new Uint32Array(256); +for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) { + c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + } + crcTable[i] = c >>> 0; +} + +export function crc32(bytes) { + let c = -1; + for (let i = 0; i < bytes.length; i++) { + c = (c >>> 8) ^ crcTable[(c ^ bytes[i]) & 0xff]; + } + return (c ^ (-1)) >>> 0; +} + +export function strToU8(str) { + return new TextEncoder().encode(str); +} + +export function strFromU8(bytes) { + return new TextDecoder().decode(bytes); +} + +export function zipSync(files) { + const encoder = new TextEncoder(); + const entries = []; + let totalLocalSize = 0; + + for (const [name, rawData] of Object.entries(files)) { + const nameBytes = encoder.encode(name); + const data = typeof rawData === "string" + ? encoder.encode(rawData) + : (rawData instanceof Uint8Array ? rawData : new Uint8Array(rawData)); + const crc = crc32(data); + entries.push({ nameBytes, data, crc, offset: totalLocalSize }); + totalLocalSize += 30 + nameBytes.length + data.length; + } + + let totalCdSize = 0; + for (const entry of entries) { + totalCdSize += 46 + entry.nameBytes.length; + } + + const out = new Uint8Array(totalLocalSize + totalCdSize + 22); + const view = new DataView(out.buffer); + let pos = 0; + + for (const entry of entries) { + view.setUint32(pos, 0x04034b50, true); + view.setUint16(pos + 4, 20, true); + view.setUint16(pos + 6, 0x0800, true); + view.setUint16(pos + 8, 0, true); + view.setUint16(pos + 10, 0, true); + view.setUint16(pos + 12, 0x5c21, true); + view.setUint32(pos + 14, entry.crc, true); + view.setUint32(pos + 18, entry.data.length, true); + view.setUint32(pos + 22, entry.data.length, true); + view.setUint16(pos + 26, entry.nameBytes.length, true); + view.setUint16(pos + 28, 0, true); + pos += 30; + + out.set(entry.nameBytes, pos); + pos += entry.nameBytes.length; + + out.set(entry.data, pos); + pos += entry.data.length; + } + + const cdOffset = pos; + for (const entry of entries) { + view.setUint32(pos, 0x02014b50, true); + view.setUint16(pos + 4, 20, true); + view.setUint16(pos + 6, 20, true); + view.setUint16(pos + 8, 0x0800, true); + view.setUint16(pos + 10, 0, true); + view.setUint16(pos + 12, 0, true); + view.setUint16(pos + 14, 0x5c21, true); + view.setUint32(pos + 16, entry.crc, true); + view.setUint32(pos + 20, entry.data.length, true); + view.setUint32(pos + 24, entry.data.length, true); + view.setUint16(pos + 28, entry.nameBytes.length, true); + view.setUint16(pos + 30, 0, true); + view.setUint16(pos + 32, 0, true); + view.setUint16(pos + 34, 0, true); + view.setUint16(pos + 36, 0, true); + view.setUint32(pos + 38, 0, true); + view.setUint32(pos + 42, entry.offset, true); + pos += 46; + + out.set(entry.nameBytes, pos); + pos += entry.nameBytes.length; + } + + view.setUint32(pos, 0x06054b50, true); + view.setUint16(pos + 4, 0, true); + view.setUint16(pos + 6, 0, true); + view.setUint16(pos + 8, entries.length, true); + view.setUint16(pos + 10, entries.length, true); + view.setUint32(pos + 12, totalCdSize, true); + view.setUint32(pos + 16, cdOffset, true); + view.setUint16(pos + 20, 0, true); + + return out; +} + +export function unzipSync(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decoder = new TextDecoder(); + const result = {}; + let pos = 0; + + while (pos + 30 <= bytes.length) { + const sig = view.getUint32(pos, true); + if (sig !== 0x04034b50) break; + + const compSize = view.getUint32(pos + 18, true); + const nameLen = view.getUint16(pos + 26, true); + const extraLen = view.getUint16(pos + 28, true); + const nameStart = pos + 30; + const nameBytes = bytes.subarray(nameStart, nameStart + nameLen); + const name = decoder.decode(nameBytes); + const dataStart = nameStart + nameLen + extraLen; + const data = bytes.slice(dataStart, dataStart + compSize); + + result[name] = data; + pos = dataStart + compSize; + } + + if (Object.keys(result).length === 0) { + let eocdPos = -1; + for (let i = bytes.length - 22; i >= 0; i--) { + if (view.getUint32(i, true) === 0x06054b50) { + eocdPos = i; + break; + } + } + if (eocdPos !== -1) { + const entryCount = view.getUint16(eocdPos + 10, true); + let cdPos = view.getUint32(eocdPos + 16, true); + for (let i = 0; i < entryCount; i++) { + if (cdPos + 46 > bytes.length) break; + if (view.getUint32(cdPos, true) !== 0x02014b50) break; + const compSize = view.getUint32(cdPos + 20, true); + const nameLen = view.getUint16(cdPos + 28, true); + const extraLen = view.getUint16(cdPos + 30, true); + const commentLen = view.getUint16(cdPos + 32, true); + const localOffset = view.getUint32(cdPos + 42, true); + const nameStart = cdPos + 46; + const name = decoder.decode(bytes.subarray(nameStart, nameStart + nameLen)); + const localNameLen = view.getUint16(localOffset + 26, true); + const localExtraLen = view.getUint16(localOffset + 28, true); + const dataStart = localOffset + 30 + localNameLen + localExtraLen; + result[name] = bytes.slice(dataStart, dataStart + compSize); + cdPos = nameStart + nameLen + extraLen + commentLen; + } + } + } + + return result; +} diff --git a/tests/trace.spec.js b/tests/trace.spec.js index 25c9e10..754d736 100644 --- a/tests/trace.spec.js +++ b/tests/trace.spec.js @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import * as fflate from 'fflate'; +import { zipSync, unzipSync, strToU8, strFromU8 } from '../src/lib/utils/zip.js'; import { sanitizeData } from '../src/lib/services/trace.js'; test.describe('Trace system sensitive data sanitization', () => { @@ -118,20 +118,20 @@ test.describe('Trace archive packaging and structure', () => { const syntheticScreenshot = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); const zipFiles = { - 'log.txt': fflate.strToU8(syntheticLogText), + 'log.txt': strToU8(syntheticLogText), 'screenshots/screen_001.jpg': syntheticScreenshot, }; - const zipBytes = fflate.zipSync(zipFiles, { level: 6 }); + const zipBytes = zipSync(zipFiles); expect(zipBytes).toBeInstanceOf(Uint8Array); expect(zipBytes.length).toBeGreaterThan(0); - const unzipped = fflate.unzipSync(zipBytes); + const unzipped = unzipSync(zipBytes); expect(unzipped['log.txt']).toBeDefined(); expect(unzipped['screenshots/screen_001.jpg']).toBeDefined(); expect(unzipped['trace.json']).toBeUndefined(); - const readText = fflate.strFromU8(unzipped['log.txt']); + const readText = strFromU8(unzipped['log.txt']); expect(readText).toContain('[Click]'); expect(readText).toContain('[Api_Request]'); expect(readText).toContain('[Api_Response]'); From 043445bc49c7d57a9718eb6809c63dc27c3239d8 Mon Sep 17 00:00:00 2001 From: JuniorAww <50590566+junioraww@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:56:19 +0200 Subject: [PATCH 06/12] refactor: simplify Input.svelte and ChatWindow.svelte --- src/components/ChatWindow.svelte | 1138 ++-------- src/components/ChatWindow/ChatHeader.svelte | 144 ++ .../ChatWindow/ScrollDownButton.svelte | 118 ++ .../ChatWindow/chatMessagesLoader.js | 244 +++ .../ChatWindow/chatVirtualScroll.js | 252 +++ .../ChatWindow/input/AttachesMenu.svelte | 104 + .../ChatWindow/input/EditingBanner.svelte | 83 + src/components/ChatWindow/input/Input.svelte | 1874 ++--------------- .../ChatWindow/input/RecordingBar.svelte | 265 +++ .../ChatWindow/input/SelectedAttaches.svelte | 172 ++ .../input/VideoNoteReviewPanel.svelte | 378 ++++ .../input/VideoRecorderPreview.svelte | 89 + .../ChatWindow/input/VoiceReviewPanel.svelte | 306 +++ .../ChatWindow/input/mediaRecorderManager.js | 552 +++++ src/components/ChatWindow/swipeToClose.js | 230 ++ 15 files changed, 3288 insertions(+), 2661 deletions(-) create mode 100644 src/components/ChatWindow/ChatHeader.svelte create mode 100644 src/components/ChatWindow/ScrollDownButton.svelte create mode 100644 src/components/ChatWindow/chatMessagesLoader.js create mode 100644 src/components/ChatWindow/chatVirtualScroll.js create mode 100644 src/components/ChatWindow/input/AttachesMenu.svelte create mode 100644 src/components/ChatWindow/input/EditingBanner.svelte create mode 100644 src/components/ChatWindow/input/RecordingBar.svelte create mode 100644 src/components/ChatWindow/input/SelectedAttaches.svelte create mode 100644 src/components/ChatWindow/input/VideoNoteReviewPanel.svelte create mode 100644 src/components/ChatWindow/input/VideoRecorderPreview.svelte create mode 100644 src/components/ChatWindow/input/VoiceReviewPanel.svelte create mode 100644 src/components/ChatWindow/input/mediaRecorderManager.js create mode 100644 src/components/ChatWindow/swipeToClose.js diff --git a/src/components/ChatWindow.svelte b/src/components/ChatWindow.svelte index 6d88f04..25d6924 100644 --- a/src/components/ChatWindow.svelte +++ b/src/components/ChatWindow.svelte @@ -7,7 +7,6 @@ beforeUpdate, afterUpdate, } from "svelte"; - import { fade, fly } from "svelte/transition"; import { writable, get } from "svelte/store"; import Message from "$components/ChatWindow/Message.svelte"; @@ -25,43 +24,41 @@ import { getContact } from "$lib/stores/contacts"; - import Session, { + import { openChat, closeChat, get as sessionGet, } from "$lib/stores/session"; import { handleReaction } from "$components/ChatWindow/actions.js"; - import { checkForEncryptionRequest } from "$components/ChatWindow/e2e.js"; - import { batchDecrypt } from "$lib/crypto/messages.js"; import { convertFileSrc } from "@tauri-apps/api/core"; import { getProxiedMediaUrl } from "$lib/utils/images.js"; - import { getCurrentAccount } from "$lib/stores/accounts.js"; import { scrollToBottom } from "$lib/utils/scroll.js"; - import { getChatScroll, saveChatScroll } from "$lib/stores/chatScroll.js"; - import * as Caching from "$lib/utils/caching.js"; + import { getChatScroll } from "$lib/stores/chatScroll.js"; import Settings from "$components/ChatWindow/Settings.svelte"; import E2eModal from "$components/ChatWindow/E2eModal.svelte"; import Dropout from "$components/ChatWindow/Dropout.svelte"; - import Signature from "$components/main/Signature.svelte"; import MediaViewer from "$components/ChatWindow/MediaViewer.svelte"; import MediaPlaybackHeader from "$components/media/MediaPlaybackHeader.svelte"; - import { activeMedia, activeChatMessages, buildChatPlaylist } from "$lib/stores/mediaPlayback"; + import { activeMedia, activeChatMessages } from "$lib/stores/mediaPlayback"; import DateSeparator from "$components/ChatWindow/DateSeparator.svelte"; import Input from "$components/ChatWindow/input/Input.svelte"; import BotStart from "$components/ChatWindow/BotStart.svelte"; - import Avatar from "$components/main/Avatar.svelte"; import StickerPackModal from "$components/ChatWindow/Stickers/StickerPackModal.svelte"; import EditHistoryModal from "$components/ChatWindow/EditHistoryModal.svelte"; import { computeTextDiff } from "$lib/utils/diff.js"; import { clearChatNotification } from "$lib/utils/notifications.js"; + import ChatHeader from "$components/ChatWindow/ChatHeader.svelte"; + import ScrollDownButton from "$components/ChatWindow/ScrollDownButton.svelte"; + import { swipeToClose } from "$components/ChatWindow/swipeToClose.js"; + import { createVirtualScrollManager, DEFAULT_HEIGHT } from "$components/ChatWindow/chatVirtualScroll.js"; + import { createMessagesLoader, BATCH_SIZE } from "$components/ChatWindow/chatMessagesLoader.js"; + export let chatId; $: chat = $currentSessionChats?.find((c) => String(c.id) === String(chatId)); let title; - - let startSecretChatRequest = null; let gotSecretChatRequest = null; let replyTo = null; @@ -83,12 +80,7 @@ }; } - let loading = false; - let all_loaded = false; - let loadingNewer = false; - let all_loaded_newer = true; let allRendered = false; - let scrollElement; let scrollLoaderTimeout; let scrollBottomLoaderTimeout; @@ -96,18 +88,12 @@ let viewerOpen = false; let viewerIndex = 0; - - let lastDate; - let clickStartPos = { x: 0, y: 0 }; const messages = writable([]); $: if ($messages) { activeChatMessages.set($messages); } - let initialized = false; - - const BATCH_SIZE = 40; $: avatarUserId = (() => { if (chat?.type !== "DIALOG") return undefined; @@ -126,7 +112,6 @@ })(); $: unreadBadgeCount = Math.max(0, Number($currentSessionChats?.find((x) => x.id === chat?.id)?.newMessages ?? chat?.newMessages ?? 0)); - $: chatSettings = getChatSettings(chat?.id ?? chatId); const onBack = getContext("onBack"); @@ -140,190 +125,11 @@ closeChat(chat?.id ?? chatId); } - let touchStartX = 0; - let touchStartY = 0; let currentDragX = 0; - let isTouchTracking = false; let isSwipingChat = false; - let isScrollingChat = false; let isClosingBySwipe = false; - let chatWindowWidth = 0; - function handleTouchStart(e) { - if (e.touches.length !== 1) { - isTouchTracking = false; - return; - } - if (viewerOpen || settingsShown || dropoutActiveAt || isClosingBySwipe) { - isTouchTracking = false; - return; - } - if (e.target.closest("input, textarea, button, a, .icon-button, .scroll-down-container, .media-playback-header, .timeline-track-container, .speed-control-wrapper, .volume-control-wrapper, .hdr-btn")) { - isTouchTracking = false; - return; - } - - isTouchTracking = true; - touchStartX = e.touches[0].clientX; - touchStartY = e.touches[0].clientY; - currentDragX = 0; - isSwipingChat = false; - isScrollingChat = false; - chatWindowWidth = window.innerWidth; - } - - function handleTouchMove(e) { - if (!isTouchTracking || isScrollingChat || isClosingBySwipe) return; - if (e.touches.length !== 1) return; - - const currentX = e.touches[0].clientX; - const currentY = e.touches[0].clientY; - const diffX = currentX - touchStartX; - const diffY = currentY - touchStartY; - - if (!isSwipingChat) { - if (Math.abs(diffX) > 10 || Math.abs(diffY) > 10) { - if (diffX > 10 && diffX > Math.abs(diffY) * 1.1) { - isSwipingChat = true; - } else { - isScrollingChat = true; - return; - } - } else { - return; - } - } - - if (isSwipingChat) { - if (diffX > 0) { - currentDragX = diffX; - } else { - currentDragX = 0; - } - if (e.cancelable) e.preventDefault(); - } - } - - function handleTouchEnd() { - isTouchTracking = false; - if (!isSwipingChat || isClosingBySwipe) { - isSwipingChat = false; - isScrollingChat = false; - return; - } - - const threshold = chatWindowWidth * 0.3; - if (currentDragX >= threshold) { - isClosingBySwipe = true; - isSwipingChat = false; - currentDragX = chatWindowWidth; - setTimeout(() => { - handleCloseChat(); - }, 220); - } else { - isSwipingChat = false; - currentDragX = 0; - isScrollingChat = false; - } - } - - function handleTouchCancel() { - isTouchTracking = false; - if (!isClosingBySwipe) { - isSwipingChat = false; - isScrollingChat = false; - currentDragX = 0; - } - } - - let isMouseDragging = false; - let mouseStartX = 0; - let mouseStartY = 0; - let mouseDragEngaged = false; - - function handleMouseDown(e) { - if (isClosingBySwipe || viewerOpen || settingsShown || dropoutActiveAt) return; - if (e.button !== 0) return; - if (e.target.closest("input, textarea, button, a, .icon-button, .scroll-down-container, .media-playback-header, .timeline-track-container, .speed-control-wrapper, .volume-control-wrapper")) return; - - const isHeader = Boolean(e.target.closest("header")); - const isLeftEdge = e.clientX <= 60; - const isMessage = Boolean(e.target.closest(".message, .bubble")); - - if (!isHeader && !isLeftEdge && isMessage) { - return; - } - - mouseStartX = e.clientX; - mouseStartY = e.clientY; - isMouseDragging = true; - mouseDragEngaged = false; - chatWindowWidth = window.innerWidth; - - const onMouseMove = (moveEv) => { - if (!isMouseDragging) return; - const diffX = moveEv.clientX - mouseStartX; - const diffY = moveEv.clientY - mouseStartY; - - if (!mouseDragEngaged) { - if (diffX > 10 && diffX > Math.abs(diffY) * 1.1) { - mouseDragEngaged = true; - isSwipingChat = true; - document.body.style.userSelect = "none"; - document.body.style.cursor = "grabbing"; - } else if (Math.abs(diffY) > 10) { - isMouseDragging = false; - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - return; - } - } - - if (mouseDragEngaged) { - currentDragX = Math.max(0, diffX); - moveEv.preventDefault(); - } - }; - - const onMouseUp = () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - document.body.style.userSelect = ""; - document.body.style.cursor = ""; - - if (!isMouseDragging) return; - isMouseDragging = false; - - if (!mouseDragEngaged) { - isSwipingChat = false; - currentDragX = 0; - return; - } - - const threshold = chatWindowWidth * 0.3; - if (currentDragX >= threshold) { - isClosingBySwipe = true; - isSwipingChat = false; - currentDragX = chatWindowWidth; - setTimeout(() => { - handleCloseChat(); - }, 220); - } else { - isSwipingChat = false; - currentDragX = 0; - } - }; - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - } - - $: swipeStyle = (() => { - if (currentDragX > 0) { - return `transform: translate3d(${currentDragX}px, 0, 0);`; - } - return ""; - })(); + $: swipeStyle = currentDragX > 0 ? `transform: translate3d(${currentDragX}px, 0, 0);` : ""; onBack["chat"] = () => { handleCloseChat(); @@ -339,410 +145,39 @@ if (onBack.dropout) delete onBack["dropout"]; if (onBack.chatSettings) delete onBack["chatSettings"]; scrollResizeObserver?.disconnect(); - if (resizeObserver) resizeObserver.disconnect(); + virtualScroll.destroy(); }); - const DEFAULT_HEIGHT = 120; - const OVERSCAN = 1500; + const virtualScroll = createVirtualScrollManager(); + const { messageHeights, observeResize } = virtualScroll; - const messageHeights = writable({}); - let cumulativeHeights = []; let innerList; let visibleMessages = {}; - let scrollAnchor = { messageId: null, offset: 0 }; - - let pendingHeightUpdates = {}; - - let resizeObserver = null; - function setupResizeObserver() { - if (resizeObserver) return; - resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const el = entry.target; - const wrapper = el.closest('.message-wrapper'); - if (!wrapper) continue; - const id = wrapper.id?.replace('m-', ''); - if (!id) continue; - const height = entry.contentRect.height; - if (height > 0) { - pendingHeightUpdates[id] = height; - } - } - }); - } - - function observeResize(node, id) { - if (resizeObserver) resizeObserver.observe(node); - return { - destroy() { - if (resizeObserver) resizeObserver.unobserve(node); - } - }; - } - - function applyPendingHeights() { - const updates = pendingHeightUpdates; - pendingHeightUpdates = {}; - const keys = Object.keys(updates); - if (keys.length === 0) return; - messageHeights.update(h => { - const newH = { ...h }; - for (const id of keys) newH[id] = updates[id]; - return newH; - }); - computeCumulativeHeights(); - } - - function computeCumulativeHeights() { - const heights = []; - let sum = 0; - for (const msg of $messages) { - const h = $messageHeights[msg.id] || DEFAULT_HEIGHT; - sum += h; - heights.push(sum); - } - cumulativeHeights = heights; - } + let currentScrollAnchor = null; - function findIndexByOffset(target) { - let lo = 0, hi = cumulativeHeights.length; - while (lo < hi) { - const mid = Math.floor((lo + hi) / 2); - if (cumulativeHeights[mid] < target) lo = mid + 1; - else hi = mid; - } - return lo; + function handleAnchorCapture() { + currentScrollAnchor = virtualScroll.captureScrollAnchor(scrollElement, visibleMessages); } - function captureScrollAnchor() { - if (!scrollElement) return; - - const containerRect = scrollElement.getBoundingClientRect(); - - for (const id in visibleMessages) { - const el = visibleMessages[id]; - if (!el) continue; - - const rect = el.getBoundingClientRect(); - - if (rect.bottom > containerRect.top) { - scrollAnchor = { - id: el.id, - offset: rect.top - containerRect.top - }; - return; - } - } - } - - function restoreScrollAnchor() { - if (!scrollAnchor || !scrollElement) return; - - const el = document.getElementById(scrollAnchor.id); - if (!el) return; - - const containerRect = scrollElement.getBoundingClientRect(); - const rect = el.getBoundingClientRect(); - - const delta = rect.top - containerRect.top - scrollAnchor.offset; - - if (delta !== 0) { - scrollElement.scrollTop += delta; - } - } - - async function updateVisibleMessages() { - if (!scrollElement) return; - - applyPendingHeights(); - - const { scrollTop, clientHeight } = scrollElement; - const totalHeight = cumulativeHeights.length ? cumulativeHeights[cumulativeHeights.length - 1] : 0; - if (totalHeight === 0) { - visibleMessages = {}; - return; - } - - const isNearBottom = totalHeight - scrollTop - clientHeight < 50; - let startIdx, endIdx; - - if (isNearBottom) { - const targetOffset = Math.max(0, totalHeight - clientHeight - OVERSCAN); - startIdx = findIndexByOffset(targetOffset); - endIdx = $messages.length - 1; - } else { - const viewTop = Math.max(0, scrollTop - OVERSCAN); - const viewBottom = scrollTop + clientHeight + OVERSCAN; - startIdx = findIndexByOffset(viewTop); - endIdx = findIndexByOffset(viewBottom); - endIdx = Math.min(endIdx, $messages.length - 1); - if (startIdx > endIdx) endIdx = startIdx; - } - - const newVisible = {}; - for (let i = startIdx; i <= endIdx && i < $messages.length; i++) { - const id = $messages[i].id; - if (!newVisible[id]) { - newVisible[id] = document.getElementById("m-" + id); - } - } - visibleMessages = newVisible; - - if (userHasScrolled) { - await scheduleRead(); - } - } - - function measureAllHeights() { - if (!innerList) return; - const wrappers = innerList.querySelectorAll('.message-wrapper'); - const updates = {}; - for (const wrapper of wrappers) { - const id = wrapper.id?.replace('m-', ''); - if (!id) continue; - const content = - wrapper.querySelector("#clickable-area") || - wrapper.querySelector(".observer-area") || - wrapper; - if (content) { - const height = content.getBoundingClientRect().height; - if (height > 0) updates[id] = height; - } - } - if (Object.keys(updates).length) { - messageHeights.update(h => ({ ...h, ...updates })); - } + function handleAnchorRestore() { + virtualScroll.restoreScrollAnchor(scrollElement, currentScrollAnchor); } const decodedMessages = writable({}); + $: chatCache = getChat(chat?.id ?? chatId); - const decodeMessagesBatch = async (list) => { - if (!list || !list.length) return; - try { - const currentDecoded = get(decodedMessages); - const toDecode = list.filter(m => { - const idStr = String(m.id); - const existing = currentDecoded[idStr]; - if (!existing) return true; - if (m.edited || m.status === "EDITED") return true; - return false; - }); - - if (!toDecode.length) return; - - const account = await getCurrentAccount(); - const password = $chatSettings?.password || null; - const currentChatId = chat?.id ?? chatId; - const updates = await batchDecrypt( - Number(account?.id || 0), - Number(currentChatId), - toDecode, - password - ); - - decodedMessages.update(old => ({ - ...old, - ...updates - })); - - const newReq = await checkForEncryptionRequest(chat, chatSettings, updates, toDecode); - if (newReq) { - gotSecretChatRequest = newReq; - } - } catch (e) { - console.error(e); - } - }; - - const mergeMessages = async ( - incoming, - updateCache = false - ) => { - if (!incoming?.length) return; - - const map = new Map( - get(messages).map(m => [String(m.id), m]) - ); - - const changed = []; - - for (const msg of incoming) { - const msgId = String(msg.id); - const old = map.get(msgId); - - if (!old) { - const isEdited = msg.status === "EDITED" || !!msg.edited; - const entry = { - ...msg, - id: msgId, - ...(isEdited ? { edited: true } : {}), - }; - map.set(msgId, entry); - changed.push(entry); - continue; - } - - const isEditedStatus = msg.status === "EDITED" || old.status === "EDITED" || old.edited || msg.edited; - const isDeletedStatus = old.deleted || msg.deleted || msg.status === "REMOVED"; - - let newHistory = Array.isArray(old.history) ? [...old.history] : []; - if (Array.isArray(msg.history)) { - for (const h of msg.history) { - if (!newHistory.some(existing => existing.at === h.at)) { - newHistory.push(h); - } - } - } - - const textChanged = old.text && msg.text && old.text !== msg.text; - if (textChanged && (!Array.isArray(msg.history) || !msg.history.length)) { - const textDiff = computeTextDiff(old.text, msg.text); - const at = msg.editTime || msg.edited_at || Date.now(); - newHistory.push({ at, diff: textDiff }); - } - - const merged = { - ...old, - ...msg, - id: msgId, - ...(isDeletedStatus ? { deleted: true, deleted_at: old.deleted_at || msg.deleted_at || Date.now() } : {}), - ...(isEditedStatus ? { edited: true, edited_at: old.edited_at || msg.edited_at || msg.editTime || Date.now() } : {}), - ...(newHistory.length ? { history: newHistory } : {}), - }; - - if (JSON.stringify(old) !== JSON.stringify(merged)) { - map.set(msgId, merged); - changed.push(merged); - } - } - - if (!changed.length) return; - - messages.set( - [...map.values()].sort( - (a,b) => a.time - b.time - ) - ); - - if (updateCache) { - chatCache.updateMessages(changed); - } - - await decodeMessagesBatch(changed); - }; - - const loadHistory = async ( - isInitial = false, - from = Date.now() + sessionGet("drift"), - backward = BATCH_SIZE, - forward = 0 - ) => { - const currentChatId = chat?.id ?? chatId; - if (loading) return; - if (all_loaded && !isInitial) return; - if (currentChatId == null) return; - - loading = true; - - try { - const cached = await chatCache.loadMessages( - from, - backward - ); - - captureScrollAnchor(); - - await mergeMessages(cached, false); - - restoreScrollAnchor(); - captureScrollAnchor(); - - if (!initialized || isInitial) { - const { - error, - messages: serverMessages - } = await $API.getMessages(currentChatId, from, backward, forward); - - if (error) throw new Error(error); - - await mergeMessages(serverMessages, true); - - if (serverMessages.length < backward + forward) { - if (forward === 0) { - all_loaded = true; - } - } - - initialized = true; - } else { - const oldest = get(messages)[0]; - - const { - error, - messages: olderMessages - } = await $API.getMessages( - currentChatId, - oldest?.time ?? from, - backward, - forward - ); - - if (error) throw new Error(error); - - if (olderMessages.length < backward) { - all_loaded = true; - } - - await mergeMessages( - olderMessages, - true - ); - } - } catch(e) { - console.error(e); - } finally { - restoreScrollAnchor(); - loading = false; - } - - restoreScrollAnchor(); - }; - - const loadNewer = async () => { - const currentChatId = chat?.id ?? chatId; - if (loadingNewer || all_loaded_newer) return; - if (currentChatId == null) return; - - loadingNewer = true; - - try { - const msgs = get(messages); - const newest = msgs[msgs.length - 1]; - const fromTime = newest?.time ?? (Date.now() + sessionGet("drift")); - - const { - error, - messages: newerMessages - } = await $API.getNewerMessages(currentChatId, fromTime, BATCH_SIZE); - - if (error) throw new Error(error); - - if (!newerMessages || newerMessages.length < BATCH_SIZE) { - all_loaded_newer = true; - } - - await mergeMessages( - newerMessages, - false - ); - } catch (e) { - console.error(e); - } finally { - loadingNewer = false; - } - - restoreScrollAnchor(); - }; + const loader = createMessagesLoader({ + messages, + decodedMessages, + getChatObj: () => chat, + getChatId: () => chatId, + getChatSettings: () => chatSettings, + getChatCache: () => chatCache, + getApi: () => $API, + onAnchorCapture: handleAnchorCapture, + onAnchorRestore: handleAnchorRestore, + onSecretChatRequest: (req) => { gotSecretChatRequest = req; }, + }); let scrollTimeout = null; let updateScheduled = false; @@ -750,62 +185,29 @@ let savePositionTimeout = null; let isInitialMounting = true; let isProgrammaticScroll = false; - let readTimer = null; let lastReadMessageId = null; - function saveCurrentPosition() { - const targetChatId = chat?.id ?? chatId; - if (!scrollElement || targetChatId == null || isInitialMounting || isProgrammaticScroll) return; - if (scrollElement.clientHeight <= 0 || scrollElement.scrollHeight <= 0) return; - if (scrollElement.scrollHeight <= scrollElement.clientHeight + 20) return; - const distanceFromBottom = - scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; - const isAtBottom = all_loaded_newer && distanceFromBottom < 60; - if (isAtBottom) { - saveChatScroll(targetChatId, { - wasAtBottom: true, - lastSeenTime: Date.now(), - }); - return; - } - const containerRect = scrollElement.getBoundingClientRect(); - let bottomMsg = null; - let bottomOffset = 0; - let maxBottom = -Infinity; - - for (const id in visibleMessages) { - const el = visibleMessages[id]; - if (!el) continue; - const rect = el.getBoundingClientRect(); - if (rect.top < containerRect.bottom && rect.bottom > maxBottom) { - maxBottom = rect.bottom; - const m = $messages.find((x) => String(x.id) === String(id)); - if (m) { - bottomMsg = m; - bottomOffset = rect.top - containerRect.top; - } - } - } - - if (!bottomMsg && $messages && $messages.length > 0) { - const currentScroll = scrollElement.scrollTop; - const idx = findIndexByOffset(currentScroll + scrollElement.clientHeight / 2); - if (idx >= 0 && idx < $messages.length) { - bottomMsg = $messages[idx]; - bottomOffset = 40; - } + async function updateVisibleMessages() { + visibleMessages = virtualScroll.calculateVisibleMessages({ + scrollElement, + messagesList: $messages, + }); + if (userHasScrolled) { + await scheduleRead(); } + } - if (bottomMsg) { - saveChatScroll(targetChatId, { - wasAtBottom: false, - bottomMessageId: bottomMsg.id, - bottomMessageTime: bottomMsg.time, - offset: bottomOffset, - lastSeenTime: Date.now(), - }); - } + function saveCurrentPosition() { + virtualScroll.savePosition({ + targetChatId: chat?.id ?? chatId, + scrollElement, + all_loaded_newer: loader.all_loaded_newer, + visibleMessages, + messagesList: $messages, + isInitialMounting, + isProgrammaticScroll, + }); } function queueSavePosition() { @@ -823,9 +225,8 @@ userHasScrolled = true; queueSavePosition(); const target = event.currentTarget; - const distanceFromBottom = - target.scrollHeight - target.scrollTop - target.clientHeight; - showScrollDown = !all_loaded_newer || distanceFromBottom > 50; + const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + showScrollDown = !loader.all_loaded_newer || distanceFromBottom > 50; if (!updateScheduled) { updateScheduled = true; @@ -835,40 +236,38 @@ }); } - if (target.scrollTop <= 50 && !loading && !all_loaded) { + if (target.scrollTop <= 50 && !loader.loading && !loader.all_loaded) { if (scrollLoaderTimeout) return; scrollLoaderTimeout = setTimeout(async () => { - await loadHistory(); + await loader.loadHistory(); await updateVisibleMessages(); - setTimeout(() => scrollLoaderTimeout = null, 500); + setTimeout(() => (scrollLoaderTimeout = null), 500); }, 200); } - if (distanceFromBottom <= 100 && !loadingNewer && !all_loaded_newer) { + if (distanceFromBottom <= 100 && !loader.loadingNewer && !loader.all_loaded_newer) { if (scrollBottomLoaderTimeout) return; scrollBottomLoaderTimeout = setTimeout(async () => { - await loadNewer(); + await loader.loadNewer(); await updateVisibleMessages(); - setTimeout(() => scrollBottomLoaderTimeout = null, 500); + setTimeout(() => (scrollBottomLoaderTimeout = null), 500); }, 200); } } function getLowestVisibleMessageId() { if (!scrollElement) return null; - let lowest = null; let lowestTop = -Infinity; for (const key in visibleMessages) { - const entry = $messages.find(x => String(x.id) === String(key)); + const entry = $messages.find((x) => String(x.id) === String(key)); if (!entry || Number(entry.sender) === Number($currentUser)) continue; const el = visibleMessages[key]; if (!el) continue; const rect = el.getBoundingClientRect(); - if (rect.bottom < 0 || rect.top > window.innerHeight) continue; if (rect.top > lowestTop) { @@ -884,7 +283,7 @@ if (readTimer) clearTimeout(readTimer); readTimer = setTimeout(async () => { - const readDisabled = !$chatSettings.reader; + const readDisabled = !$chatSettings?.reader; if (readDisabled) return; const msgId = getLowestVisibleMessageId(); @@ -892,13 +291,13 @@ if (msgId === lastReadMessageId) return; - const index = $messages.length - $messages.findIndex(x => x.id === msgId) - if (index > chat.newMessages) return; + const index = $messages.length - $messages.findIndex((x) => x.id === msgId); + if (index > chat?.newMessages) return; lastReadMessageId = msgId; try { - chat.newMessages = index - 1; + if (chat) chat.newMessages = index - 1; await $API.readMessage(chat.id, msgId); } catch (e) { console.error("readMessage failed", e); @@ -906,8 +305,6 @@ }, 500); } - $: chatCache = getChat(chat?.id ?? chatId); - let unsubReceivedMessage = null; let currentSubscribedChatId = null; let lastProcessedMessageKey = null; @@ -924,7 +321,7 @@ unsubReceivedMessage = currentChatCache.receivedMessage.subscribe(async (message) => { if (!message || String(message.chatId) !== String(targetChatId)) return; - const msgKey = `${message.id}_${message.time || message.created_at || ''}_${message.status || ''}`; + const msgKey = `${message.id}_${message.time || message.created_at || ""}_${message.status || ""}`; if (msgKey === lastProcessedMessageKey) return; lastProcessedMessageKey = msgKey; @@ -942,7 +339,7 @@ let newHistory = Array.isArray(old.history) ? [...old.history] : []; if (Array.isArray(message.history)) { for (const h of message.history) { - if (!newHistory.some(existing => existing.at === h.at)) newHistory.push(h); + if (!newHistory.some((existing) => existing.at === h.at)) newHistory.push(h); } } if (old.text && message.text && old.text !== message.text && (!Array.isArray(message.history) || !message.history.length)) { @@ -961,19 +358,19 @@ } return _messages; }); - await decodeMessagesBatch([message]); + await loader.decodeMessagesBatch([message]); await tick(); - applyPendingHeights(); - computeCumulativeHeights(); - await updateVisibleMessages(wasAtBottom); + virtualScroll.applyPendingHeights($messages); + virtualScroll.computeCumulativeHeights($messages); + await updateVisibleMessages(); return; } if (message.sender === $currentUser) { - all_loaded_newer = true; + loader.all_loaded_newer = true; } - if (all_loaded_newer) { + if (loader.all_loaded_newer) { messages.update((_messages) => { const idx = _messages.findIndex((x) => String(x.id) === String(message.id)); if (idx !== -1) { @@ -990,18 +387,17 @@ return _messages; }); - await decodeMessagesBatch([message]); - + await loader.decodeMessagesBatch([message]); await tick(); - applyPendingHeights(); - computeCumulativeHeights(); + virtualScroll.applyPendingHeights($messages); + virtualScroll.computeCumulativeHeights($messages); const isRecentSelfMessage = message.sender === $currentUser && (Date.now() - (message.time || Date.now()) < 5000); if (isRecentSelfMessage || wasAtBottom) { scrollToBottom(scrollElement, true); } - await updateVisibleMessages(wasAtBottom); + await updateVisibleMessages(); } }); } @@ -1032,23 +428,10 @@ return result; })(); - $: cachedContact = chat.type === "DIALOG" ? getContact(avatarUserId) : writable(undefined); - $: title = chat.id === 0 ? "Избранное" : (chat.title || $cachedContact?.names?.[0]?.name); + $: cachedContact = chat?.type === "DIALOG" ? getContact(avatarUserId) : writable(undefined); + $: title = chat?.id === 0 ? "Избранное" : (chat?.title || $cachedContact?.names?.[0]?.name); $: isBot = $cachedContact?.options?.includes("BOT") || chat?.options?.BOT === true || chat?.options?.IS_BOT === true; - $: otherReadTime = (() => { - let maxMark = chat?.otherReadTime || 0; - const myId = Number($currentUser); - if (chat?.participants) { - for (const [uid, mark] of Object.entries(chat.participants)) { - if (Number(uid) !== myId && Number(mark) > maxMark) { - maxMark = Number(mark); - } - } - } - return maxMark; - })(); - let botInfo = null; let botCommands = []; let botStarting = false; @@ -1122,7 +505,7 @@ const targetChatId = chat?.id ?? chatId; clearChatNotification(targetChatId); - setupResizeObserver(); + virtualScroll.setupResizeObserver(); startAutoScrollIfAtBottom(); const unreadCount = Number(chat?.newMessages || 0); @@ -1132,18 +515,18 @@ let initialFrom = Date.now() + sessionGet("drift"); if (hasSavedScroll) { initialFrom = Number(savedPos.bottomMessageTime) + 1; - all_loaded_newer = false; + loader.all_loaded_newer = false; showScrollDown = true; - await loadHistory(true, initialFrom, 35, 35); + await loader.loadHistory(true, initialFrom, 35, 35); } else { - all_loaded_newer = true; - await loadHistory(true, initialFrom, 40, 0); + loader.all_loaded_newer = true; + await loader.loadHistory(true, initialFrom, 40, 0); } await tick(); - measureAllHeights(); - computeCumulativeHeights(); + virtualScroll.measureAllHeights(innerList); + virtualScroll.computeCumulativeHeights($messages); if (hasSavedScroll) { let targetId = savedPos.bottomMessageId; @@ -1170,7 +553,7 @@ offset: savedPos.offset || 40, }); } - if (!restored && !all_loaded_newer) { + if (!restored && !loader.all_loaded_newer) { const msgs = $messages; if (msgs.length > 0) { const mid = msgs[Math.floor(msgs.length / 2)].id; @@ -1211,9 +594,9 @@ }); async function jumpToBottom() { - if (!all_loaded_newer) { - all_loaded_newer = true; - await loadHistory(true, Date.now() + sessionGet("drift"), 40, 0); + if (!loader.all_loaded_newer) { + loader.all_loaded_newer = true; + await loader.loadHistory(true, Date.now() + sessionGet("drift"), 40, 0); await tick(); } scrollToBottom(scrollElement, true); @@ -1277,16 +660,17 @@ const messageWrapper = e.target.closest(".message-wrapper"); if (messageWrapper) { const id = messageWrapper.id?.replace("m-", ""); - const msg = $messages.find(x => String(x.id) === String(id)); + const msg = $messages.find((x) => String(x.id) === String(id)); if (e.target.closest(".reaction")) { const reaction = e.target.childNodes[0]?.nodeValue?.trim(); if (reaction && msg) { await handleReaction(chat, msg, reaction); - messages.update(x => x); + messages.update((x) => x); } + } else if (msg && !dropoutActiveAt) { + selectMessage(e, msg); } - else if (msg && !dropoutActiveAt) selectMessage(e, msg); } } } @@ -1362,7 +746,6 @@ justOpenedDropout = true; requestAnimationFrame(() => (justOpenedDropout = false)); - dropoutActiveAt = { e, msg }; } @@ -1371,10 +754,9 @@ dropoutActiveAt = null; const action = e.detail?.action; - if (action === "delete") { - messages.update(x => { - const idx = x.findIndex(m => String(m.id) === String(msgId)); + messages.update((x) => { + const idx = x.findIndex((m) => String(m.id) === String(msgId)); if (idx !== -1) { x[idx] = { ...x[idx], @@ -1384,21 +766,19 @@ } return [...x]; }); - getChat(chat?.id || chatId).markMessageDeleted?.(msgId); } else if (action === "reaction") { - messages.update(x => x); + messages.update((x) => x); } } - const openSettings = () => { + function openSettings() { settingsShown = !settingsShown; if (settingsShown) onBack.chatSettings = () => (settingsShown = false); else delete onBack["chatSettings"]; - }; + } let dateSeparators = {}; - $: if ($messages.length) { const newSeparators = {}; let lastDateStr = null; @@ -1414,7 +794,6 @@ let scrollResizeObserver; let lastClientHeight = 0; - let prevActiveMediaBool = null; let scrollAnchorBeforeMediaChange = null; @@ -1444,11 +823,10 @@ function startAutoScrollIfAtBottom() { if (!scrollElement) return; - lastClientHeight = scrollElement.clientHeight; scrollResizeObserver = new ResizeObserver(() => { - if (!scrollElement || isInitialMounting || isProgrammaticScroll || !all_loaded_newer) return; + if (!scrollElement || isInitialMounting || isProgrammaticScroll || !loader.all_loaded_newer) return; const { scrollTop, scrollHeight, clientHeight } = scrollElement; const heightDelta = clientHeight - lastClientHeight; lastClientHeight = clientHeight; @@ -1530,6 +908,19 @@ } } + $: otherReadTime = (() => { + let maxMark = chat?.otherReadTime || 0; + const myId = Number($currentUser); + if (chat?.participants) { + for (const [uid, mark] of Object.entries(chat.participants)) { + if (Number(uid) !== myId && Number(mark) > maxMark) { + maxMark = Number(mark); + } + } + } + return maxMark; + })(); + $: allMedia = $messages.flatMap((m) => { const decoded = $decodedMessages[String(m.id)]; const media = decoded?.media; @@ -1592,7 +983,6 @@ viewerOpen = true; } } -
0 || isClosingBySwipe)} style={swipeStyle} + use:swipeToClose={{ + canSwipe: () => !viewerOpen && !settingsShown && !dropoutActiveAt && !isClosingBySwipe, + onClose: handleCloseChat, + onStateChange: (state) => { + currentDragX = state.currentDragX; + isSwipingChat = state.isSwipingChat; + isClosingBySwipe = state.isClosingBySwipe; + }, + }} on:click|capture={handleClick} - on:mousedown={handleMouseDown} - on:touchstart={handleTouchStart} - on:touchmove={handleTouchMove} - on:touchend={handleTouchEnd} - on:touchcancel={handleTouchCancel} > @@ -1618,41 +1012,13 @@ /> {/if} -
-
- -
{ - if (chat.id === 0) { - $Session.profile = { userId: $currentUser }; - } else if (chat.type === "DIALOG") { - $Session.profile = { userId: avatarUserId }; - } else { - $Session.profile = { chatId: chat.id }; - } - }} - > - -
- {title} - -
-
-
-
- {#if chat.type !== "CHANNEL"} - - {/if} -
-
+ {#if $activeMedia} @@ -1693,40 +1059,39 @@ style={"opacity: " + (allRendered ? "1;" : "0;")} bind:this={innerList} > +
+ + {#each uniqueMessages as msg (msg.id)} +
+ {#if visibleMessages[msg.id] || !$messageHeights[msg.id]} +
+ {#if dateSeparators[msg.id]} + + {/if} + openMedia(e.detail.attach)} + on:openChat={() => openChat(chat.id, msg.id)} + on:openStickerPack={(e) => handleOpenStickerPack(e.detail.sticker)} + on:openHistory={(e) => (historyModalMessage = e.detail.msg)} + /> +
+ {:else} +
+ {/if} +
+ {/each} -
- - {#each uniqueMessages as msg (msg.id)} -
- {#if visibleMessages[msg.id] || !$messageHeights[msg.id]} -
- {#if dateSeparators[msg.id]} - - {/if} - openMedia(e.detail.attach)} - on:openChat={() => openChat(chat.id, msg.id)} - on:openStickerPack={(e) => handleOpenStickerPack(e.detail.sticker)} - on:openHistory={(e) => (historyModalMessage = e.detail.msg)} - /> -
- {:else} -
- {/if} -
- {/each} - -
+
@@ -1782,33 +1147,14 @@ /> {/if} - {#if showScrollDown} -
- - {#if unreadBadgeCount > 0} -
- {unreadBadgeCount > 99 ? "99+" : unreadBadgeCount} -
- {/if} -
- {/if} +
diff --git a/src/components/ChatWindow/ScrollDownButton.svelte b/src/components/ChatWindow/ScrollDownButton.svelte new file mode 100644 index 0000000..df07adb --- /dev/null +++ b/src/components/ChatWindow/ScrollDownButton.svelte @@ -0,0 +1,118 @@ + + +{#if showScrollDown} +
+ + {#if unreadBadgeCount > 0} +
{ if (e.key === 'Enter') handleClick(); }}> + {unreadBadgeCount > 99 ? "99+" : unreadBadgeCount} +
+ {/if} +
+{/if} + + diff --git a/src/components/ChatWindow/chatMessagesLoader.js b/src/components/ChatWindow/chatMessagesLoader.js new file mode 100644 index 0000000..f046a93 --- /dev/null +++ b/src/components/ChatWindow/chatMessagesLoader.js @@ -0,0 +1,244 @@ +import { get } from "svelte/store"; +import { batchDecrypt } from "$lib/crypto/messages.js"; +import { getCurrentAccount } from "$lib/stores/accounts.js"; +import { checkForEncryptionRequest } from "$components/ChatWindow/e2e.js"; +import { computeTextDiff } from "$lib/utils/diff.js"; +import { get as sessionGet } from "$lib/stores/session"; + +export const BATCH_SIZE = 40; + +export function createMessagesLoader({ + messages, + decodedMessages, + getChatObj, + getChatId, + getChatSettings, + getChatCache, + getApi, + onAnchorCapture = () => {}, + onAnchorRestore = () => {}, + onSecretChatRequest = () => {}, +}) { + let loading = false; + let all_loaded = false; + let loadingNewer = false; + let all_loaded_newer = true; + let initialized = false; + + async function decodeMessagesBatch(list) { + if (!list || !list.length) return; + try { + const currentDecoded = get(decodedMessages); + const toDecode = list.filter((m) => { + const idStr = String(m.id); + const existing = currentDecoded[idStr]; + if (!existing) return true; + if (m.edited || m.status === "EDITED") return true; + return false; + }); + + if (!toDecode.length) return; + + const account = await getCurrentAccount(); + const settingsStore = getChatSettings(); + const password = (settingsStore ? get(settingsStore)?.password : null) || null; + const currentChatId = getChatObj()?.id ?? getChatId(); + const updates = await batchDecrypt( + Number(account?.id || 0), + Number(currentChatId), + toDecode, + password + ); + + decodedMessages.update((old) => ({ + ...old, + ...updates, + })); + + const newReq = await checkForEncryptionRequest(getChatObj(), settingsStore, updates, toDecode); + if (newReq) { + onSecretChatRequest(newReq); + } + } catch (e) { + console.error(e); + } + } + + async function mergeMessages(incoming, updateCache = false) { + if (!incoming?.length) return; + + const map = new Map( + get(messages).map((m) => [String(m.id), m]) + ); + + const changed = []; + + for (const msg of incoming) { + const msgId = String(msg.id); + const old = map.get(msgId); + + if (!old) { + const isEdited = msg.status === "EDITED" || !!msg.edited; + const entry = { + ...msg, + id: msgId, + ...(isEdited ? { edited: true } : {}), + }; + map.set(msgId, entry); + changed.push(entry); + continue; + } + + const isEditedStatus = msg.status === "EDITED" || old.status === "EDITED" || old.edited || msg.edited; + const isDeletedStatus = old.deleted || msg.deleted || msg.status === "REMOVED"; + + let newHistory = Array.isArray(old.history) ? [...old.history] : []; + if (Array.isArray(msg.history)) { + for (const h of msg.history) { + if (!newHistory.some((existing) => existing.at === h.at)) { + newHistory.push(h); + } + } + } + + const textChanged = old.text && msg.text && old.text !== msg.text; + if (textChanged && (!Array.isArray(msg.history) || !msg.history.length)) { + const textDiff = computeTextDiff(old.text, msg.text); + const at = msg.editTime || msg.edited_at || Date.now(); + newHistory.push({ at, diff: textDiff }); + } + + const merged = { + ...old, + ...msg, + id: msgId, + ...(isDeletedStatus ? { deleted: true, deleted_at: old.deleted_at || msg.deleted_at || Date.now() } : {}), + ...(isEditedStatus ? { edited: true, edited_at: old.edited_at || msg.edited_at || msg.editTime || Date.now() } : {}), + ...(newHistory.length ? { history: newHistory } : {}), + }; + + if (JSON.stringify(old) !== JSON.stringify(merged)) { + map.set(msgId, merged); + changed.push(merged); + } + } + + if (!changed.length) return; + + messages.set( + [...map.values()].sort((a, b) => a.time - b.time) + ); + + if (updateCache) { + getChatCache()?.updateMessages(changed); + } + + await decodeMessagesBatch(changed); + } + + async function loadHistory( + isInitial = false, + from = Date.now() + sessionGet("drift"), + backward = BATCH_SIZE, + forward = 0 + ) { + const currentChatId = getChatObj()?.id ?? getChatId(); + if (loading) return; + if (all_loaded && !isInitial) return; + if (currentChatId == null) return; + + loading = true; + + try { + const chatCache = getChatCache(); + const cached = await chatCache?.loadMessages(from, backward); + + onAnchorCapture(); + if (cached) { + await mergeMessages(cached, false); + } + onAnchorRestore(); + onAnchorCapture(); + + const api = getApi(); + if (!initialized || isInitial) { + const { error, messages: serverMessages } = await api.getMessages(currentChatId, from, backward, forward); + if (error) throw new Error(error); + + await mergeMessages(serverMessages, true); + + if (serverMessages.length < backward + forward) { + if (forward === 0) { + all_loaded = true; + } + } + initialized = true; + } else { + const oldest = get(messages)[0]; + const { error, messages: olderMessages } = await api.getMessages( + currentChatId, + oldest?.time ?? from, + backward, + forward + ); + if (error) throw new Error(error); + + if (olderMessages.length < backward) { + all_loaded = true; + } + + await mergeMessages(olderMessages, true); + } + } catch (e) { + console.error(e); + } finally { + onAnchorRestore(); + loading = false; + } + + onAnchorRestore(); + } + + async function loadNewer() { + const currentChatId = getChatObj()?.id ?? getChatId(); + if (loadingNewer || all_loaded_newer) return; + if (currentChatId == null) return; + + loadingNewer = true; + + try { + const msgs = get(messages); + const newest = msgs[msgs.length - 1]; + const fromTime = newest?.time ?? (Date.now() + sessionGet("drift")); + + const api = getApi(); + const { error, messages: newerMessages } = await api.getNewerMessages(currentChatId, fromTime, BATCH_SIZE); + if (error) throw new Error(error); + + if (!newerMessages || newerMessages.length < BATCH_SIZE) { + all_loaded_newer = true; + } + + await mergeMessages(newerMessages, false); + } catch (e) { + console.error(e); + } finally { + loadingNewer = false; + } + + onAnchorRestore(); + } + + return { + get loading() { return loading; }, + get all_loaded() { return all_loaded; }, + set all_loaded(v) { all_loaded = v; }, + get loadingNewer() { return loadingNewer; }, + get all_loaded_newer() { return all_loaded_newer; }, + set all_loaded_newer(v) { all_loaded_newer = v; }, + decodeMessagesBatch, + mergeMessages, + loadHistory, + loadNewer, + }; +} diff --git a/src/components/ChatWindow/chatVirtualScroll.js b/src/components/ChatWindow/chatVirtualScroll.js new file mode 100644 index 0000000..fc390d0 --- /dev/null +++ b/src/components/ChatWindow/chatVirtualScroll.js @@ -0,0 +1,252 @@ +import { writable, get } from "svelte/store"; +import { saveChatScroll } from "$lib/stores/chatScroll.js"; + +export const DEFAULT_HEIGHT = 120; +export const OVERSCAN = 1500; + +export function createVirtualScrollManager() { + const messageHeights = writable({}); + let cumulativeHeights = []; + let pendingHeightUpdates = {}; + let resizeObserver = null; + + function setupResizeObserver() { + if (resizeObserver || typeof ResizeObserver === "undefined") return; + resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const el = entry.target; + const wrapper = el.closest(".message-wrapper"); + if (!wrapper) continue; + const id = wrapper.id?.replace("m-", ""); + if (!id) continue; + const height = entry.contentRect.height; + if (height > 0) { + pendingHeightUpdates[id] = height; + } + } + }); + } + + function observeResize(node) { + if (resizeObserver) resizeObserver.observe(node); + return { + destroy() { + if (resizeObserver) resizeObserver.unobserve(node); + }, + }; + } + + function applyPendingHeights(messagesList = []) { + const updates = pendingHeightUpdates; + pendingHeightUpdates = {}; + const keys = Object.keys(updates); + if (keys.length === 0) return; + messageHeights.update((h) => { + const newH = { ...h }; + for (const id of keys) newH[id] = updates[id]; + return newH; + }); + computeCumulativeHeights(messagesList); + } + + function computeCumulativeHeights(messagesList = []) { + const heights = []; + let sum = 0; + const currentHeights = get(messageHeights); + for (const msg of messagesList) { + const h = currentHeights[msg.id] || DEFAULT_HEIGHT; + sum += h; + heights.push(sum); + } + cumulativeHeights = heights; + return cumulativeHeights; + } + + function findIndexByOffset(target) { + let lo = 0; + let hi = cumulativeHeights.length; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (cumulativeHeights[mid] < target) lo = mid + 1; + else hi = mid; + } + return lo; + } + + function measureAllHeights(innerList) { + if (!innerList) return; + const wrappers = innerList.querySelectorAll(".message-wrapper"); + const updates = {}; + for (const wrapper of wrappers) { + const id = wrapper.id?.replace("m-", ""); + if (!id) continue; + const content = + wrapper.querySelector("#clickable-area") || + wrapper.querySelector(".observer-area") || + wrapper; + if (content) { + const height = content.getBoundingClientRect().height; + if (height > 0) updates[id] = height; + } + } + if (Object.keys(updates).length) { + messageHeights.update((h) => ({ ...h, ...updates })); + } + } + + function captureScrollAnchor(scrollElement, visibleMessages) { + if (!scrollElement) return null; + const containerRect = scrollElement.getBoundingClientRect(); + + for (const id in visibleMessages) { + const el = visibleMessages[id]; + if (!el) continue; + + const rect = el.getBoundingClientRect(); + if (rect.bottom > containerRect.top) { + return { + id: el.id, + offset: rect.top - containerRect.top, + }; + } + } + return null; + } + + function restoreScrollAnchor(scrollElement, scrollAnchor) { + if (!scrollAnchor || !scrollElement) return; + const el = document.getElementById(scrollAnchor.id); + if (!el) return; + + const containerRect = scrollElement.getBoundingClientRect(); + const rect = el.getBoundingClientRect(); + const delta = rect.top - containerRect.top - scrollAnchor.offset; + + if (delta !== 0) { + scrollElement.scrollTop += delta; + } + } + + function calculateVisibleMessages({ scrollElement, messagesList = [] }) { + if (!scrollElement) return {}; + applyPendingHeights(messagesList); + + const { scrollTop, clientHeight } = scrollElement; + const totalHeight = cumulativeHeights.length ? cumulativeHeights[cumulativeHeights.length - 1] : 0; + if (totalHeight === 0) { + return {}; + } + + const isNearBottom = totalHeight - scrollTop - clientHeight < 50; + let startIdx; + let endIdx; + + if (isNearBottom) { + const targetOffset = Math.max(0, totalHeight - clientHeight - OVERSCAN); + startIdx = findIndexByOffset(targetOffset); + endIdx = messagesList.length - 1; + } else { + const viewTop = Math.max(0, scrollTop - OVERSCAN); + const viewBottom = scrollTop + clientHeight + OVERSCAN; + startIdx = findIndexByOffset(viewTop); + endIdx = findIndexByOffset(viewBottom); + endIdx = Math.min(endIdx, messagesList.length - 1); + if (startIdx > endIdx) endIdx = startIdx; + } + + const newVisible = {}; + for (let i = startIdx; i <= endIdx && i < messagesList.length; i++) { + const id = messagesList[i].id; + if (!newVisible[id]) { + newVisible[id] = document.getElementById("m-" + id); + } + } + return newVisible; + } + + function savePosition({ + targetChatId, + scrollElement, + all_loaded_newer, + visibleMessages, + messagesList = [], + isInitialMounting, + isProgrammaticScroll, + }) { + if (!scrollElement || targetChatId == null || isInitialMounting || isProgrammaticScroll) return; + if (scrollElement.clientHeight <= 0 || scrollElement.scrollHeight <= 0) return; + if (scrollElement.scrollHeight <= scrollElement.clientHeight + 20) return; + + const distanceFromBottom = + scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; + const isAtBottom = all_loaded_newer && distanceFromBottom < 60; + if (isAtBottom) { + saveChatScroll(targetChatId, { + wasAtBottom: true, + lastSeenTime: Date.now(), + }); + return; + } + + const containerRect = scrollElement.getBoundingClientRect(); + let bottomMsg = null; + let bottomOffset = 0; + let maxBottom = -Infinity; + + for (const id in visibleMessages) { + const el = visibleMessages[id]; + if (!el) continue; + const rect = el.getBoundingClientRect(); + if (rect.top < containerRect.bottom && rect.bottom > maxBottom) { + maxBottom = rect.bottom; + const m = messagesList.find((x) => String(x.id) === String(id)); + if (m) { + bottomMsg = m; + bottomOffset = rect.top - containerRect.top; + } + } + } + + if (!bottomMsg && messagesList && messagesList.length > 0) { + const currentScroll = scrollElement.scrollTop; + const idx = findIndexByOffset(currentScroll + scrollElement.clientHeight / 2); + if (idx >= 0 && idx < messagesList.length) { + bottomMsg = messagesList[idx]; + bottomOffset = 40; + } + } + + if (bottomMsg) { + saveChatScroll(targetChatId, { + wasAtBottom: false, + bottomMessageId: bottomMsg.id, + bottomMessageTime: bottomMsg.time, + offset: bottomOffset, + lastSeenTime: Date.now(), + }); + } + } + + function destroy() { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + } + + return { + messageHeights, + getCumulativeHeights: () => cumulativeHeights, + setupResizeObserver, + observeResize, + applyPendingHeights, + computeCumulativeHeights, + findIndexByOffset, + measureAllHeights, + captureScrollAnchor, + restoreScrollAnchor, + calculateVisibleMessages, + savePosition, + destroy, + }; +} diff --git a/src/components/ChatWindow/input/AttachesMenu.svelte b/src/components/ChatWindow/input/AttachesMenu.svelte new file mode 100644 index 0000000..f94fad3 --- /dev/null +++ b/src/components/ChatWindow/input/AttachesMenu.svelte @@ -0,0 +1,104 @@ + + +
+ + + + + +
+ + diff --git a/src/components/ChatWindow/input/EditingBanner.svelte b/src/components/ChatWindow/input/EditingBanner.svelte new file mode 100644 index 0000000..df91207 --- /dev/null +++ b/src/components/ChatWindow/input/EditingBanner.svelte @@ -0,0 +1,83 @@ + + +{#if editingMessage} +
+
+ + + +
+
+
Редактирование сообщения
+
{editingMessage.text || 'Вложения'}
+
+ +
+{/if} + + diff --git a/src/components/ChatWindow/input/Input.svelte b/src/components/ChatWindow/input/Input.svelte index c221422..5b8b46a 100644 --- a/src/components/ChatWindow/input/Input.svelte +++ b/src/components/ChatWindow/input/Input.svelte @@ -1,13 +1,11 @@ -{#if attaches.length} -
- {#each attaches as attach, i} - {@const attachType = attach.type || attach._type} -
- - - {#if attachType === "PHOTO"} - preview - {:else if attachType === "VIDEO"} - {#if attach.path} - - {:else} -
🎥
Видео
- {/if} - {:else if attachType === "AUDIO"} -
🎵
Голосовое
- {:else} -
-
📄
-
- {attach.name || (attach.path ? attach.path.split("/").pop() : "Файл")} -
-
- {/if} + - {#if attach.uploading || attach.uploaded} -
-
-
-
- {/if} -
- {/each} -
-{/if} + removeAttach(e.detail.index)} /> -{#if editingMessage} -
-
- - - -
-
-
Редактирование сообщения
-
{editingMessage.text || 'Вложения'}
-
- -
-{/if} + {#if replyTo} {/if} - -
{#if stickerSuggestions.length > 0} - {#if isFallbackVideo} - - {:else} - - {/if} - -
+ session.flipCamera(videoPreviewEl)} + /> {/if} {#if isReviewingVoice} - {@const voiceReviewBars = Array.from(generateWaveformFromAmplitudes(reviewAmplitudes, 60))} - {@const voiceProgress = reviewAudioDuration > 0 ? Math.min(1, Math.max(0, (reviewAudioCurrentTime - reviewAudioTrimStart) / (reviewAudioTrimEnd - reviewAudioTrimStart))) : 0} -
- - - - -
- -
- - {#each voiceReviewBars as bar, i} - {@const h = Math.max(2, Math.round(bar * 22))} - {@const played = (i + 0.5) / voiceReviewBars.length <= voiceProgress} - - {/each} - -
- - -
-
- {formatSeconds(reviewAudioTrimStart)} - {(reviewAudioTrimEnd - reviewAudioTrimStart).toFixed(1)}s - {formatSeconds(reviewAudioTrimEnd)} -
-
-
+ {#if attachesDropout} + selectFile(e.detail.type)} /> + {/if} - -
- {:else if isReviewingVideoNote} - {@const reviewDur = reviewTrimEnd - reviewTrimStart} - {@const reviewProgress = reviewDur > 0 ? Math.min(1, Math.max(0, (reviewCurrentTime - reviewTrimStart) / reviewDur)) : 0} - {@const ringSize = 200} - {@const ringStroke = 4} - {@const ringRadius = (ringSize - ringStroke) / 2} - {@const ringCirc = 2 * Math.PI * ringRadius} -
- - - - -
{ if (e.key === ' ' || e.key === 'Enter') toggleReviewPlay(); }}> -