diff --git a/locales/en.toml b/locales/en.toml index fec3697..85a9284 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -62,6 +62,7 @@ password_confirm_label = "Confirm password" hostname_label = "Computer name" username_invalid = "Invalid username. Must start with a lowercase letter or underscore, max 32 characters." username_too_long = "Username too long (maximum 32 characters)" +username_empty = "Username cannot be empty" username_forbidden = "This username is reserved by the system and is forbidden" username_invalid_start = "Username must start with a lowercase letter or underscore" username_invalid_chars = "Only lowercase letters, digits, underscore and hyphen are allowed" @@ -146,6 +147,7 @@ mp_create_here = "Create Here" mp_will_delete = "will be deleted" mp_restore = "Restore" mp_free_space = "Free Space" +mp_duplicate_mountpoint = "Two partitions share the same mount point; each partition must have a distinct mount point" mp_bar_now = "Current Layout" mp_bar_after = "After Installation" @@ -216,6 +218,8 @@ job_install_packages = "Installing selected packages" job_ldconfig = "Configuring shared libraries (ldconfig)" job_update_env = "Updating system environment" job_cleanup_live = "Cleaning up live environment traces" +job_netinstall = "Installing extra packages" +job_setup_lvm = "Configuring LVM" # ── Manual Partition UI ──────────────────────────────────── manual_partition_title = "Manual Partitioning" diff --git a/locales/tr.toml b/locales/tr.toml index 4fab7d1..c5037f9 100644 --- a/locales/tr.toml +++ b/locales/tr.toml @@ -62,6 +62,7 @@ password_confirm_label = "Parola (tekrar)" hostname_label = "Bilgisayar adı" username_invalid = "Geçersiz kullanıcı adı. Küçük harf veya alt çizgi ile başlamalı, en fazla 32 karakter." username_too_long = "Kullanıcı adı çok uzun (maksimum 32 karakter)" +username_empty = "Kullanıcı adı boş olamaz" username_forbidden = "Bu kullanıcı adı sistem tarafından kullanılıyor ve yasaklı" username_invalid_start = "Kullanıcı adı küçük harf veya alt çizgi ile başlamalı" username_invalid_chars = "Sadece küçük harf, rakam, alt çizgi ve tire kullanılabilir" @@ -122,6 +123,7 @@ mp_create_here = "Buraya Oluştur" mp_will_delete = "silinecek" mp_restore = "Geri Al" mp_free_space = "Boş Alan" +mp_duplicate_mountpoint = "Aynı bağlama noktası iki bölüme birden verilmiş; her bölümün farklı bir bağlama noktası olmalı" mp_bar_now = "Mevcut Durum" mp_bar_after = "Kurulum Sonrası" diff --git a/src/funct.rs b/src/funct.rs index 22bc55d..37c17bc 100644 --- a/src/funct.rs +++ b/src/funct.rs @@ -1,5 +1,28 @@ use eframe::egui; +/// Uygulamanın desteklediği diller (`locales/` altındaki dosyalarla eşleşir). +/// +/// `locales/` klasörüne yeni bir dosya eklendiğinde buraya da eklenmelidir. +pub const AVAILABLE_LANGUAGES: &[&str] = &["tr", "en"]; + +/// Yedek dil: desteklenmeyen bir kod gelirse kullanılır. +pub const FALLBACK_LANGUAGE: &str = "en"; + +/// `rust_i18n::set_locale` çağrısından ÖNCE kullanılır. +/// +/// Neden: `set_locale("de")` hata vermez, sessizce kabul eder ve `t!()` +/// karşılığı olmayan her anahtar için anahtarın KENDİSİNİ döndürür — +/// arayüz "location_title", "next", "partition" gibi çıplak metinlere +/// dolar. O yüzden karar burada verilir: dil desteklenmiyorsa hiç +/// değiştirilmez, kullanıcının seçimi korunur. +pub fn sanitize_locale(locale: &str) -> &str { + if AVAILABLE_LANGUAGES.contains(&locale) { + locale + } else { + FALLBACK_LANGUAGE + } +} + #[macro_export] macro_rules! draw_icon { ($ui:expr, $path:literal, $size:expr, $color:expr) => { @@ -150,4 +173,36 @@ pub fn language_selector_ui(ui: &mut eframe::egui::Ui, state: &mut crate::instal } }); }); -} \ No newline at end of file +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supported_locales_pass_through() { + assert_eq!(sanitize_locale("tr"), "tr"); + assert_eq!(sanitize_locale("en"), "en"); + } + + #[test] + fn unsupported_locales_fall_back() { + // Bu diller için locale dosyası YOK. set_locage'e sızsaydı + // arayüz çıplak anahtarlarla dolardı. + for code in ["de", "fr", "it", "es", "ru", "nl", "pl", "xx", ""] { + assert_eq!( + sanitize_locale(code), + FALLBACK_LANGUAGE, + "{} desteklenmiyor, yedeğe düşmeli", code + ); + } + } + + #[test] + fn fallback_language_is_itself_supported() { + // Yedeğin de desteklenmemesi durumunda sonsuz hata durumu oluşur. + assert!( + AVAILABLE_LANGUAGES.contains(&FALLBACK_LANGUAGE), + "yedek dil {} desteklenen listede olmalı", FALLBACK_LANGUAGE + ); + } +} diff --git a/src/installer.rs b/src/installer.rs index db7bb4e..63de09e 100644 --- a/src/installer.rs +++ b/src/installer.rs @@ -399,6 +399,9 @@ pub struct WelcomeStep { checks: Vec, checked: bool, last_language: String, + /// Kurtarma modunun kullanılabilir olup olmadığı. `on_enter`'da bir kez + /// hesaplanır — `blkid` her karede çalıştırılırsa UI kilitlenir. + rescue_available: bool, } impl Default for WelcomeStep { @@ -407,6 +410,7 @@ impl Default for WelcomeStep { checks: Vec::new(), checked: false, last_language: String::new(), + rescue_available: false, } } } @@ -416,8 +420,9 @@ impl InstallerStep for WelcomeStep { fn on_enter(&mut self, state: &mut GlobalState) { if !self.checked || self.last_language != state.language { - rust_i18n::set_locale(&state.language); + rust_i18n::set_locale(crate::funct::sanitize_locale(&state.language)); self.checks = run_all(); + self.rescue_available = detect_existing_linux(); self.checked = true; self.last_language = state.language.clone(); } @@ -426,9 +431,13 @@ impl InstallerStep for WelcomeStep { fn show(&mut self, ui: &mut egui::Ui, state: &mut GlobalState) { use crate::ui::theme; + // on_enter ile aynı koşul, aynı etki: dil değiştiyse kontrolleri ve + // kurtarma kullanılabilirliğini tazele. (show() içinde yalnızca bu + // senkron blok var; artık disk taraması yapmıyor.) if self.last_language != state.language { - rust_i18n::set_locale(&state.language); + rust_i18n::set_locale(crate::funct::sanitize_locale(&state.language)); self.checks = run_all(); + self.rescue_available = detect_existing_linux(); self.last_language = state.language.clone(); } @@ -568,8 +577,12 @@ impl InstallerStep for WelcomeStep { .inner_margin(egui::Margin::symmetric(8.0, 4.0)) .show(ui, |ui| { ui.horizontal(|ui| { - let rescue_available = detect_existing_linux(); - if rescue_available { + // `detect_existing_linux()` bir `blkid -o list` süreci + // başlatır — TÜM blok aygıtlarını tarar. Bunu çizim + // (show) içinde çağırmak saniyede ~60 kez süreç + // doğuruyor, yani kare hızında tam disk taraması. + // Sonuç `on_enter`'da bir kez hesaplanıp saklanıyor. + if self.rescue_available { if theme::light_gray_btn(ui, &t!("rescue_button")).clicked() { state.rescue_mode = true; } diff --git a/src/main.rs b/src/main.rs index 8d9725d..e4abbd0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -295,7 +295,7 @@ impl Default for YaliApp { impl eframe::App for YaliApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - rust_i18n::set_locale(&self.state.language); + rust_i18n::set_locale(crate::funct::sanitize_locale(&self.state.language)); // Dil değişimi kontrolü -> branding dosyasını dinamik olarak yeniden yükle if self.loaded_language != self.state.language { diff --git a/src/steps/location.rs b/src/steps/location.rs index 42737e1..bd70293 100644 --- a/src/steps/location.rs +++ b/src/steps/location.rs @@ -97,6 +97,31 @@ impl Default for LocationStep { } impl LocationStep { + /// Ülke kodundan dil koduna çevirir. + /// + /// YALIDIR: yalnızca `locales/` altında GERÇEKTEN bulunan diller + /// döndürür. Daha önce 8 ülke eşleniyordu (de/fr/it/es/ru/nl/pl) ama + /// projede yalnızca tr.toml ve en.toml var. `set_locale("de")` + /// çağrıldığında rust-i18n sessizce anahtarın kendisini döndürür — + /// yani Alman kullanıcı "location_title", "next", "partition" gibi + /// çıplak anahtarlarla dolu bir arayüz görüyordu. + fn language_for_country(country: &str) -> Option<&'static str> { + match country { + "TR" => Some("tr"), + // AB'nin İngilizce konuşulan bölümleri ve İngilizce resmî + // dili olan ülkeler. Bunlar en.toml'a düşer. + // + // Not: Güney Afrika (ZA) bilinçli olarak listede değil — 11 + // resmî dili var ve İngilizce baskın değil. Benzer durumdaki + // ülkeler de (Kanada, İsviçre) yalnızca İngilizce resmî dile + // sahip oldukları için listede. + "GB" | "IE" | "MT" | "US" | "CA" | "AU" | "NZ" | "IN" | "SG" | + "NG" | "KE" | "GH" | "HK" | "JM" | "BS" | "BB" | + "BZ" | "GY" | "TT" | "BW" | "ZW" | "ZM" | "FJI" => Some("en"), + _ => None, + } + } + fn geoip_lookup(state: &mut GlobalState) { let out = std::process::Command::new("curl") .args(["-s", "--connect-timeout", "3", "http://ip-api.com/json"]) @@ -111,16 +136,10 @@ impl LocationStep { }) }; if let Some(country) = extract("countryCode") { - match country.as_str() { - "TR" => state.language = "tr".to_string(), - "DE" => state.language = "de".to_string(), - "FR" => state.language = "fr".to_string(), - "IT" => state.language = "it".to_string(), - "ES" => state.language = "es".to_string(), - "RU" => state.language = "ru".to_string(), - "NL" => state.language = "nl".to_string(), - "PL" => state.language = "pl".to_string(), - _ => {} + // Mevcut locale dosyası olmayan bir dile geçilmezse + // kullanıcının seçtiği dil korunur. + if let Some(lang) = Self::language_for_country(&country) { + state.language = lang.to_string(); } } if let Some(tz) = extract("timezone") { @@ -347,3 +366,54 @@ impl InstallerStep for LocationStep { !state.timezone.is_empty() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn geoip_only_maps_to_locales_that_exist() { + // Regresyon: de/fr/it/es/ru/nl/pl eşleniyordu ama locales/ altında + // yalnızca tr.toml ve en.toml var. set_locale("de") sessizce + // kabul edildiği için Alman kullanıcı tüm arayüzü çıplak + // anahtar olarak görüyordu. + for code in ["DE", "FR", "IT", "ES", "RU", "NL", "PL"] { + let mapped = LocationStep::language_for_country(code); + if let Some(lang) = mapped { + assert!( + crate::funct::AVAILABLE_LANGUAGES.contains(&lang), + "{} -> {} eşlendi ama {} locale dosyası yok", + code, lang, lang + ); + } + } + } + + #[test] + fn turkey_maps_to_turkish() { + assert_eq!(LocationStep::language_for_country("TR"), Some("tr")); + } + + #[test] + fn english_speaking_countries_map_to_english() { + for code in ["GB", "US", "IE", "AU", "CA", "NZ", "IN"] { + assert_eq!( + LocationStep::language_for_country(code), + Some("en"), + "{} İngilizce'ye eşlenmeli", code + ); + } + } + + #[test] + fn unknown_countries_leave_language_alone() { + // Eşleme yok → None → kullanıcının seçtiği dil korunur. + for code in ["JP", "CN", "BR", "ZA", "XX", ""] { + assert_eq!( + LocationStep::language_for_country(code), + None, + "{} eşlenmemeli", code + ); + } + } +} diff --git a/src/steps/network.rs b/src/steps/network.rs index 8f63941..71677f4 100644 --- a/src/steps/network.rs +++ b/src/steps/network.rs @@ -9,6 +9,9 @@ pub struct NetworkStep { scanning: bool, scan_done: bool, proxy_enabled: bool, + /// `nmcli dev status` çıktısı. Adıma girişte bir kez okunur — + /// show() içinde okunursa her karede alt süreç doğar. + status_text: Option, } #[derive(Clone, Default)] @@ -66,6 +69,15 @@ impl NetworkStep { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) } } + /// `nmcli dev status` çıktısını tazeler. Adıma girişte ve Wi-Fi + /// bağlantı denemelerinden sonra çağrılır. + fn refresh_status(&mut self) { + self.status_text = std::process::Command::new("nmcli") + .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "dev", "status"]) + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).into_owned()); + } } impl InstallerStep for NetworkStep { @@ -73,28 +85,41 @@ impl InstallerStep for NetworkStep { t!("network").to_string() } + fn on_enter(&mut self, _state: &mut GlobalState) { + self.refresh_status(); + } + fn show(&mut self, ui: &mut egui::Ui, state: &mut GlobalState) { ui.heading(t!("network_title")); ui.label(t!("network_description")); ui.add_space(12.0); // Mevcut bağlantı durumu - if let Ok(out) = std::process::Command::new("nmcli") - .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "dev", "status"]) - .output() - { - let text = String::from_utf8_lossy(&out.stdout); - let connected: Vec<&str> = text.lines() - .filter(|l| l.contains(":connected:") || l.contains(":connecting:")) - .collect(); - if !connected.is_empty() { + // + // `nmcli dev status` bir alt süreçtir ve ~50-100 ms sürer. Burada + // show() içinde, yani saniyede ~60 kez çağrılıyordu. Durum + // adımlara girişte bir kez okunur; bağlantı değişikliği + // `scan_wifi`/`connect` sonrası elle tazelenir. + match &self.status_text { + Some(text) => { + let connected: Vec<&str> = text.lines() + .filter(|l| l.contains(":connected:") || l.contains(":connecting:")) + .collect(); + if !connected.is_empty() { + ui.colored_label( + egui::Color32::from_rgb(80, 170, 80), + format!("{} {}", t!("network_connected"), connected.join(", ")), + ); + } else { + ui.colored_label( + egui::Color32::from_rgb(200, 140, 40), + t!("network_disconnected"), + ); + } + } + None => { ui.colored_label( - egui::Color32::from_rgb(80, 170, 80), - format!("{} {}", t!("network_connected"), connected.join(", ")), - ); - } else { - ui.colored_label( - egui::Color32::from_rgb(200, 140, 40), + egui::Color32::from_rgb(120, 120, 140), t!("network_disconnected"), ); } @@ -128,6 +153,7 @@ impl InstallerStep for NetworkStep { if !self.scan_done { if ui.add(egui::Button::new(t!("network_scan"))).clicked() { self.scan_wifi(); + self.refresh_status(); } } else { ui.horizontal(|ui| { @@ -185,6 +211,9 @@ impl InstallerStep for NetworkStep { Ok(msg) => { state.install_log.push(format!("Wi-Fi: {} — {}", net.ssid, msg)); self.scan_done = false; + // Bağlantı durumu değişti; üstteki + // "bağlı/bağlı değil" satırı tazelensin. + self.refresh_status(); } Err(e) => { state.install_log.push(format!("Wi-Fi error: {}", e)); diff --git a/src/steps/users.rs b/src/steps/users.rs index 897f45f..dfd7467 100644 --- a/src/steps/users.rs +++ b/src/steps/users.rs @@ -66,7 +66,10 @@ fn derive_username(real_name: &str) -> String { /// Hata varsa hata mesajını döndürür, yoksa None döndürür. fn validate_username(s: &str) -> Option { if s.is_empty() { - return None; + // Boş kullanıcı adı GEÇERLİ DEĞİLDİR. Daha önce burada `None` + // (hata yok) dönüyordu, bu yüzden geçerli bir hostname ile boş + // kullanıcı adı sihirbazı geçip `useradd ""` çalıştırıyordu. + return Some(t!("username_empty").to_string()); } if s.len() > 32 { return Some(t!("username_too_long").to_string()); @@ -425,3 +428,83 @@ impl InstallerStep for UsersStep { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_username_is_rejected() { + // Regresyon: validate_username("") None (hata yok) dönüyordu, bu + // yüzden geçerli bir hostname ile boş kullanıcı adı sihirbazı + // geçip CreateUserJob'a ulaşıyordu. + assert!( + validate_username("").is_some(), + "boş kullanıcı adı hata dönmeli" + ); + assert!(!is_valid_username(""), "boş kullanıcı adı geçerli olmamalı"); + } + + #[test] + fn valid_usernames_pass() { + for name in ["user", "ahmet", "a", "_root_", "user-1", "u_2026"] { + assert!( + validate_username(name).is_none(), + "{} geçerli olmalı", name + ); + } + } + + #[test] + fn system_usernames_are_forbidden() { + for name in ["root", "daemon", "nobody", "sshd", "www-data"] { + assert!( + validate_username(name).is_some(), + "{} yasaklı olmalı", name + ); + } + } + + #[test] + fn username_start_and_charset_rules() { + assert!(validate_username("1user").is_some(), "rakamla başlayamaz"); + assert!(validate_username("Auser").is_some(), "büyük harfle başlayamaz"); + assert!(validate_username("user name").is_some(), "boşluk olmaz"); + assert!(validate_username(&"a".repeat(33)).is_some(), "32'den uzun olmaz"); + assert!(validate_username(&"a".repeat(32)).is_none(), "32 kabul edilir"); + } + + #[test] + fn hostname_rules() { + assert!(is_valid_hostname("pisi-pc")); + assert!(is_valid_hostname("Pisi1")); + assert!(!is_valid_hostname(""), "boş olmaz"); + assert!(!is_valid_hostname("-pisi"), "tire ile başlayamaz"); + assert!(!is_valid_hostname("pisi-"), "tire ile bitemez"); + assert!(!is_valid_hostname("pisi pc"), "boşluk olmaz"); + assert!(!is_valid_hostname("pisi_pc"), "alt çizgi olmaz"); + assert!(!is_valid_hostname(&"a".repeat(64)), "63'ten uzun olmaz"); + assert!(is_valid_hostname(&"a".repeat(63)), "63 kabul edilir"); + } + + #[test] + fn derive_username_transliterates_turkish() { + assert_eq!(derive_username("Şükrü Çelik"), "sukru-celik"); + assert_eq!(derive_username("Çağrı"), "cagri"); + assert_eq!(derive_username("İpek"), "ipek"); + } + + #[test] + fn derive_username_never_returns_empty() { + // Yalnızca geçersiz karakterlerden oluşan isimde boş dönmemeli. + assert_eq!(derive_username("!!!"), "user"); + assert_eq!(derive_username(" "), "user"); + assert_eq!(derive_username(""), "user"); + } + + #[test] + fn derive_username_caps_length() { + let long = "a".repeat(50); + assert_eq!(derive_username(&long).len(), 32); + } +}