feat(dns,i18n): add missing public DNS endpoints and implement Arabic/Persian text shaping and bidi

This commit is contained in:
Kirill Minovsky 2026-09-04 17:19:58 +03:00
parent a983de446d
commit 65dc96a300
8 changed files with 144 additions and 29 deletions

14
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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"]
# === Дополнительные настройки (меняются без правки кода) ===

View file

@ -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"

View file

@ -68,9 +68,13 @@ fn d_dns_udp_servers() -> Vec<Vec<String>> {
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);
}
}

View file

@ -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<Language> {
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 &sections {
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);
}
}

View file

@ -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::<Vec<_>>()
@ -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::<Vec<_>>()
.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<String> = 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::<Vec<_>>()
.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);
}

View file

@ -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<String> = 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<String> = 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