From 65dc96a30048c22870dedadf5bdb3d477a6fef0d Mon Sep 17 00:00:00 2001 From: Kirill Minovsky Date: Fri, 4 Sep 2026 17:19:58 +0300 Subject: [PATCH] feat(dns,i18n): add missing public DNS endpoints and implement Arabic/Persian text shaping and bidi --- Cargo.lock | 14 +++++++ Cargo.toml | 2 + config.yml | 19 +++++++++ crates/dpi-core/Cargo.toml | 2 + crates/dpi-core/src/config.rs | 16 +++++--- crates/dpi-core/src/i18n/mod.rs | 68 +++++++++++++++++++++++++++++-- crates/dpi-detector/src/menu.rs | 47 +++++++++++++-------- crates/dpi-detector/src/render.rs | 5 ++- 8 files changed, 144 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 434d8b8..7f78211 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "arabic_reshaper" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb963411bc62b3888e69ad0d821ac03a3176ec7d74c2096bb5555a4cc419545b" + [[package]] name = "arrayvec" version = "0.7.8" @@ -444,6 +450,7 @@ dependencies = [ name = "dpi-core" version = "4.0.0-rust" dependencies = [ + "arabic_reshaper", "base64", "bytes", "http-body-util", @@ -460,6 +467,7 @@ dependencies = [ "tokio", "tokio-rustls", "tracing", + "unicode-bidi", "url", "webpki-roots 0.26.11", "winreg", @@ -1936,6 +1944,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 0dd0ab2..6fae459 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,8 @@ crossterm = "0.28" comfy-table = { version = "7.1", default-features = false, features = ["custom_styling"] } unicode-width = "0.2" rand = "0.8" +arabic_reshaper = "0.4.2" +unicode-bidi = "0.3" thiserror = "1.0" base64 = "0.22" diff --git a/config.yml b/config.yml index 83e77fb..b83872d 100644 --- a/config.yml +++ b/config.yml @@ -81,9 +81,13 @@ DNS_UDP_SERVERS: - ["76.76.2.0", "ControlD"] - ["194.242.2.2", "Mullvad"] - ["185.228.168.9", "CleanBrowsing"] + - ["76.76.19.19", "Alternate DNS"] - ["76.223.122.150", "NextDNS"] - ["185.222.222.222", "DNS.SB"] - ["176.9.93.198", "dnsforge"] + - ["8.26.56.26", "Comodo"] + - ["84.200.69.80", "DNS Watch"] + - ["209.244.0.3", "Level 3"] # --- Тест 2: Проверка доступности DNS-серверов --- DNS_AVAILABILITY_DOMAINS: @@ -136,6 +140,19 @@ DNS_AVAILABILITY_SERVERS: - ["45.155.204.190", "Geohide", "udp"] - ["83.220.169.155", "Comss.one", "udp"] - ["212.109.195.93", "Comss.one", "udp"] + - ["76.76.19.19", "Alternate DNS", "udp"] + - ["76.223.122.150", "Alternate DNS 2", "udp"] + - ["8.26.56.26", "Comodo", "udp"] + - ["8.20.247.20", "Comodo 2", "udp"] + - ["84.200.69.80", "DNS Watch", "udp"] + - ["84.200.70.40", "DNS Watch 2", "udp"] + - ["209.244.0.3", "Level 3", "udp"] + - ["209.244.0.4", "Level 3 2", "udp"] + - ["209.224.0.3", "Level 3 (alt)", "udp"] + - ["209.224.0.4", "Level 3 2 (alt)", "udp"] + - ["149.112.112.10", "Quad9 (UN 2)", "udp"] + - ["77.88.8.88", "Yandex (Safe)", "udp"] + - ["77.88.8.2", "Yandex (Safe 2)", "udp"] # Нестабильные: # - ["https://doh.pub/dns-query", "DNSPod (doh.pub)", "doh_wire"] @@ -177,6 +194,7 @@ DNS_AVAILABILITY_SERVERS: - ["https://xbox-dns.ru/dns-query", "XboxDNS (RU)", "doh_wire"] - ["https://common.dot.dns.yandex.net/dns-query", "Yandex", "doh_wire"] - ["https://dns.geohide.ru:444/dns-query", "Geohide", "doh_wire"] + - ["https://safe.dot.dns.yandex.net/dns-query", "Yandex (Safe)", "doh_wire"] # Нестабильные: @@ -216,6 +234,7 @@ DNS_AVAILABILITY_SERVERS: - ["common.dot.dns.yandex.net", "Yandex", "dot"] - ["dns.geohide.ru", "Geohide", "dot"] - ["dot.libredns.gr", "LibreDNS", "dot"] + - ["safe.dot.dns.yandex.net", "Yandex (Safe)", "dot"] # === Дополнительные настройки (меняются без правки кода) === diff --git a/crates/dpi-core/Cargo.toml b/crates/dpi-core/Cargo.toml index d1eac37..fa14e2c 100644 --- a/crates/dpi-core/Cargo.toml +++ b/crates/dpi-core/Cargo.toml @@ -23,6 +23,8 @@ rand = { workspace = true } thiserror = { workspace = true } base64 = { workspace = true } tracing = { workspace = true } +arabic_reshaper = { workspace = true } +unicode-bidi = { workspace = true } url = "2.5" [target.'cfg(windows)'.dependencies] winreg = "0.55" diff --git a/crates/dpi-core/src/config.rs b/crates/dpi-core/src/config.rs index 685feab..be84680 100644 --- a/crates/dpi-core/src/config.rs +++ b/crates/dpi-core/src/config.rs @@ -68,9 +68,13 @@ fn d_dns_udp_servers() -> Vec> { vec!["76.76.2.0".to_string(), "ControlD".to_string()], vec!["194.242.2.2".to_string(), "Mullvad".to_string()], vec!["185.228.168.9".to_string(), "CleanBrowsing".to_string()], + vec!["76.76.19.19".to_string(), "Alternate DNS".to_string()], vec!["76.223.122.150".to_string(), "NextDNS".to_string()], vec!["185.222.222.222".to_string(), "DNS.SB".to_string()], vec!["176.9.93.198".to_string(), "dnsforge".to_string()], + vec!["8.26.56.26".to_string(), "Comodo".to_string()], + vec!["84.200.69.80".to_string(), "DNS Watch".to_string()], + vec!["209.244.0.3".to_string(), "Level 3".to_string()], ] } @@ -962,14 +966,14 @@ mod tests { cfg.dns_check_domains, vec!["rutor.info", "flibusta.is", "clubtone.do.am", "rezka.ag", "shikimori.one"] ); - assert_eq!(cfg.dns_udp_servers.len(), 13); + assert_eq!(cfg.dns_udp_servers.len(), 17); assert_eq!(cfg.dns_udp_servers[0], vec!["8.8.8.8", "Google"]); assert_eq!(cfg.dns_availability_domains, vec!["vk.ru", "gosuslugi.ru"]); let servers = cfg.availability_servers(); - assert_eq!(servers.iter().filter(|s| s.kind == "udp").count(), 37); - assert_eq!(servers.iter().filter(|s| s.kind == "doh_wire").count(), 36); - assert_eq!(servers.iter().filter(|s| s.kind == "dot").count(), 33); - assert_eq!(servers.len(), 106); + assert_eq!(servers.iter().filter(|s| s.kind == "udp").count(), 50); + assert_eq!(servers.iter().filter(|s| s.kind == "doh_wire").count(), 37); + assert_eq!(servers.iter().filter(|s| s.kind == "dot").count(), 34); + assert_eq!(servers.len(), 121); assert_eq!(servers[0].addr, "94.140.14.14"); assert_eq!(servers.last().map(|s| s.kind.as_str()), Some("dot")); assert_eq!(cfg.telegram_dc_list().len(), 5); @@ -1016,6 +1020,6 @@ mod tests { assert!(!embedded_tcp16_targets().is_empty()); assert!(!embedded_whitelist_sni().is_empty()); let cfg = AppConfig::from_yaml_str(EMBEDDED_CONFIG_YML); - assert_eq!(cfg.availability_servers().len(), 106); + assert_eq!(cfg.availability_servers().len(), 121); } } diff --git a/crates/dpi-core/src/i18n/mod.rs b/crates/dpi-core/src/i18n/mod.rs index 4ebe52b..0ab55b0 100644 --- a/crates/dpi-core/src/i18n/mod.rs +++ b/crates/dpi-core/src/i18n/mod.rs @@ -92,6 +92,44 @@ impl Language { } } +/// Shapes Arabic and Persian text into cursive presentation glyphs and reorders +/// them according to the Unicode Bidirectional Algorithm for visual display in LTR terminals. +/// For LTR languages (English, Russian, Chinese, Spanish), returns the text unchanged. +pub fn format_bidi(text: &str, lang: Language) -> String { + if !lang.is_rtl() { + return text.to_string(); + } + format_bidi_str(text) +} + +/// Shapes Arabic script characters into connected ligatures/presentation forms +/// and reorders lines for terminal display. +pub fn format_bidi_str(text: &str) -> String { + // If text contains no Arabic/Persian characters, return early + if !text.chars().any(|c| matches!(c, '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}')) { + return text.to_string(); + } + + // 1. Reshape cursive Arabic / Persian characters into connected presentation forms + let reshaped = arabic_reshaper::arabic_reshape(text); + + // 2. Reorder for visual display using Unicode Bidirectional Algorithm per line/paragraph + let bidi_info = unicode_bidi::BidiInfo::new(&reshaped, None); + if bidi_info.paragraphs.is_empty() { + return reshaped; + } + + let mut out = String::with_capacity(reshaped.len()); + for (i, para) in bidi_info.paragraphs.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + let line = bidi_info.reorder_line(para, para.range.clone()); + out.push_str(&line); + } + out +} + #[cfg(target_os = "windows")] fn detect_windows_language() -> Option { use winreg::enums::HKEY_CURRENT_USER; @@ -1260,7 +1298,7 @@ pub fn get_messages(lang: Language) -> Messages { /// Terms stay Latin; descriptions follow the selected language (en/ru full, /// other languages fall back to English descriptions). pub fn print_legend(lang: Language, msg: &Messages) { - println!("{}", msg.legend_title); + println!("{}", format_bidi(msg.legend_title, lang)); let sections = match lang { Language::Ru => legend_sections(), Language::Zh => legend_sections_zh(), @@ -1270,9 +1308,9 @@ pub fn print_legend(lang: Language, msg: &Messages) { Language::En => legend_sections_en(), }; for (section, items) in §ions { - println!(" {}", section); + println!(" {}", format_bidi(section, lang)); for (term, desc) in items { - println!(" \x1b[36m{:<14}\x1b[0m \x1b[2m{}\x1b[0m", term, desc); + println!(" \x1b[36m{:<14}\x1b[0m \x1b[2m{}\x1b[0m", term, format_bidi(desc, lang)); } println!(); } @@ -1630,4 +1668,28 @@ mod tests { assert_eq!(msg.menu_test_label('7'), ""); } } + + #[test] + fn test_format_bidi_farsi_and_arabic() { + // LTR strings remain unchanged + assert_eq!(format_bidi("English text", Language::En), "English text"); + assert_eq!(format_bidi("Русский текст", Language::Ru), "Русский текст"); + + // Farsi words are shaped and reordered + let shaped = format_bidi("خروج", Language::Fa); + assert_ne!(shaped, "خروج"); + // Contains Arabic Presentation Forms + assert!(shaped.chars().any(|c| matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}'))); + + // Complex Farsi sentence + let menu_title = "پارامترها و انتخاب آزمون‌ها"; + let shaped_title = format_bidi(menu_title, Language::Fa); + assert_ne!(shaped_title, menu_title); + assert!(shaped_title.chars().any(|c| matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}'))); + + // Arabic string + let ar_text = "المعلمات واختيار الاختبارات"; + let shaped_ar = format_bidi(ar_text, Language::Ar); + assert_ne!(shaped_ar, ar_text); + } } diff --git a/crates/dpi-detector/src/menu.rs b/crates/dpi-detector/src/menu.rs index acacbf0..18a8f97 100644 --- a/crates/dpi-detector/src/menu.rs +++ b/crates/dpi-detector/src/menu.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use dpi_core::config::AppConfig; -use dpi_core::i18n::{get_messages, Language, Messages}; +use dpi_core::i18n::{format_bidi, get_messages, Language, Messages}; use dpi_core::net::netinfo::ipv6_supported; use dpi_core::net::version::{version_badge_lang, ReleaseInfo}; use dpi_core::profile::RegionProfile; @@ -241,10 +241,11 @@ fn draw_menu( let lang_opts = Language::ALL .iter() .map(|&l| { + let lbl = format_bidi(l.label(), current_lang); if l == current_lang { - format!("\x1b[1;32m●\x1b[0m {}", l.label()) + format!("\x1b[1;32m●\x1b[0m {}", lbl) } else { - format!("\x1b[2m○ {}\x1b[0m", l.label()) + format!("\x1b[2m○ {}\x1b[0m", lbl) } }) .collect::>() @@ -268,10 +269,12 @@ fn draw_menu( Language::Es => "(no disponible)", Language::En => "(unavailable)", }; - format!("\x1b[1;32m●\x1b[0m IPv4 \x1b[2m○ IPv6 {}\x1b[0m", unavail) + let unavail_bidi = format_bidi(unavail, current_lang); + format!("\x1b[1;32m●\x1b[0m IPv4 \x1b[2m○ IPv6 {}\x1b[0m", unavail_bidi) }; let ip_cursor = if cursor == 1 { "►" } else { " " }; - lines.push(format!(" {} {} {}", ip_cursor, pad_width(msg.menu_ip_version, 15), ip_opts)); + let ip_lbl = format_bidi(msg.menu_ip_version, current_lang); + lines.push(format!(" {} {} {}", ip_cursor, pad_width(&ip_lbl, 15), ip_opts)); // Concurrency row let conc_opts = presets @@ -286,8 +289,8 @@ fn draw_menu( .collect::>() .join(" "); let conc_cursor = if cursor == 2 { "►" } else { " " }; - lines.push(format!(" {} {} {}", conc_cursor, pad_width(msg.menu_concurrency, 15), conc_opts)); - + let conc_lbl = format_bidi(msg.menu_concurrency, current_lang); + lines.push(format!(" {} {} {}", conc_cursor, pad_width(&conc_lbl, 15), conc_opts)); lines.push(format!(" {}", "─".repeat(BOX_WIDTH - 8))); for (i, (digit, label)) in test_options.iter().enumerate() { @@ -298,12 +301,12 @@ fn draw_menu( "\x1b[2m[ ]\x1b[0m" }; let row_cursor = if cursor == i + offset { "►" } else { " " }; - lines.push(format!(" {} {} {}. {}", row_cursor, check_box, digit, label)); + lines.push(format!(" {} {} {}. {}", row_cursor, check_box, digit, format_bidi(label, current_lang))); } // Glyph-safe content first: widths are measured after replacement. let lines: Vec = lines.into_iter().map(|l| asc(&l)).collect(); - let title_clean = format!(" {} ", asc(msg.menu_title)); + let title_clean = format!(" {} ", asc(&format_bidi(msg.menu_title, current_lang))); let title_len = strip_ansi_len(&title_clean); let border_total = BOX_WIDTH.saturating_sub(title_len + 3); let (tl, tr, bl, br, hb, vb) = if ascii_mode() { @@ -322,9 +325,13 @@ fn draw_menu( print!("\x1b[1;36m{}{}{}\x1b[0m\r\n", bl, hb.repeat(BOX_WIDTH - 2), br); print!("{}\r\n", asc(&format!(" \x1b[1;46;37m ↑↓ \x1b[0m {} │ \x1b[1;46;37m ←→ \x1b[0m {} │ \x1b[1;46;37m 0-6 \x1b[0m {} │ \x1b[1;42;37m Enter \x1b[0m {} │ \x1b[1;41;37m Q \x1b[0m {}", - msg.menu_hw_row, msg.menu_hw_change, msg.menu_hw_tests, msg.menu_hw_start, msg.menu_hw_quit))); + format_bidi(msg.menu_hw_row, current_lang), + format_bidi(msg.menu_hw_change, current_lang), + format_bidi(msg.menu_hw_tests, current_lang), + format_bidi(msg.menu_hw_start, current_lang), + format_bidi(msg.menu_hw_quit, current_lang)))); if let Some(n) = notice { - print!(" \x1b[1;33m{}\x1b[0m\r\n", n); + print!(" \x1b[1;33m{}\x1b[0m\r\n", format_bidi(n, current_lang)); } let _ = stdout().flush(); } @@ -385,7 +392,7 @@ mod tests { let test_options = get_test_options(&msg); // Title - let title_clean = format!(" {} ", asc(msg.menu_title)); + let title_clean = format!(" {} ", asc(&format_bidi(msg.menu_title, lang))); let title_len = strip_ansi_len(&title_clean); assert!(title_len + 3 <= BOX_WIDTH, "title for {:?} overflows box: {}", lang, title_len); @@ -393,31 +400,35 @@ mod tests { let lang_opts = Language::ALL .iter() .map(|&l| { + let lbl = format_bidi(l.label(), lang); if l == lang { - format!("\x1b[1;32m●\x1b[0m {}", l.label()) + format!("\x1b[1;32m●\x1b[0m {}", lbl) } else { - format!("\x1b[2m○ {}\x1b[0m", l.label()) + format!("\x1b[2m○ {}\x1b[0m", lbl) } }) .collect::>() .join(" "); - let lang_line = format!(" ► {} {}", pad_width(msg.menu_language, 8), lang_opts); + let lang_lbl = format_bidi(msg.menu_language, lang); + let lang_line = format!(" ► {} {}", pad_width(&lang_lbl, 8), lang_opts); let w = strip_ansi_len(&lang_line); assert!(w + 3 <= BOX_WIDTH, "language row for {:?} overflows box: w={}", lang, w); // IP version row - let ip_line = format!(" {} ● IPv4 ○ IPv6", pad_width(msg.menu_ip_version, 15)); + let ip_lbl = format_bidi(msg.menu_ip_version, lang); + let ip_line = format!(" {} ● IPv4 ○ IPv6", pad_width(&ip_lbl, 15)); let w = strip_ansi_len(&ip_line); assert!(w + 3 <= BOX_WIDTH, "ip row for {:?} overflows box: w={}", lang, w); // Concurrency row - let conc_line = format!(" {} ● 50 ○ 100", pad_width(msg.menu_concurrency, 15)); + let conc_lbl = format_bidi(msg.menu_concurrency, lang); + let conc_line = format!(" {} ● 50 ○ 100", pad_width(&conc_lbl, 15)); let w = strip_ansi_len(&conc_line); assert!(w + 3 <= BOX_WIDTH, "conc row for {:?} overflows box: w={}", lang, w); // Test options for (digit, label) in test_options { - let test_line = format!(" [ ] {}. {}", digit, label); + let test_line = format!(" [ ] {}. {}", digit, format_bidi(label, lang)); let w = strip_ansi_len(&test_line); assert!(w + 3 <= BOX_WIDTH, "test row {} for {:?} overflows box: w={}", digit, lang, w); } diff --git a/crates/dpi-detector/src/render.rs b/crates/dpi-detector/src/render.rs index 5f4e9af..78e9491 100644 --- a/crates/dpi-detector/src/render.rs +++ b/crates/dpi-detector/src/render.rs @@ -135,8 +135,9 @@ pub fn panel_to_string(title: &str, lines: &[String]) -> String { /// Mirrors Rich: banner is left-titled cyan, netinfo centered dim. pub fn panel_with(title: &str, lines: &[String], width: usize, centered: bool, border: &str) -> String { // Glyph-safe content first: widths are measured after replacement. - let title_clean = format!(" {} ", asc(title)); - let lines: Vec = lines.iter().map(|l| asc(l)).collect(); + let title_bidi = dpi_core::i18n::format_bidi_str(title); + let title_clean = format!(" {} ", asc(&title_bidi)); + let lines: Vec = lines.iter().map(|l| asc(&dpi_core::i18n::format_bidi_str(l))).collect(); let mut out = String::new(); // Titles may carry SGR escapes (e.g. the bold banner title): measure visible // width only, and re-arm the border color after the title so an inner reset